mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Revert "Merge pull request #424 from Wangnov/feat/i18n"
This reverts commit1d915d8327, reversing changes made to009f7c84f6.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,7 @@
|
||||
>
|
||||
<i class="fas fa-layer-group text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">
|
||||
{{ t('groupManagement.title') }}
|
||||
</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">账户分组管理</h3>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@@ -27,32 +25,26 @@
|
||||
<div class="mb-6">
|
||||
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
|
||||
<i class="fas fa-plus mr-2" />
|
||||
{{ t('groupManagement.createNewGroup') }}
|
||||
创建新分组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建分组表单 -->
|
||||
<div v-if="showCreateForm" class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ t('groupManagement.createGroup') }}
|
||||
</h4>
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-900">创建新分组</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.groupNameRequired')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('groupManagement.groupNamePlaceholder')"
|
||||
placeholder="输入分组名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.platformTypeRequired')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
|
||||
@@ -70,13 +62,11 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.descriptionOptional')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
class="form-input w-full resize-none"
|
||||
:placeholder="t('groupManagement.descriptionPlaceholder')"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
@@ -88,11 +78,9 @@
|
||||
@click="createGroup"
|
||||
>
|
||||
<div v-if="creating" class="loading-spinner mr-2" />
|
||||
{{ creating ? t('groupManagement.creating') : t('groupManagement.create') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">
|
||||
{{ t('groupManagement.cancel') }}
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,12 +89,12 @@
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="py-8 text-center">
|
||||
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||
<p class="text-gray-500">{{ t('groupManagement.loading') }}</p>
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="groups.length === 0" class="rounded-lg bg-gray-50 py-8 text-center">
|
||||
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300" />
|
||||
<p class="text-gray-500">{{ t('groupManagement.noGroups') }}</p>
|
||||
<p class="text-gray-500">暂无分组</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -121,7 +109,7 @@
|
||||
{{ group.name }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ group.description || t('groupManagement.noDescription') }}
|
||||
{{ group.description || '暂无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center gap-2">
|
||||
@@ -150,7 +138,7 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<span>
|
||||
<i class="fas fa-users mr-1" />
|
||||
{{ group.memberCount || 0 }}{{ t('groupManagement.membersCount') }}
|
||||
{{ group.memberCount || 0 }} 个成员
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" />
|
||||
@@ -160,7 +148,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-blue-600 transition-colors hover:text-blue-800"
|
||||
:title="t('groupManagement.edit')"
|
||||
title="编辑"
|
||||
@click="editGroup(group)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
@@ -168,7 +156,7 @@
|
||||
<button
|
||||
class="text-red-600 transition-colors hover:text-red-800"
|
||||
:disabled="group.memberCount > 0"
|
||||
:title="t('groupManagement.delete')"
|
||||
title="删除"
|
||||
@click="deleteGroup(group)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
@@ -188,7 +176,7 @@
|
||||
>
|
||||
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900">{{ t('groupManagement.editGroup') }}</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
|
||||
<button class="text-gray-400 transition-colors hover:text-gray-600" @click="cancelEdit">
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
@@ -196,21 +184,17 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.groupNameRequired')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('groupManagement.groupNamePlaceholder')"
|
||||
placeholder="输入分组名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.platformTypeLabel')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型</label>
|
||||
<div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
|
||||
{{
|
||||
editForm.platform === 'claude'
|
||||
@@ -219,20 +203,16 @@
|
||||
? 'Gemini'
|
||||
: 'OpenAI'
|
||||
}}
|
||||
<span class="ml-2 text-xs text-gray-500">{{
|
||||
t('groupManagement.cannotModify')
|
||||
}}</span>
|
||||
<span class="ml-2 text-xs text-gray-500">(不可修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.descriptionOptional')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
class="form-input w-full resize-none"
|
||||
:placeholder="t('groupManagement.descriptionPlaceholder')"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
@@ -244,11 +224,9 @@
|
||||
@click="updateGroup"
|
||||
>
|
||||
<div v-if="updating" class="loading-spinner mr-2" />
|
||||
{{ updating ? t('groupManagement.updating') : t('groupManagement.update') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">
|
||||
{{ t('groupManagement.cancel') }}
|
||||
{{ updating ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,12 +236,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits(['close', 'refresh'])
|
||||
|
||||
const show = ref(true)
|
||||
@@ -303,7 +278,7 @@ const loadGroups = async () => {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast(t('groupManagement.loadGroupsFailed'), 'error')
|
||||
showToast('加载分组列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -312,7 +287,7 @@ const loadGroups = async () => {
|
||||
// 创建分组
|
||||
const createGroup = async () => {
|
||||
if (!createForm.value.name || !createForm.value.platform) {
|
||||
showToast(t('groupManagement.fillRequiredFields'), 'error')
|
||||
showToast('请填写必填项', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -324,12 +299,12 @@ const createGroup = async () => {
|
||||
description: createForm.value.description
|
||||
})
|
||||
|
||||
showToast(t('groupManagement.groupCreated'), 'success')
|
||||
showToast('分组创建成功', 'success')
|
||||
cancelCreate()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || t('groupManagement.createGroupFailed'), 'error')
|
||||
showToast(error.response?.data?.error || '创建分组失败', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -359,7 +334,7 @@ const editGroup = (group) => {
|
||||
// 更新分组
|
||||
const updateGroup = async () => {
|
||||
if (!editForm.value.name) {
|
||||
showToast(t('groupManagement.fillGroupName'), 'error')
|
||||
showToast('请填写分组名称', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -370,12 +345,12 @@ const updateGroup = async () => {
|
||||
description: editForm.value.description
|
||||
})
|
||||
|
||||
showToast(t('groupManagement.groupUpdated'), 'success')
|
||||
showToast('分组更新成功', 'success')
|
||||
cancelEdit()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || t('groupManagement.updateGroupFailed'), 'error')
|
||||
showToast(error.response?.data?.error || '更新分组失败', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
@@ -395,21 +370,21 @@ const cancelEdit = () => {
|
||||
// 删除分组
|
||||
const deleteGroup = async (group) => {
|
||||
if (group.memberCount > 0) {
|
||||
showToast(t('groupManagement.groupHasMembers'), 'error')
|
||||
showToast('分组内还有成员,无法删除', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(t('groupManagement.confirmDelete', { name: group.name }))) {
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
||||
showToast(t('groupManagement.groupDeleted'), 'success')
|
||||
showToast('分组删除成功', 'success')
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || t('groupManagement.deleteGroupFailed'), 'error')
|
||||
showToast(error.response?.data?.error || '删除分组失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
<i class="fas fa-link text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.claudeAccountAuth') }}
|
||||
</h4>
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ t('oauthFlow.claudeAuthDescription') }}
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -32,7 +30,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.step1Title') }}
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -42,7 +40,7 @@
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }}
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -54,7 +52,7 @@
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="t('oauthFlow.copyLinkTooltip')"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
@@ -64,7 +62,7 @@
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,18 +81,18 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.step2Title') }}
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('oauthFlow.step2Description') }}
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>{{ t('oauthFlow.proxyNotice') }}</strong
|
||||
>{{ t('oauthFlow.proxyNoticeText') }}
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,32 +111,29 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.step3Title') }}
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('oauthFlow.step3Description') }}
|
||||
<strong>{{ t('oauthFlow.authorizationCode') }}</strong
|
||||
>{{ t('oauthFlow.step3DescriptionMiddle') }}
|
||||
授权完成后,页面会显示一个
|
||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-blue-500" />{{
|
||||
t('oauthFlow.authorizationCode')
|
||||
}}
|
||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
:placeholder="t('oauthFlow.authCodePlaceholder')"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('oauthFlow.authCodeHint') }}
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,11 +157,9 @@
|
||||
<i class="fas fa-robot text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.geminiAccountAuth') }}
|
||||
</h4>
|
||||
<h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">Gemini 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-green-800 dark:text-green-300">
|
||||
{{ t('oauthFlow.geminiAuthDescription') }}
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -182,7 +175,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.step1Title') }}
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -192,7 +185,7 @@
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }}
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -204,7 +197,7 @@
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="t('oauthFlow.copyLinkTooltip')"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
@@ -214,7 +207,7 @@
|
||||
class="text-xs text-green-600 hover:text-green-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,18 +226,18 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.step2Title') }}
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('oauthFlow.step2DescriptionGemini') }}
|
||||
请在新标签页中打开授权链接,登录您的 Gemini 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>{{ t('oauthFlow.proxyNotice') }}</strong
|
||||
>{{ t('oauthFlow.proxyNoticeText') }}
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,31 +256,29 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.step3Title') }}
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('oauthFlow.step3DescriptionGemini') }}
|
||||
授权完成后,页面会显示一个 Authorization Code,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-green-500" />{{
|
||||
t('oauthFlow.authorizationCode')
|
||||
}}
|
||||
<i class="fas fa-key mr-2 text-green-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
:placeholder="t('oauthFlow.authCodePlaceholderGemini')"
|
||||
placeholder="粘贴从Gemini页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-check-circle mr-1 text-green-500" />
|
||||
{{ t('oauthFlow.authCodeHintGemini') }}
|
||||
请粘贴从Gemini页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,11 +303,9 @@
|
||||
<i class="fas fa-brain text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.openaiAccountAuth') }}
|
||||
</h4>
|
||||
<h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">OpenAI 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-orange-800 dark:text-orange-300">
|
||||
{{ t('oauthFlow.openaiAuthDescription') }}
|
||||
请按照以下步骤完成 OpenAI 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -332,7 +321,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.step1Title') }}
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -342,7 +331,7 @@
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }}
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -354,7 +343,7 @@
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="t('oauthFlow.copyLinkTooltip')"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
@@ -364,7 +353,7 @@
|
||||
class="text-xs text-orange-600 hover:text-orange-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,23 +372,22 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.step2Title') }}
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-orange-700 dark:text-orange-300">
|
||||
{{ t('oauthFlow.step2DescriptionOpenAI') }}
|
||||
请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="mb-3 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<p class="text-xs text-amber-800 dark:text-amber-300">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
<strong>{{ t('oauthFlow.openaiImportantNote') }}</strong
|
||||
>{{ t('oauthFlow.openaiLoadingNote') }}
|
||||
<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('oauthFlow.openaiAddressNote') }}
|
||||
当浏览器地址栏变为
|
||||
<strong class="font-mono">http://localhost:1455/...</strong>
|
||||
{{ t('oauthFlow.openaiAddressNoteMiddle') }}
|
||||
开头时,表示授权已完成。
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -407,8 +395,8 @@
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>{{ t('oauthFlow.proxyNotice') }}</strong
|
||||
>{{ t('oauthFlow.proxyNoticeText') }}
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,26 +415,23 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.step3TitleOpenAI') }}
|
||||
输入授权链接或 Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-orange-700 dark:text-orange-300">
|
||||
{{ t('oauthFlow.step3DescriptionOpenAI') }}
|
||||
<strong class="font-mono">http://localhost:1455/...</strong>
|
||||
{{ t('oauthFlow.step3DescriptionOpenAIMiddle') }}
|
||||
授权完成后,当页面地址变为
|
||||
<strong class="font-mono">http://localhost:1455/...</strong> 时:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-link mr-2 text-orange-500" />{{
|
||||
t('oauthFlow.authLinkOrCode')
|
||||
}}
|
||||
<i class="fas fa-link mr-2 text-orange-500" />授权链接或 Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
:placeholder="t('oauthFlow.authCodePlaceholderOpenAI')"
|
||||
placeholder="方式1:复制完整的链接(http://localhost:1455/auth/callback?code=...) 方式2:仅复制 code 参数的值 系统会自动识别并提取所需信息"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
@@ -455,18 +440,18 @@
|
||||
>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<i class="fas fa-lightbulb mr-1" />
|
||||
<strong>{{ t('oauthFlow.openaiTip') }}</strong
|
||||
>{{ t('oauthFlow.openaiTipText') }}
|
||||
<strong>提示:</strong>您可以直接复制整个链接或仅复制 code
|
||||
参数值,系统会自动识别。
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t('oauthFlow.openaiLinkExample')
|
||||
}}<span class="font-mono"
|
||||
• 完整链接示例:<span class="font-mono"
|
||||
>http://localhost:1455/auth/callback?code=ac_4hm8...</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-blue-600">
|
||||
{{ t('oauthFlow.openaiCodeExample')
|
||||
}}<span class="font-mono">ac_4hm8iqmx9A2fzMy_cwye7U3W7...</span>
|
||||
• 仅 Code 示例:<span class="font-mono"
|
||||
>ac_4hm8iqmx9A2fzMy_cwye7U3W7...</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +470,7 @@
|
||||
type="button"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ t('oauthFlow.previousStep') }}
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -494,7 +479,7 @@
|
||||
@click="exchangeCode"
|
||||
>
|
||||
<div v-if="exchanging" class="loading-spinner mr-2" />
|
||||
{{ exchanging ? t('oauthFlow.verifying') : t('oauthFlow.completeAuth') }}
|
||||
{{ exchanging ? '验证中...' : '完成授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -502,12 +487,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
@@ -562,16 +544,16 @@ watch(authCode, (newValue) => {
|
||||
if (code) {
|
||||
// 成功提取授权码
|
||||
authCode.value = code
|
||||
showToast(t('oauthFlow.successExtractCode'), 'success')
|
||||
showToast('成功提取授权码!', 'success')
|
||||
console.log('Successfully extracted authorization code from URL')
|
||||
} else {
|
||||
// URL 中没有 code 参数
|
||||
showToast(t('oauthFlow.errorCodeNotFound'), 'error')
|
||||
showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
// URL 解析失败
|
||||
console.error('Failed to parse URL:', error)
|
||||
showToast(t('oauthFlow.errorLinkFormat'), 'error')
|
||||
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
||||
}
|
||||
} else if (props.platform === 'gemini' || props.platform === 'openai') {
|
||||
// Gemini 和 OpenAI 平台可能使用不同的回调URL
|
||||
@@ -582,14 +564,14 @@ watch(authCode, (newValue) => {
|
||||
|
||||
if (code) {
|
||||
authCode.value = code
|
||||
showToast(t('oauthFlow.successExtractCode'), 'success')
|
||||
showToast('成功提取授权码!', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
// 不是有效的URL,保持原值
|
||||
}
|
||||
} else {
|
||||
// 错误的 URL(不是正确的 localhost 回调地址)
|
||||
showToast(t('oauthFlow.errorWrongUrlFormat'), 'error')
|
||||
showToast('请粘贴以 http://localhost:1455 或 http://localhost:45462 开头的链接', 'error')
|
||||
}
|
||||
}
|
||||
// 如果不是 URL,保持原值(兼容直接输入授权码)
|
||||
@@ -625,7 +607,7 @@ const generateAuthUrl = async () => {
|
||||
sessionId.value = result.sessionId
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || t('oauthFlow.generateAuthFailed'), 'error')
|
||||
showToast(error.message || '生成授权链接失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -643,7 +625,7 @@ const copyAuthUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl.value)
|
||||
copied.value = true
|
||||
showToast(t('oauthFlow.linkCopied'), 'success')
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
@@ -656,7 +638,7 @@ const copyAuthUrl = async () => {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
showToast(t('oauthFlow.linkCopied'), 'success')
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
@@ -713,7 +695,7 @@ const exchangeCode = async () => {
|
||||
|
||||
emit('success', tokenInfo)
|
||||
} catch (error) {
|
||||
showToast(error.message || t('oauthFlow.authFailed'), 'error')
|
||||
showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
|
||||
} finally {
|
||||
exchanging.value = false
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ t('proxyConfig.title') }}
|
||||
</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">代理设置 (可选)</h4>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="proxy.enabled"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.enableProxy')
|
||||
}}</span>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">启用代理</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +22,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('proxyConfig.configDescription') }}
|
||||
配置代理以访问受限的网络资源。支持 SOCKS5 和 HTTP 代理。
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('proxyConfig.stabilityNotice') }}
|
||||
请确保代理服务器稳定可用,否则会影响账户的正常使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,9 +70,9 @@
|
||||
<div class="my-3 border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.proxyType')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>代理类型</label
|
||||
>
|
||||
<select
|
||||
v-model="proxy.type"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@@ -89,24 +85,24 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.hostAddress')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>主机地址</label
|
||||
>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.hostPlaceholder')"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.port')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>端口</label
|
||||
>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.portPlaceholder')"
|
||||
placeholder="例如: 1080"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,31 +120,31 @@
|
||||
class="ml-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
|
||||
for="proxyAuth"
|
||||
>
|
||||
{{ t('proxyConfig.needsAuth') }}
|
||||
需要身份验证
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.username')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>用户名</label
|
||||
>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.usernamePlaceholder')"
|
||||
placeholder="代理用户名"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.password')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>密码</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.passwordPlaceholder')"
|
||||
placeholder="代理密码"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
/>
|
||||
<button
|
||||
@@ -168,8 +164,8 @@
|
||||
>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<strong>{{ t('proxyConfig.tip') }}</strong
|
||||
>{{ t('proxyConfig.apiRequestNotice') }}
|
||||
<strong>提示:</strong
|
||||
>代理设置将用于所有与此账户相关的API请求。请确保代理服务器支持HTTPS流量转发。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,9 +174,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ $t('user.changeRoleModal.title') }}</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">Change User Role</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -54,7 +54,7 @@
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
]"
|
||||
>
|
||||
{{ $t('user.changeRoleModal.currentRole', { role: user.role }) }}
|
||||
Current: {{ user.role }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,9 +64,7 @@
|
||||
<!-- Role Selection -->
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{{ $t('user.changeRoleModal.newRole') }}
|
||||
</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
@@ -77,12 +75,8 @@
|
||||
value="user"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $t('user.changeRoleModal.roles.user') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ $t('user.changeRoleModal.roles.userDesc') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">User</div>
|
||||
<div class="text-xs text-gray-500">Regular user with basic permissions</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
@@ -94,12 +88,8 @@
|
||||
value="admin"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $t('user.changeRoleModal.roles.admin') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ $t('user.changeRoleModal.roles.adminDesc') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">Administrator</div>
|
||||
<div class="text-xs text-gray-500">Full access to manage users and system</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -121,15 +111,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
{{ $t('user.changeRoleModal.roleChangeWarning.title') }}
|
||||
</h3>
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p v-if="selectedRole === 'admin'">
|
||||
{{ $t('user.changeRoleModal.roleChangeWarning.grantAdmin') }}
|
||||
Granting admin privileges will give this user full access to the system,
|
||||
including the ability to manage other users and their API keys.
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ $t('user.changeRoleModal.roleChangeWarning.removeAdmin') }}
|
||||
Removing admin privileges will restrict this user to only managing their own
|
||||
API keys and viewing their own usage statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +150,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('user.changeRoleModal.cancel') }}
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -188,9 +178,9 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{{ $t('user.changeRoleModal.updating') }}
|
||||
Updating...
|
||||
</span>
|
||||
<span v-else>{{ $t('user.changeRoleModal.updateRole') }}</span>
|
||||
<span v-else>Update Role</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -204,9 +194,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -239,7 +226,7 @@ const handleSubmit = async () => {
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast(t('user.changeRoleModal.roleUpdated', { role: selectedRole.value }), 'success')
|
||||
showToast(`User role updated to ${selectedRole.value}`, 'success')
|
||||
emit('updated')
|
||||
} else {
|
||||
error.value = response.message || 'Failed to update user role'
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
{{
|
||||
$t('user.usageStatsModal.titleWithUser', {
|
||||
displayName: user?.displayName || user?.username
|
||||
})
|
||||
}}
|
||||
Usage Statistics - {{ user?.displayName || user?.username }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">@{{ user?.username }} • {{ user?.role }}</p>
|
||||
</div>
|
||||
@@ -35,12 +31,10 @@
|
||||
class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
@change="loadUsageStats"
|
||||
>
|
||||
<option value="day">{{ $t('user.usageStatsModal.periodSelection.day') }}</option>
|
||||
<option value="week">{{ $t('user.usageStatsModal.periodSelection.week') }}</option>
|
||||
<option value="month">{{ $t('user.usageStatsModal.periodSelection.month') }}</option>
|
||||
<option value="quarter">
|
||||
{{ $t('user.usageStatsModal.periodSelection.quarter') }}
|
||||
</option>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
<option value="month">Last 30 Days</option>
|
||||
<option value="quarter">Last 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +60,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">{{ $t('user.usageStatsModal.loadingStats') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Content -->
|
||||
@@ -93,9 +87,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-blue-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.requests') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
|
||||
<dd class="text-lg font-medium text-blue-900">
|
||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||
</dd>
|
||||
@@ -125,9 +117,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-green-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.inputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
|
||||
<dd class="text-lg font-medium text-green-900">
|
||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -157,9 +147,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-purple-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.outputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
|
||||
<dd class="text-lg font-medium text-purple-900">
|
||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -189,9 +177,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-yellow-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.totalCost') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
|
||||
<dd class="text-lg font-medium text-yellow-900">
|
||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||
</dd>
|
||||
@@ -208,9 +194,7 @@
|
||||
class="rounded-lg border border-gray-200 bg-white"
|
||||
>
|
||||
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.title') }}
|
||||
</h4>
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -220,37 +204,37 @@
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.apiKey') }}
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.status') }}
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.requests') }}
|
||||
Requests
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.tokens') }}
|
||||
Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.cost') }}
|
||||
Cost
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.lastUsed') }}
|
||||
Last Used
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -269,35 +253,21 @@
|
||||
: 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
apiKey.isActive
|
||||
? $t('user.usageStatsModal.apiKeysTable.status.active')
|
||||
: $t('user.usageStatsModal.apiKeysTable.status.disabled')
|
||||
}}
|
||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
<div>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.tokensFormat.input') }}:
|
||||
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.tokensFormat.output') }}:
|
||||
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
|
||||
</div>
|
||||
<div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
|
||||
<div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{{
|
||||
apiKey.lastUsedAt
|
||||
? formatDate(apiKey.lastUsedAt)
|
||||
: $t('user.usageStatsModal.apiKeysTable.never')
|
||||
}}
|
||||
{{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -308,9 +278,7 @@
|
||||
<!-- Chart Placeholder -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">
|
||||
{{ $t('user.usageStatsModal.usageTrend.title') }}
|
||||
</h4>
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div
|
||||
@@ -330,16 +298,12 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ $t('user.usageStatsModal.usageTrend.chartTitle') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{
|
||||
$t('user.usageStatsModal.usageTrend.dailyTrends', { period: selectedPeriod })
|
||||
}}
|
||||
Daily usage trends for {{ selectedPeriod }} period
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{{ $t('user.usageStatsModal.usageTrend.chartNote') }}
|
||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,11 +325,9 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ $t('user.usageStatsModal.noData.title') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ $t('user.usageStatsModal.noData.description') }}
|
||||
This user hasn't made any API requests in the selected period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,7 +337,7 @@
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.close') }}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +349,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
// import { useI18n } from 'vue-i18n' - using $t in template instead
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
||||
@@ -12,17 +12,13 @@
|
||||
<i class="fas fa-layer-group text-lg text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
{{ $t('apiKeys.batchApiKeyModal.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.successMessage', { count: apiKeys.length }) }}
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900">批量创建成功</h3>
|
||||
<p class="text-sm text-gray-600">成功创建 {{ apiKeys.length }} 个 API Key</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
:title="$t('apiKeys.batchApiKeyModal.directCloseTooltip')"
|
||||
title="直接关闭(不推荐)"
|
||||
@click="handleDirectClose"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
@@ -38,11 +34,10 @@
|
||||
<i class="fas fa-exclamation-triangle text-sm text-white" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h5 class="mb-1 font-semibold text-amber-900">
|
||||
{{ $t('apiKeys.batchApiKeyModal.importantReminder') }}
|
||||
</h5>
|
||||
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
|
||||
<p class="text-sm text-amber-800">
|
||||
{{ $t('apiKeys.batchApiKeyModal.warningMessage') }}
|
||||
这是您唯一能看到所有 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API
|
||||
Key。请立即下载并妥善保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,9 +50,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-blue-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.createdCount') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-blue-600">创建数量</p>
|
||||
<p class="mt-1 text-2xl font-bold text-blue-900">
|
||||
{{ apiKeys.length }}
|
||||
</p>
|
||||
@@ -75,9 +68,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-green-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.baseName') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-green-600">基础名称</p>
|
||||
<p class="mt-1 truncate text-lg font-bold text-green-900">
|
||||
{{ baseName }}
|
||||
</p>
|
||||
@@ -95,9 +86,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-purple-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.permissionScope') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-purple-600">权限范围</p>
|
||||
<p class="mt-1 text-lg font-bold text-purple-900">
|
||||
{{ getPermissionText() }}
|
||||
</p>
|
||||
@@ -115,9 +104,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-orange-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.expiryTime') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-orange-600">过期时间</p>
|
||||
<p class="mt-1 text-lg font-bold text-orange-900">
|
||||
{{ getExpiryText() }}
|
||||
</p>
|
||||
@@ -134,9 +121,7 @@
|
||||
<!-- API Keys 预览 -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700">{{
|
||||
$t('apiKeys.batchApiKeyModal.previewTitle')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700">API Keys 预览</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
@@ -144,15 +129,9 @@
|
||||
@click="togglePreview"
|
||||
>
|
||||
<i :class="['fas', showPreview ? 'fa-eye-slash' : 'fa-eye']" />
|
||||
{{
|
||||
showPreview
|
||||
? $t('apiKeys.batchApiKeyModal.hide')
|
||||
: $t('apiKeys.batchApiKeyModal.show')
|
||||
}}{{ $t('apiKeys.batchApiKeyModal.preview') }}
|
||||
{{ showPreview ? '隐藏' : '显示' }}预览
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">{{
|
||||
$t('apiKeys.batchApiKeyModal.maxDisplayNote')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500">(最多显示前10个)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,13 +150,13 @@
|
||||
@click="downloadApiKeys"
|
||||
>
|
||||
<i class="fas fa-download" />
|
||||
{{ $t('apiKeys.batchApiKeyModal.downloadAll') }}
|
||||
下载所有 API Keys
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ $t('apiKeys.batchApiKeyModal.alreadySaved') }}
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +165,8 @@
|
||||
<p class="flex items-start text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{{ $t('apiKeys.batchApiKeyModal.fileFormatInfo') }}
|
||||
下载的文件格式为文本文件(.txt),每行包含一个 API Key。
|
||||
请将文件保存在安全的位置,避免泄露。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -197,11 +177,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
apiKeys: {
|
||||
type: Array,
|
||||
@@ -226,28 +203,30 @@ const baseName = computed(() => {
|
||||
|
||||
// 获取权限文本
|
||||
const getPermissionText = () => {
|
||||
if (props.apiKeys.length === 0) return t('apiKeys.batchApiKeyModal.permissions.unknown')
|
||||
if (props.apiKeys.length === 0) return '未知'
|
||||
const permissions = props.apiKeys[0].permissions
|
||||
const permissionKey = `apiKeys.batchApiKeyModal.permissions.${permissions}`
|
||||
return t(permissionKey, t('apiKeys.batchApiKeyModal.permissions.unknown'))
|
||||
const permissionMap = {
|
||||
all: '全部服务',
|
||||
claude: '仅 Claude',
|
||||
gemini: '仅 Gemini'
|
||||
}
|
||||
return permissionMap[permissions] || permissions
|
||||
}
|
||||
|
||||
// 获取过期时间文本
|
||||
const getExpiryText = () => {
|
||||
if (props.apiKeys.length === 0) return t('apiKeys.batchApiKeyModal.permissions.unknown')
|
||||
if (props.apiKeys.length === 0) return '未知'
|
||||
const expiresAt = props.apiKeys[0].expiresAt
|
||||
if (!expiresAt) return t('apiKeys.batchApiKeyModal.neverExpire')
|
||||
if (!expiresAt) return '永不过期'
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 7) return t('apiKeys.batchApiKeyModal.daysFormat', { days: diffDays })
|
||||
if (diffDays <= 30)
|
||||
return t('apiKeys.batchApiKeyModal.weeksFormat', { weeks: Math.ceil(diffDays / 7) })
|
||||
if (diffDays <= 365)
|
||||
return t('apiKeys.batchApiKeyModal.monthsFormat', { months: Math.ceil(diffDays / 30) })
|
||||
return t('apiKeys.batchApiKeyModal.yearsFormat', { years: Math.ceil(diffDays / 365) })
|
||||
if (diffDays <= 7) return `${diffDays}天`
|
||||
if (diffDays <= 30) return `${Math.ceil(diffDays / 7)}周`
|
||||
if (diffDays <= 365) return `${Math.ceil(diffDays / 30)}个月`
|
||||
return `${Math.ceil(diffDays / 365)}年`
|
||||
}
|
||||
|
||||
// 切换预览显示
|
||||
@@ -263,7 +242,7 @@ const getPreviewText = () => {
|
||||
})
|
||||
|
||||
if (props.apiKeys.length > 10) {
|
||||
lines.push(t('apiKeys.batchApiKeyModal.moreKeysNote', { count: props.apiKeys.length - 10 }))
|
||||
lines.push(`... 还有 ${props.apiKeys.length - 10} 个 API Key`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
@@ -298,24 +277,26 @@ const downloadApiKeys = () => {
|
||||
// 释放 URL 对象
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
showToast(t('apiKeys.batchApiKeyModal.downloadSuccess'), 'success')
|
||||
showToast('API Keys 文件已下载', 'success')
|
||||
}
|
||||
|
||||
// 关闭弹窗(带确认)
|
||||
const handleClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.batchApiKeyModal.closeReminderTitle'),
|
||||
t('apiKeys.batchApiKeyModal.closeReminderMessage'),
|
||||
t('apiKeys.batchApiKeyModal.confirmCloseButton'),
|
||||
t('apiKeys.batchApiKeyModal.goBackDownloadButton')
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'返回下载'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.batchApiKeyModal.closeReminderMessage'))
|
||||
const confirmed = confirm(
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
@@ -326,17 +307,17 @@ const handleClose = async () => {
|
||||
const handleDirectClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.batchApiKeyModal.directCloseTitle'),
|
||||
t('apiKeys.batchApiKeyModal.directCloseMessage'),
|
||||
t('apiKeys.batchApiKeyModal.stillCloseButton'),
|
||||
t('apiKeys.batchApiKeyModal.goBackDownloadButton')
|
||||
'确定要关闭吗?',
|
||||
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
|
||||
'仍然关闭',
|
||||
'返回下载'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.batchApiKeyModal.directCloseFallbackMessage'))
|
||||
const confirmed = confirm('您还没有下载 API Keys,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<i class="fas fa-edit text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.title', { count: selectedCount }) }}
|
||||
批量编辑 API Keys ({{ selectedCount }} 个)
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -32,11 +32,10 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-info-circle mt-1 text-blue-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.infoTitle') }}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">批量编辑说明</p>
|
||||
<p class="mt-1 text-sm text-blue-700 dark:text-blue-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.infoContent', { count: selectedCount }) }}
|
||||
以下设置将应用到所选的 {{ selectedCount }} 个 API
|
||||
Key。只有填写或修改的字段才会被更新,空白字段将保持原值不变。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,34 +46,26 @@
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.tagLabel') }}
|
||||
标签 (批量操作)
|
||||
</label>
|
||||
<div class="space-y-4">
|
||||
<!-- 标签操作模式选择 -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="replace" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.replace')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">替换标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="add" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.add')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">添加标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="remove" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.remove')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">移除标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="none" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.none')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">不修改标签</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -85,10 +76,10 @@
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{
|
||||
tagOperation === 'replace'
|
||||
? $t('apiKeys.batchEditApiKeyModal.newTagsList')
|
||||
? '新标签列表:'
|
||||
: tagOperation === 'add'
|
||||
? $t('apiKeys.batchEditApiKeyModal.tagsToAdd')
|
||||
: $t('apiKeys.batchEditApiKeyModal.tagsToRemove')
|
||||
? '要添加的标签:'
|
||||
: '要移除的标签:'
|
||||
}}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -112,7 +103,7 @@
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.clickToSelectTags') }}
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -131,13 +122,13 @@
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.createNewTag') }}
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.inputNewTagPlaceholder')"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
@@ -164,48 +155,46 @@
|
||||
>
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.rateLimitTitle') }}
|
||||
</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">速率限制设置</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.rateLimitWindow') }}
|
||||
时间窗口 (分钟)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.rateLimitRequests')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>请求次数限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.rateLimitCost')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')"
|
||||
placeholder="不修改"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
@@ -217,13 +206,13 @@
|
||||
<!-- 每日费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.dailyCostLimit') }}
|
||||
每日费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.dailyCostLimitPlaceholder')"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
@@ -232,31 +221,31 @@
|
||||
<!-- Opus 模型周费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.weeklyOpusCostLimit') }}
|
||||
Opus 模型周费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.weeklyOpusCostLimitPlaceholder')"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.opusLimitDescription') }}
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 并发限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.concurrencyLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.concurrencyLimitPlaceholder')"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -264,27 +253,19 @@
|
||||
<!-- 激活状态 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.activeStatus')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">激活状态</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="true" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.statusOptions.active')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">激活</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="false" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.statusOptions.disabled')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">禁用</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="null" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.statusOptions.noChange')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">不修改</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,39 +273,29 @@
|
||||
|
||||
<!-- 服务权限 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.servicePermissions')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.noChange')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">不修改</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="all" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.all')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.claude')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.gemini')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.openai')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,13 +303,13 @@
|
||||
<!-- 专属账号绑定 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.accountBinding')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>专属账号绑定</label
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:disabled="accountsLoading"
|
||||
:title="$t('apiKeys.batchEditApiKeyModal.refreshAccounts')"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
@@ -349,46 +320,31 @@
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{
|
||||
accountsLoading
|
||||
? $t('apiKeys.batchEditApiKeyModal.refreshing')
|
||||
: $t('apiKeys.batchEditApiKeyModal.refreshAccounts')
|
||||
}}</span>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.claudeAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Claude 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.claudeGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix')
|
||||
}}{{ group.name }}
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<optgroup v-if="localAccounts.claude.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.claude"
|
||||
:key="account.id"
|
||||
@@ -404,37 +360,26 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.geminiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Gemini 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.geminiGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix')
|
||||
}}{{ group.name }}
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<optgroup v-if="localAccounts.gemini.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.gemini"
|
||||
:key="account.id"
|
||||
@@ -446,37 +391,26 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.openaiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>OpenAI 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.openaiAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.openaiGroups.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.openaiGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.openaiGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix')
|
||||
}}{{ group.name }}
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.openai.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<optgroup v-if="localAccounts.openai.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.openai"
|
||||
:key="account.id"
|
||||
@@ -488,24 +422,17 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.bedrockAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Bedrock 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.bedrockAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.bedrock.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.bedrock.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.bedrock"
|
||||
:key="account.id"
|
||||
@@ -525,7 +452,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -534,11 +461,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
loading
|
||||
? $t('apiKeys.batchEditApiKeyModal.saving')
|
||||
: $t('apiKeys.batchEditApiKeyModal.batchSave')
|
||||
}}
|
||||
{{ loading ? '保存中...' : '批量保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -549,13 +472,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
selectedKeys: {
|
||||
type: Array,
|
||||
@@ -700,9 +620,9 @@ const refreshAccounts = async () => {
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.refreshAccountsSuccess'), 'success')
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.refreshAccountsFailed'), 'error')
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -802,33 +722,24 @@ const batchUpdateApiKeys = async () => {
|
||||
const { successCount, failedCount, errors } = result.data
|
||||
|
||||
if (successCount > 0) {
|
||||
showToast(
|
||||
t('apiKeys.batchEditApiKeyModal.batchEditSuccess', { count: successCount }),
|
||||
'success'
|
||||
)
|
||||
showToast(`成功批量编辑 ${successCount} 个 API Keys`, 'success')
|
||||
|
||||
if (failedCount > 0) {
|
||||
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
|
||||
showToast(
|
||||
t('apiKeys.batchEditApiKeyModal.batchEditPartialFail', {
|
||||
failedCount,
|
||||
errors: errorMessages
|
||||
}),
|
||||
'warning'
|
||||
)
|
||||
showToast(`${failedCount} 个编辑失败:\n${errorMessages}`, 'warning')
|
||||
}
|
||||
} else {
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.batchEditAllFailed'), 'error')
|
||||
showToast('所有 API Keys 编辑失败', 'error')
|
||||
}
|
||||
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.batchEditApiKeyModal.batchEditFailed'), 'error')
|
||||
showToast(result.message || '批量编辑失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.batchEditFailed'), 'error')
|
||||
console.error(t('apiKeys.batchEditApiKeyModal.batchEditErrorLog'), error)
|
||||
showToast('批量编辑失败', 'error')
|
||||
console.error('批量编辑 API Keys 失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<i class="fas fa-key text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ $t('apiKeys.createApiKeyModal.title') }}
|
||||
创建新的 API Key
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -37,7 +37,7 @@
|
||||
>
|
||||
<label
|
||||
class="flex h-full items-center text-xs font-semibold text-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
>{{ $t('apiKeys.createApiKeyModal.createType') }}</label
|
||||
>创建类型</label
|
||||
>
|
||||
<div class="flex items-center gap-3 sm:gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
@@ -51,7 +51,7 @@
|
||||
class="flex items-center text-xs text-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
>
|
||||
<i class="fas fa-key mr-1 text-xs" />
|
||||
{{ $t('apiKeys.createApiKeyModal.singleCreate') }}
|
||||
单个创建
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
@@ -65,7 +65,7 @@
|
||||
class="flex items-center text-xs text-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
>
|
||||
<i class="fas fa-layer-group mr-1 text-xs" />
|
||||
{{ $t('apiKeys.createApiKeyModal.batchCreate') }}
|
||||
批量创建
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -75,30 +75,32 @@
|
||||
<div v-if="form.createType === 'batch'" class="mt-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.batchCount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>创建数量</label
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="form.batchCount"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
max="500"
|
||||
min="2"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.batchCountPlaceholder')"
|
||||
placeholder="输入数量 (2-500)"
|
||||
required
|
||||
type="number"
|
||||
/>
|
||||
<div class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.maxSupported') }}
|
||||
最大支持 500 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 flex items-start text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-info-circle mr-1 mt-0.5 flex-shrink-0" />
|
||||
<span>{{
|
||||
$t('apiKeys.createApiKeyModal.batchHint', { name: form.name || 'MyKey' })
|
||||
}}</span>
|
||||
<span
|
||||
>批量创建时,每个 Key 的名称会自动添加序号后缀,例如:{{
|
||||
form.name || 'MyKey'
|
||||
}}_1, {{ form.name || 'MyKey' }}_2 ...</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,24 +108,23 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
|
||||
>{{ $t('apiKeys.createApiKeyModal.name') }}
|
||||
<span class="text-red-500">{{
|
||||
$t('apiKeys.createApiKeyModal.nameRequired')
|
||||
}}</span></label
|
||||
>名称 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? $t('apiKeys.createApiKeyModal.batchNamePlaceholder')
|
||||
: $t('apiKeys.createApiKeyModal.singleNamePlaceholder')
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? '输入基础名称(将自动添加序号)'
|
||||
: '为您的 API Key 取一个名称'
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.name }}
|
||||
</p>
|
||||
@@ -131,14 +132,14 @@
|
||||
|
||||
<!-- 标签 -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.tags')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>标签</label
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 已选择的标签 -->
|
||||
<div v-if="form.tags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.selectedTags') }}
|
||||
已选择的标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -161,7 +162,7 @@
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.clickToSelectTags') }}
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -180,13 +181,13 @@
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.createNewTag') }}
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.newTagPlaceholder')"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
@@ -201,7 +202,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.tagHint') }}
|
||||
用于标记不同团队或用途,方便筛选管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,76 +218,68 @@
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitTitle') }}
|
||||
速率限制设置 (可选)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.rateLimitWindow')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>时间窗口 (分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitWindowPlaceholder')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitWindowHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.rateLimitRequests')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>请求次数限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitRequestsPlaceholder')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitRequestsHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.rateLimitCost')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitCostPlaceholder')"
|
||||
placeholder="无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitCostHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.exampleTitle') }}
|
||||
💡 使用示例
|
||||
</h5>
|
||||
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
|
||||
<div>
|
||||
<strong>{{ $t('apiKeys.createApiKeyModal.example1') }}</strong>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用</div>
|
||||
<div>
|
||||
<strong>{{ $t('apiKeys.createApiKeyModal.example2') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ $t('apiKeys.createApiKeyModal.example3') }}</strong>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,9 +287,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.dailyCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>每日费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -325,27 +318,27 @@
|
||||
type="button"
|
||||
@click="form.dailyCostLimit = ''"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.dailyCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.dailyCostHint') }}
|
||||
设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.weeklyOpusCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -374,55 +367,55 @@
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = ''"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.weeklyOpusCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.weeklyOpusHint') }}
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.concurrencyLimit')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.concurrencyLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.concurrencyHint') }}
|
||||
设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.description')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>备注 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input w-full resize-none border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.descriptionPlaceholder')"
|
||||
placeholder="描述此 API Key 的用途..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.expirationSettings')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>过期设置</label
|
||||
>
|
||||
<!-- 过期模式选择 -->
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
|
||||
@@ -435,9 +428,7 @@
|
||||
type="radio"
|
||||
value="fixed"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.fixedTimeExpiry')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">固定时间过期</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -446,19 +437,17 @@
|
||||
type="radio"
|
||||
value="activation"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.activationExpiry')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">首次使用后激活</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="form.expirationMode === 'fixed'">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ $t('apiKeys.createApiKeyModal.fixedModeHint') }}
|
||||
固定时间模式:Key 创建后立即生效,按设定时间过期
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ $t('apiKeys.createApiKeyModal.activationModeHint') }}
|
||||
激活模式:Key 首次使用时激活,激活后按设定天数过期(适合批量销售)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -470,14 +459,14 @@
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@change="updateExpireAt"
|
||||
>
|
||||
<option value="">{{ $t('apiKeys.createApiKeyModal.neverExpire') }}</option>
|
||||
<option value="1d">{{ $t('apiKeys.createApiKeyModal.1d') }}</option>
|
||||
<option value="7d">{{ $t('apiKeys.createApiKeyModal.7d') }}</option>
|
||||
<option value="30d">{{ $t('apiKeys.createApiKeyModal.30d') }}</option>
|
||||
<option value="90d">{{ $t('apiKeys.createApiKeyModal.90d') }}</option>
|
||||
<option value="180d">{{ $t('apiKeys.createApiKeyModal.180d') }}</option>
|
||||
<option value="365d">{{ $t('apiKeys.createApiKeyModal.365d') }}</option>
|
||||
<option value="custom">{{ $t('apiKeys.createApiKeyModal.customDate') }}</option>
|
||||
<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
|
||||
@@ -489,11 +478,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
$t('apiKeys.createApiKeyModal.willExpireOn', {
|
||||
date: formatExpireDate(form.expiresAt)
|
||||
})
|
||||
}}
|
||||
将于 {{ formatExpireDate(form.expiresAt) }} 过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -505,12 +490,10 @@
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
max="3650"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.activationDays')"
|
||||
placeholder="输入天数"
|
||||
type="number"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.daysUnit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">天</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -520,24 +503,20 @@
|
||||
type="button"
|
||||
@click="form.activationDays = days"
|
||||
>
|
||||
{{ days }}{{ $t('apiKeys.createApiKeyModal.daysUnit') }}
|
||||
{{ days }}天
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
{{
|
||||
$t('apiKeys.createApiKeyModal.activationHint', {
|
||||
days: form.activationDays || 30
|
||||
})
|
||||
}}
|
||||
Key 将在首次使用后激活,激活后 {{ form.activationDays || 30 }} 天过期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.servicePermissions')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -546,9 +525,7 @@
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.allServices')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -557,9 +534,7 @@
|
||||
type="radio"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.claudeOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -568,9 +543,7 @@
|
||||
type="radio"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.geminiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -579,25 +552,23 @@
|
||||
type="radio"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.openaiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.permissionHint') }}
|
||||
控制此 API Key 可以访问哪些服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.dedicatedAccountBinding')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>专属账号绑定 (可选)</label
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:disabled="accountsLoading"
|
||||
title="{{ $t('apiKeys.createApiKeyModal.refreshAccounts') }}"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
@@ -608,73 +579,69 @@
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{
|
||||
accountsLoading
|
||||
? $t('apiKeys.createApiKeyModal.refreshing')
|
||||
: $t('apiKeys.createApiKeyModal.refreshAccounts')
|
||||
}}</span>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.claudeDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Claude 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectClaudeAccount')"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.geminiDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Gemini 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectGeminiAccount')"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.openaiDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>OpenAI 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectOpenaiAccount')"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.bedrockDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Bedrock 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="[]"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectBedrockAccount')"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.accountBindingHint') }}
|
||||
选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -690,15 +657,13 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="enableModelRestriction"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.enableModelRestriction') }}
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">{{
|
||||
$t('apiKeys.createApiKeyModal.restrictedModelsList')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label>
|
||||
<div
|
||||
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2"
|
||||
>
|
||||
@@ -717,7 +682,7 @@
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.noRestrictedModels') }}
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -736,7 +701,7 @@
|
||||
v-if="availableQuickModels.length === 0"
|
||||
class="text-sm italic text-gray-400"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.allCommonModelsRestricted') }}
|
||||
所有常用模型已在限制列表中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -745,7 +710,7 @@
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.addRestrictedModelPlaceholder')"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
/>
|
||||
@@ -759,7 +724,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
{{ $t('apiKeys.createApiKeyModal.modelRestrictionHint') }}
|
||||
设置此API Key无法访问的模型,例如:claude-opus-4-20250514
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,7 +743,7 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="enableClientRestriction"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.enableClientRestriction') }}
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -787,9 +752,9 @@
|
||||
class="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/20"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.allowedClients')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>允许的客户端</label
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
@@ -819,7 +784,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-4 py-2.5 text-sm font-semibold"
|
||||
@@ -828,11 +793,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-plus mr-2" />
|
||||
{{
|
||||
loading
|
||||
? $t('apiKeys.createApiKeyModal.creating')
|
||||
: $t('apiKeys.createApiKeyModal.create')
|
||||
}}
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -843,15 +804,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
accounts: {
|
||||
type: Object,
|
||||
@@ -928,31 +886,61 @@ onMounted(async () => {
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
})
|
||||
|
||||
// 刷新账号列表
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -986,13 +974,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -1008,9 +1014,9 @@ const refreshAccounts = async () => {
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast(t('apiKeys.createApiKeyModal.refreshAccountsSuccess'), 'success')
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.createApiKeyModal.refreshAccountsFailed'), 'error')
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -1071,17 +1077,13 @@ const updateCustomExpireAt = () => {
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const { locale } = useI18n()
|
||||
return date.toLocaleString(
|
||||
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加限制的模型
|
||||
@@ -1139,14 +1141,14 @@ const createApiKey = async () => {
|
||||
errors.value.name = ''
|
||||
|
||||
if (!form.name || !form.name.trim()) {
|
||||
errors.value.name = t('apiKeys.createApiKeyModal.nameError')
|
||||
errors.value.name = '请输入API Key名称'
|
||||
return
|
||||
}
|
||||
|
||||
// 批量创建时验证数量
|
||||
if (form.createType === 'batch') {
|
||||
if (!form.batchCount || form.batchCount < 2 || form.batchCount > 500) {
|
||||
showToast(t('apiKeys.createApiKeyModal.batchCountError'), 'error')
|
||||
showToast('批量创建数量必须在 2-500 之间', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1156,14 +1158,14 @@ const createApiKey = async () => {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmTitle'),
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmMessage'),
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmContinue'),
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmBack')
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续创建',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(t('apiKeys.createApiKeyModal.costLimitFallbackMessage'))
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
if (!confirmed) {
|
||||
return
|
||||
@@ -1252,11 +1254,11 @@ const createApiKey = async () => {
|
||||
const result = await apiClient.post('/admin/api-keys', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(t('apiKeys.createApiKeyModal.createSuccess'), 'success')
|
||||
showToast('API Key 创建成功', 'success')
|
||||
emit('success', result.data)
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.createApiKeyModal.createFailed'), 'error')
|
||||
showToast(result.message || '创建失败', 'error')
|
||||
}
|
||||
} else {
|
||||
// 批量创建
|
||||
@@ -1270,18 +1272,15 @@ const createApiKey = async () => {
|
||||
const result = await apiClient.post('/admin/api-keys/batch', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t('apiKeys.createApiKeyModal.batchCreateSuccess', { count: result.data.length }),
|
||||
'success'
|
||||
)
|
||||
showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
|
||||
emit('batch-success', result.data)
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.createApiKeyModal.batchCreateFailed'), 'error')
|
||||
showToast(result.message || '批量创建失败', 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.createApiKeyModal.createFailed'), 'error')
|
||||
showToast('创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<i class="fas fa-edit text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ t('apiKeys.editApiKeyModal.title') }}
|
||||
编辑 API Key
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -30,18 +30,20 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>{{ t('apiKeys.editApiKeyModal.name') }}</label
|
||||
>名称</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.namePlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
placeholder="请输入API Key名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
{{ t('apiKeys.editApiKeyModal.nameHint') }}
|
||||
用于识别此 API Key 的用途
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +51,7 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>{{ t('apiKeys.editApiKeyModal.owner') }}</label
|
||||
>所有者</label
|
||||
>
|
||||
<select
|
||||
v-model="form.ownerId"
|
||||
@@ -57,13 +59,11 @@
|
||||
>
|
||||
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||
{{ user.displayName }} ({{ user.username }})
|
||||
<span v-if="user.role === 'admin'" class="text-gray-500">{{
|
||||
t('apiKeys.editApiKeyModal.adminLabel')
|
||||
}}</span>
|
||||
<span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
{{ t('apiKeys.editApiKeyModal.ownerHint') }}
|
||||
分配此 API Key 给指定用户或管理员,管理员分配时不受用户 API Key 数量限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -71,13 +71,13 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>{{ t('apiKeys.editApiKeyModal.tags') }}</label
|
||||
>标签</label
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 已选择的标签 -->
|
||||
<div v-if="form.tags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.selectedTags') }}
|
||||
已选择的标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -100,7 +100,7 @@
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.clickToSelectTags') }}
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -119,13 +119,13 @@
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.createNewTag') }}
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.newTagPlaceholder')"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
@@ -140,7 +140,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.tagsHint') }}
|
||||
用于标记不同团队或用途,方便筛选管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,76 +156,68 @@
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitTitle') }}
|
||||
速率限制设置 (可选)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.rateLimitWindow')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>时间窗口 (分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.noLimit')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitWindowHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.rateLimitRequests')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>请求次数限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.noLimit')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitRequestsHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.rateLimitCost')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.noLimit')"
|
||||
placeholder="无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitCostHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
|
||||
{{ t('apiKeys.editApiKeyModal.usageExamples') }}
|
||||
💡 使用示例
|
||||
</h5>
|
||||
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
|
||||
<div>
|
||||
<strong>{{ t('apiKeys.editApiKeyModal.example1') }}</strong>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用</div>
|
||||
<div>
|
||||
<strong>{{ t('apiKeys.editApiKeyModal.example2') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ t('apiKeys.editApiKeyModal.example3') }}</strong>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,9 +225,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.dailyCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>每日费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -264,27 +256,27 @@
|
||||
type="button"
|
||||
@click="form.dailyCostLimit = ''"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.dailyCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.dailyCostHint') }}
|
||||
设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.weeklyOpusCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -313,36 +305,36 @@
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = ''"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.weeklyOpusCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.weeklyOpusHint') }}
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.concurrencyLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.concurrencyLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.concurrencyHint') }}
|
||||
设置此 API Key 可同时处理的最大请求数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -359,18 +351,18 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="editIsActive"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.activeStatus') }}
|
||||
激活账号
|
||||
</label>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.activeStatusHint') }}
|
||||
取消勾选将禁用此 API Key,暂停所有请求,客户端返回 401 错误
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.servicePermissions')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -379,9 +371,7 @@
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.allServices')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -390,9 +380,7 @@
|
||||
type="radio"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.claudeOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -401,9 +389,7 @@
|
||||
type="radio"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.geminiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -412,25 +398,23 @@
|
||||
type="radio"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.openaiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.permissionsHint') }}
|
||||
控制此 API Key 可以访问哪些服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.accountBinding')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>专属账号绑定</label
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:disabled="accountsLoading"
|
||||
:title="t('apiKeys.editApiKeyModal.refreshAccounts')"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
@@ -441,73 +425,69 @@
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{
|
||||
accountsLoading
|
||||
? t('apiKeys.editApiKeyModal.refreshing')
|
||||
: t('apiKeys.editApiKeyModal.refreshAccounts')
|
||||
}}</span>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.claudeAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Claude 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectClaudeAccount')"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.geminiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Gemini 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectGeminiAccount')"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.openaiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>OpenAI 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectOpenaiAccount')"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.bedrockAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Bedrock 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="[]"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectBedrockAccount')"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.accountBindingHint') }}
|
||||
修改绑定账号将影响此API Key的请求路由
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -523,15 +503,15 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="editEnableModelRestriction"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.enableModelRestriction') }}
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.restrictedModels')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>限制的模型列表</label
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
@@ -553,7 +533,7 @@
|
||||
v-if="form.restrictedModels.length === 0"
|
||||
class="text-sm text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.noRestrictedModels') }}
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -572,7 +552,7 @@
|
||||
v-if="availableQuickModels.length === 0"
|
||||
class="text-sm italic text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.allCommonModelsRestricted') }}
|
||||
所有常用模型已在限制列表中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -581,7 +561,7 @@
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.addRestrictedModelPlaceholder')"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
/>
|
||||
@@ -595,7 +575,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.modelRestrictionHint') }}
|
||||
设置此API Key无法访问的模型,例如:claude-opus-4-20250514
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -614,17 +594,17 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="editEnableClientRestriction"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.enableClientRestriction') }}
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.allowedClients')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>允许的客户端</label
|
||||
>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.clientRestrictionHint') }}
|
||||
勾选允许使用此API Key的客户端
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
@@ -655,7 +635,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -664,9 +644,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
loading ? t('apiKeys.editApiKeyModal.saving') : t('apiKeys.editApiKeyModal.save')
|
||||
}}
|
||||
{{ loading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -677,7 +655,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
@@ -697,9 +674,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
@@ -811,14 +785,14 @@ const updateApiKey = async () => {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmTitle'),
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmMessage'),
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmContinue'),
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmBack')
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续保存',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(t('apiKeys.editApiKeyModal.costLimitConfirmMessage'))
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
if (!confirmed) {
|
||||
return
|
||||
@@ -924,10 +898,10 @@ const updateApiKey = async () => {
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.editApiKeyModal.updateFailed'), 'error')
|
||||
showToast(result.message || '更新失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.editApiKeyModal.updateFailed'), 'error')
|
||||
showToast('更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -937,15 +911,23 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -979,13 +961,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -1001,9 +1001,9 @@ const refreshAccounts = async () => {
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast(t('apiKeys.editApiKeyModal.refreshAccountsSuccess'), 'success')
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.editApiKeyModal.refreshAccountsFailed'), 'error')
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -1017,7 +1017,7 @@ const loadUsers = async () => {
|
||||
availableUsers.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
// console.error('Failed to load users:', error)
|
||||
availableUsers.value = [
|
||||
{
|
||||
id: 'admin',
|
||||
@@ -1043,7 +1043,7 @@ onMounted(async () => {
|
||||
supportedClients.value = clients || []
|
||||
availableTags.value = tags || []
|
||||
} catch (error) {
|
||||
console.error('Error loading initial data:', error)
|
||||
// console.error('Error loading initial data:', error)
|
||||
// Fallback to empty arrays if loading fails
|
||||
supportedClients.value = []
|
||||
availableTags.value = []
|
||||
@@ -1051,10 +1051,29 @@ onMounted(async () => {
|
||||
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
@@ -1062,6 +1081,9 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
|
||||
form.name = props.apiKey.name
|
||||
|
||||
// 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户
|
||||
@@ -1071,7 +1093,7 @@ onMounted(async () => {
|
||||
// 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置
|
||||
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
|
||||
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
|
||||
console.log('Token limit migration detected, consider setting cost limit')
|
||||
// console.log('检测到历史Token限制,请考虑设置费用限制')
|
||||
}
|
||||
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
@@ -1087,7 +1109,10 @@ onMounted(async () => {
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
}
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
|
||||
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
|
||||
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
||||
|
||||
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
|
||||
@@ -18,11 +18,9 @@
|
||||
<i class="fas fa-clock text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ $t('apiKeys.expiryEditModal.title') }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改过期时间</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.expiryEditModal.subtitle', { name: apiKey.name || 'API Key' }) }}
|
||||
为 "{{ apiKey.name || 'API Key' }}" 设置新的过期时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,20 +39,14 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.expiryEditModal.currentStatus') }}
|
||||
</p>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">当前状态</p>
|
||||
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
<!-- 未激活状态 -->
|
||||
<template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated">
|
||||
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
||||
{{ $t('apiKeys.expiryEditModal.notActivated') }}
|
||||
未激活
|
||||
<span class="ml-2 text-xs font-normal text-gray-600">
|
||||
{{
|
||||
$t('apiKeys.expiryEditModal.activationDaysHint', {
|
||||
days: apiKey.activationDays || 30
|
||||
})
|
||||
}}
|
||||
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
||||
</span>
|
||||
</template>
|
||||
<!-- 已设置过期时间 -->
|
||||
@@ -71,7 +63,7 @@
|
||||
<!-- 永不过期 -->
|
||||
<template v-else>
|
||||
<i class="fas fa-infinity mr-1 text-gray-500" />
|
||||
{{ $t('apiKeys.expiryEditModal.neverExpire') }}
|
||||
永不过期
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -97,23 +89,19 @@
|
||||
@click="handleActivateNow"
|
||||
>
|
||||
<i class="fas fa-rocket mr-2" />
|
||||
{{
|
||||
$t('apiKeys.expiryEditModal.activateButton', { days: apiKey.activationDays || 30 })
|
||||
}}
|
||||
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{
|
||||
$t('apiKeys.expiryEditModal.activationInfo', { days: apiKey.activationDays || 30 })
|
||||
}}
|
||||
点击立即激活此 API Key,激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 快捷选项 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.expiryEditModal.selectNewDuration') }}
|
||||
</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>选择新的期限</label
|
||||
>
|
||||
<div class="mb-3 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="option in quickOptions"
|
||||
@@ -138,16 +126,16 @@
|
||||
@click="selectQuickOption('custom')"
|
||||
>
|
||||
<i class="fas fa-calendar-alt mr-1" />
|
||||
{{ $t('apiKeys.expiryEditModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义日期选择 -->
|
||||
<div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn">
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.expiryEditModal.selectDateAndTime') }}
|
||||
</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>选择日期和时间</label
|
||||
>
|
||||
<input
|
||||
v-model="localForm.customExpireDate"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@@ -156,7 +144,7 @@
|
||||
@change="updateCustomExpiryPreview"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.expiryEditModal.selectFutureDateTime') }}
|
||||
选择一个未来的日期和时间作为过期时间
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +157,7 @@
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-medium text-blue-700 dark:text-blue-400">
|
||||
<i class="fas fa-arrow-right mr-1" />
|
||||
{{ $t('apiKeys.expiryEditModal.newExpiryTime') }}
|
||||
新的过期时间
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-blue-900 dark:text-blue-200">
|
||||
<template v-if="localForm.expiresAt">
|
||||
@@ -184,7 +172,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-infinity mr-1" />
|
||||
{{ $t('apiKeys.expiryEditModal.neverExpire') }}
|
||||
永不过期
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -202,7 +190,7 @@
|
||||
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.expiryEditModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-4 py-2.5 font-semibold"
|
||||
@@ -211,11 +199,7 @@
|
||||
>
|
||||
<div v-if="saving" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
saving
|
||||
? $t('apiKeys.expiryEditModal.saving')
|
||||
: $t('apiKeys.expiryEditModal.saveChanges')
|
||||
}}
|
||||
{{ saving ? '保存中...' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,9 +210,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -254,14 +235,14 @@ const localForm = reactive({
|
||||
|
||||
// 快捷选项
|
||||
const quickOptions = [
|
||||
{ value: '', label: t('apiKeys.expiryEditModal.neverExpireOption') },
|
||||
{ value: '1d', label: t('apiKeys.expiryEditModal.oneDay') },
|
||||
{ value: '7d', label: t('apiKeys.expiryEditModal.sevenDays') },
|
||||
{ value: '30d', label: t('apiKeys.expiryEditModal.thirtyDays') },
|
||||
{ value: '90d', label: t('apiKeys.expiryEditModal.ninetyDays') },
|
||||
{ value: '180d', label: t('apiKeys.expiryEditModal.oneHundredEightyDays') },
|
||||
{ value: '365d', label: t('apiKeys.expiryEditModal.threeSixtyFiveDays') },
|
||||
{ value: '730d', label: t('apiKeys.expiryEditModal.twoYears') }
|
||||
{ value: '', label: '永不过期' },
|
||||
{ value: '1d', label: '1 天' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
{ value: '90d', label: '90 天' },
|
||||
{ value: '180d', label: '180 天' },
|
||||
{ value: '365d', label: '1 年' },
|
||||
{ value: '730d', label: '2 年' }
|
||||
]
|
||||
|
||||
// 计算最小日期时间
|
||||
@@ -356,17 +337,13 @@ const updateCustomExpiryPreview = () => {
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const { locale } = useI18n()
|
||||
return date.toLocaleString(
|
||||
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否已过期
|
||||
@@ -386,22 +363,22 @@ const getExpiryStatus = (expiresAt) => {
|
||||
|
||||
if (diffMs < 0) {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.expired'),
|
||||
text: '已过期',
|
||||
class: 'text-red-600'
|
||||
}
|
||||
} else if (diffDays <= 7) {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.daysToExpire', { days: diffDays }),
|
||||
text: `${diffDays} 天后过期`,
|
||||
class: 'text-orange-600'
|
||||
}
|
||||
} else if (diffDays <= 30) {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.daysToExpire', { days: diffDays }),
|
||||
text: `${diffDays} 天后过期`,
|
||||
class: 'text-yellow-600'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.monthsToExpire', { months: Math.ceil(diffDays / 30) }),
|
||||
text: `${Math.ceil(diffDays / 30)} 个月后过期`,
|
||||
class: 'text-green-600'
|
||||
}
|
||||
}
|
||||
@@ -422,19 +399,15 @@ const handleActivateNow = async () => {
|
||||
let confirmed = true
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('apiKeys.expiryEditModal.activateConfirmTitle'),
|
||||
t('apiKeys.expiryEditModal.activateConfirmMessage', {
|
||||
days: props.apiKey.activationDays || 30
|
||||
}),
|
||||
t('apiKeys.expiryEditModal.confirmActivate'),
|
||||
t('apiKeys.expiryEditModal.confirmCancel')
|
||||
'激活 API Key',
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
|
||||
'确定激活',
|
||||
'取消'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(
|
||||
t('apiKeys.expiryEditModal.activateConfirmMessage', {
|
||||
days: props.apiKey.activationDays || 30
|
||||
})
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,13 @@
|
||||
<i class="fas fa-check text-lg text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ t('apiKeys.newApiKeyModal.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.newApiKeyModal.subtitle') }}
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">API Key 创建成功</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">请妥善保存您的 API Key</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
:title="t('apiKeys.newApiKeyModal.directCloseTooltip')"
|
||||
title="直接关闭(不推荐)"
|
||||
@click="handleDirectClose"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
@@ -40,11 +36,10 @@
|
||||
<i class="fas fa-exclamation-triangle text-sm text-white" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">
|
||||
{{ t('apiKeys.newApiKeyModal.warningTitle') }}
|
||||
</h5>
|
||||
<h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">重要提醒</h5>
|
||||
<p class="text-sm text-amber-800 dark:text-amber-300">
|
||||
{{ t('apiKeys.newApiKeyModal.warningMessage') }}
|
||||
这是您唯一能看到完整 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API
|
||||
Key。请立即复制并妥善保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,9 +48,9 @@
|
||||
<!-- API Key 信息 -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.newApiKeyModal.apiKeyName')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API Key 名称</label
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
@@ -64,22 +59,22 @@
|
||||
</div>
|
||||
|
||||
<div v-if="apiKey.description">
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.newApiKeyModal.remarks')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>备注</label
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{
|
||||
apiKey.description || t('apiKeys.newApiKeyModal.noDescription')
|
||||
apiKey.description || '无描述'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.newApiKeyModal.apiKey')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API Key</label
|
||||
>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex min-h-[60px] items-center break-all rounded-lg border border-gray-700 bg-gray-900 p-4 pr-14 font-mono text-sm text-white dark:border-gray-600 dark:bg-gray-900"
|
||||
@@ -89,11 +84,7 @@
|
||||
<div class="absolute right-3 top-3">
|
||||
<button
|
||||
class="btn-icon-sm bg-gray-700 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="
|
||||
showFullKey
|
||||
? t('apiKeys.newApiKeyModal.hideApiKey')
|
||||
: t('apiKeys.newApiKeyModal.showFullApiKey')
|
||||
"
|
||||
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
|
||||
type="button"
|
||||
@click="toggleKeyVisibility"
|
||||
>
|
||||
@@ -102,7 +93,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.newApiKeyModal.visibilityHint') }}
|
||||
点击眼睛图标切换显示模式,使用下方按钮复制完整 API Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,13 +105,13 @@
|
||||
@click="copyApiKey"
|
||||
>
|
||||
<i class="fas fa-copy" />
|
||||
{{ t('apiKeys.newApiKeyModal.copyApiKey') }}
|
||||
复制 API Key
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('apiKeys.newApiKeyModal.alreadySaved') }}
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,11 +121,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
@@ -171,13 +159,13 @@ const getDisplayedApiKey = () => {
|
||||
const copyApiKey = async () => {
|
||||
const key = props.apiKey.apiKey || props.apiKey.key || ''
|
||||
if (!key) {
|
||||
showToast(t('apiKeys.newApiKeyModal.apiKeyNotFound'), 'error')
|
||||
showToast('API Key 不存在', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
showToast(t('apiKeys.newApiKeyModal.copySuccess'), 'success')
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
} catch (error) {
|
||||
// console.error('Failed to copy:', error)
|
||||
// 降级方案:创建一个临时文本区域
|
||||
@@ -187,9 +175,9 @@ const copyApiKey = async () => {
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast(t('apiKeys.newApiKeyModal.copySuccess'), 'success')
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
} catch (fallbackError) {
|
||||
showToast(t('apiKeys.newApiKeyModal.copyFailed'), 'error')
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
@@ -200,17 +188,19 @@ const copyApiKey = async () => {
|
||||
const handleClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.newApiKeyModal.closeReminderTitle'),
|
||||
t('apiKeys.newApiKeyModal.closeReminderMessage'),
|
||||
t('apiKeys.newApiKeyModal.confirmClose'),
|
||||
t('apiKeys.newApiKeyModal.cancel')
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'取消'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.newApiKeyModal.closeReminderMessage'))
|
||||
const confirmed = confirm(
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
@@ -221,17 +211,17 @@ const handleClose = async () => {
|
||||
const handleDirectClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.newApiKeyModal.directCloseTitle'),
|
||||
t('apiKeys.newApiKeyModal.directCloseMessage'),
|
||||
t('apiKeys.newApiKeyModal.stillClose'),
|
||||
t('apiKeys.newApiKeyModal.goBack')
|
||||
'确定要关闭吗?',
|
||||
'您还没有保存API Key,关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
|
||||
'仍然关闭',
|
||||
'返回复制'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.newApiKeyModal.directCloseFallback'))
|
||||
const confirmed = confirm('您还没有保存API Key,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
>
|
||||
<i class="fas fa-clock text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
{{ $t('apiKeys.renewApiKeyModal.title') }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
@@ -30,18 +28,13 @@
|
||||
<i class="fas fa-info text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-1 font-semibold text-gray-800">
|
||||
{{ $t('apiKeys.renewApiKeyModal.apiKeyInfo') }}
|
||||
</h4>
|
||||
<h4 class="mb-1 font-semibold text-gray-800">API Key 信息</h4>
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ apiKey.name }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
{{ $t('apiKeys.renewApiKeyModal.currentExpiry')
|
||||
}}{{
|
||||
apiKey.expiresAt
|
||||
? formatExpireDate(apiKey.expiresAt)
|
||||
: $t('apiKeys.renewApiKeyModal.neverExpires')
|
||||
当前过期时间:{{
|
||||
apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,21 +42,19 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">{{
|
||||
$t('apiKeys.renewApiKeyModal.renewDuration')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">续期时长</label>
|
||||
<select
|
||||
v-model="form.renewDuration"
|
||||
class="form-input w-full"
|
||||
@change="updateRenewExpireAt"
|
||||
>
|
||||
<option value="7d">{{ $t('apiKeys.renewApiKeyModal.extend7Days') }}</option>
|
||||
<option value="30d">{{ $t('apiKeys.renewApiKeyModal.extend30Days') }}</option>
|
||||
<option value="90d">{{ $t('apiKeys.renewApiKeyModal.extend90Days') }}</option>
|
||||
<option value="180d">{{ $t('apiKeys.renewApiKeyModal.extend180Days') }}</option>
|
||||
<option value="365d">{{ $t('apiKeys.renewApiKeyModal.extend365Days') }}</option>
|
||||
<option value="custom">{{ $t('apiKeys.renewApiKeyModal.customDate') }}</option>
|
||||
<option value="permanent">{{ $t('apiKeys.renewApiKeyModal.setPermanent') }}</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>
|
||||
<option value="permanent">设为永不过期</option>
|
||||
</select>
|
||||
<div v-if="form.renewDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
@@ -75,8 +66,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.newExpiresAt" class="mt-2 text-xs text-gray-500">
|
||||
{{ $t('apiKeys.renewApiKeyModal.newExpiry')
|
||||
}}{{ formatExpireDate(form.newExpiresAt) }}
|
||||
新的过期时间:{{ formatExpireDate(form.newExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +77,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.renewApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -97,11 +87,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-clock mr-2" />
|
||||
{{
|
||||
loading
|
||||
? $t('apiKeys.renewApiKeyModal.renewing')
|
||||
: $t('apiKeys.renewApiKeyModal.confirmRenew')
|
||||
}}
|
||||
{{ loading ? '续期中...' : '确认续期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,12 +97,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
@@ -150,10 +133,7 @@ const minDateTime = computed(() => {
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
// 根据当前语言设置选择合适的locale
|
||||
const locale =
|
||||
t('common.locale') === 'en' ? 'en-US' : t('common.locale') === 'zh-TW' ? 'zh-TW' : 'zh-CN'
|
||||
return date.toLocaleString(locale, {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -229,14 +209,14 @@ const renewApiKey = async () => {
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(t('apiKeys.renewApiKeyModal.renewSuccess'), 'success')
|
||||
showToast('API Key 续期成功', 'success')
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.renewApiKeyModal.renewFailed'), 'error')
|
||||
showToast(result.message || '续期失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.renewApiKeyModal.renewFailed'), 'error')
|
||||
showToast('续期失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<i class="fas fa-chart-line text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ t('apiKeys.usageDetailModal.title') }} - {{ apiKey.name }}
|
||||
使用统计详情 - {{ apiKey.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<button class="p-1 text-gray-400 transition-colors hover:text-gray-600" @click="close">
|
||||
@@ -34,17 +34,14 @@
|
||||
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4 dark:border-blue-700 dark:from-blue-900/20 dark:to-blue-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.totalRequests')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">总请求数</span>
|
||||
<i class="fas fa-paper-plane text-blue-500" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(totalRequests) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.usageDetailModal.today') }}: {{ formatNumber(dailyRequests) }}
|
||||
{{ t('apiKeys.usageDetailModal.times') }}
|
||||
今日: {{ formatNumber(dailyRequests) }} 次
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,16 +50,14 @@
|
||||
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4 dark:border-green-700 dark:from-green-900/20 dark:to-green-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">总Token数</span>
|
||||
<i class="fas fa-coins text-green-500" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatTokenCount(totalTokens) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.usageDetailModal.today') }}: {{ formatTokenCount(dailyTokens) }}
|
||||
今日: {{ formatTokenCount(dailyTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,16 +66,14 @@
|
||||
class="rounded-lg border border-yellow-200 bg-gradient-to-br from-yellow-50 to-yellow-100 p-4 dark:border-yellow-700 dark:from-yellow-900/20 dark:to-yellow-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.totalCost')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">总费用</span>
|
||||
<i class="fas fa-dollar-sign text-yellow-600" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
${{ totalCost.toFixed(4) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.usageDetailModal.today') }}: ${{ dailyCost.toFixed(4) }}
|
||||
今日: ${{ dailyCost.toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,9 +82,7 @@
|
||||
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4 dark:border-purple-700 dark:from-purple-900/20 dark:to-purple-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.averageRate')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">平均速率</span>
|
||||
<i class="fas fa-tachometer-alt text-purple-500" />
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
@@ -113,15 +104,13 @@
|
||||
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-chart-pie mr-2 text-indigo-500" />
|
||||
{{ t('apiKeys.usageDetailModal.tokenDistribution') }}
|
||||
Token 使用分布
|
||||
</h4>
|
||||
<div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-arrow-down mr-2 text-green-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.inputTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">输入 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatTokenCount(inputTokens) }}
|
||||
@@ -130,9 +119,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-arrow-up mr-2 text-blue-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.outputTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">输出 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatTokenCount(outputTokens) }}
|
||||
@@ -141,9 +128,7 @@
|
||||
<div v-if="cacheCreateTokens > 0" class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-save mr-2 text-purple-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.cacheCreateTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">缓存创建 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-purple-600">
|
||||
{{ formatTokenCount(cacheCreateTokens) }}
|
||||
@@ -152,9 +137,7 @@
|
||||
<div v-if="cacheReadTokens > 0" class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-download mr-2 text-purple-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.cacheReadTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">缓存读取 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-purple-600">
|
||||
{{ formatTokenCount(cacheReadTokens) }}
|
||||
@@ -169,14 +152,12 @@
|
||||
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-2 text-red-500" />
|
||||
{{ t('apiKeys.usageDetailModal.limitSettings') }}
|
||||
限制设置
|
||||
</h4>
|
||||
<div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50">
|
||||
<div v-if="apiKey.dailyCostLimit > 0" class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.dailyCostLimit')
|
||||
}}</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">每日费用限制</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
${{ apiKey.dailyCostLimit.toFixed(2) }}
|
||||
</span>
|
||||
@@ -195,11 +176,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('apiKeys.usageDetailModal.usedPercentage', {
|
||||
percentage: dailyCostPercentage.toFixed(1)
|
||||
})
|
||||
}}
|
||||
已使用 {{ dailyCostPercentage.toFixed(1) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,9 +184,7 @@
|
||||
v-if="apiKey.concurrencyLimit > 0"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.concurrencyLimit')
|
||||
}}</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">并发限制</span>
|
||||
<span class="font-semibold text-purple-600">
|
||||
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
|
||||
</span>
|
||||
@@ -218,14 +193,14 @@
|
||||
<div v-if="apiKey.rateLimitWindow > 0" class="space-y-2">
|
||||
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-clock mr-1 text-blue-500" />
|
||||
{{ t('apiKeys.usageDetailModal.timeWindowLimit') }}
|
||||
时间窗口限制
|
||||
</h5>
|
||||
<WindowCountdown
|
||||
:cost-limit="apiKey.rateLimitCost"
|
||||
:current-cost="apiKey.currentWindowCost"
|
||||
:current-requests="apiKey.currentWindowRequests"
|
||||
:current-tokens="apiKey.currentWindowTokens"
|
||||
:label="t('apiKeys.usageDetailModal.windowStatus')"
|
||||
label="窗口状态"
|
||||
:rate-limit-window="apiKey.rateLimitWindow"
|
||||
:request-limit="apiKey.rateLimitRequests"
|
||||
:show-progress="true"
|
||||
@@ -243,7 +218,7 @@
|
||||
<!-- 底部按钮 -->
|
||||
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
||||
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
||||
{{ t('apiKeys.usageDetailModal.close') }}
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,11 +228,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WindowCountdown from './WindowCountdown.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -302,9 +274,7 @@ const dailyCostPercentage = computed(() => {
|
||||
// 方法
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
// 根据当前语言环境自动选择合适的地区设置
|
||||
const currentLocale = t('common.locale')
|
||||
return num.toLocaleString(currentLocale)
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化Token数量(使用K/M单位)
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500">{{ displayLabel }}</span>
|
||||
<span class="text-gray-500">{{ label }}</span>
|
||||
<span v-if="windowState === 'active'" class="font-medium text-gray-700">
|
||||
<i class="fas fa-clock mr-1 text-blue-500" />
|
||||
{{ formatTime(remainingSeconds) }}
|
||||
</span>
|
||||
<span v-else-if="windowState === 'expired'" class="font-medium text-orange-600">
|
||||
<i class="fas fa-sync-alt mr-1" />
|
||||
{{ t('apiKeys.windowCountdown.expired') }}
|
||||
窗口已过期
|
||||
</span>
|
||||
<span v-else-if="windowState === 'notStarted'" class="font-medium text-gray-500">
|
||||
<i class="fas fa-pause-circle mr-1" />
|
||||
{{ t('apiKeys.windowCountdown.notStarted') }}
|
||||
</span>
|
||||
<span v-else class="font-medium text-gray-400">
|
||||
{{ rateLimitWindow }} {{ t('apiKeys.windowCountdown.minutes') }}
|
||||
窗口未激活
|
||||
</span>
|
||||
<span v-else class="font-medium text-gray-400"> {{ rateLimitWindow }} 分钟 </span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条(仅在有限制时显示) -->
|
||||
<div v-if="showProgress" class="space-y-0.5">
|
||||
<div v-if="hasRequestLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">{{ t('apiKeys.windowCountdown.requests') }}</span>
|
||||
<span class="text-gray-400">请求</span>
|
||||
<span class="text-gray-600"> {{ currentRequests || 0 }}/{{ requestLimit }} </span>
|
||||
</div>
|
||||
<div class="h-1 w-full rounded-full bg-gray-200">
|
||||
@@ -38,7 +36,7 @@
|
||||
<!-- Token限制(向后兼容) -->
|
||||
<div v-if="hasTokenLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">{{ t('apiKeys.windowCountdown.tokens') }}</span>
|
||||
<span class="text-gray-400">Token</span>
|
||||
<span class="text-gray-600">
|
||||
{{ formatTokenCount(currentTokens || 0) }}/{{ formatTokenCount(tokenLimit) }}
|
||||
</span>
|
||||
@@ -55,7 +53,7 @@
|
||||
<!-- 费用限制(新功能) -->
|
||||
<div v-if="hasCostLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">{{ t('apiKeys.windowCountdown.cost') }}</span>
|
||||
<span class="text-gray-400">费用</span>
|
||||
<span class="text-gray-600">
|
||||
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
|
||||
</span>
|
||||
@@ -73,29 +71,22 @@
|
||||
<!-- 额外提示信息 -->
|
||||
<div v-if="windowState === 'active' && showTooltip" class="text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<span v-if="remainingSeconds < 60">{{ t('apiKeys.windowCountdown.aboutToReset') }}</span>
|
||||
<span v-if="remainingSeconds < 60">即将重置</span>
|
||||
<span v-else-if="remainingSeconds < 300"
|
||||
>{{ Math.ceil(remainingSeconds / 60) }}
|
||||
{{ t('apiKeys.windowCountdown.minutesUntilReset') }}</span
|
||||
>
|
||||
<span v-else
|
||||
>{{ formatDetailedTime(remainingSeconds)
|
||||
}}{{ t('apiKeys.windowCountdown.untilReset') }}</span
|
||||
>{{ Math.ceil(remainingSeconds / 60) }} 分钟后重置</span
|
||||
>
|
||||
<span v-else>{{ formatDetailedTime(remainingSeconds) }}后重置</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '窗口限制'
|
||||
},
|
||||
rateLimitWindow: {
|
||||
type: Number,
|
||||
@@ -152,10 +143,6 @@ const remainingSeconds = ref(props.windowRemainingSeconds)
|
||||
let intervalId = null
|
||||
|
||||
// 计算属性
|
||||
const displayLabel = computed(() => {
|
||||
return props.label || t('apiKeys.windowCountdown.windowLimit')
|
||||
})
|
||||
|
||||
const windowState = computed(() => {
|
||||
if (props.windowStartTime === null) {
|
||||
return 'notStarted' // 窗口未开始
|
||||
@@ -195,9 +182,9 @@ const formatDetailedTime = (seconds) => {
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}${t('apiKeys.windowCountdown.hours')}${minutes}${t('apiKeys.windowCountdown.minutes')}`
|
||||
return `${hours}小时${minutes}分钟`
|
||||
} else {
|
||||
return `${minutes}${t('apiKeys.windowCountdown.minutes')}`
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.usageRatio') }}
|
||||
使用占比
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span
|
||||
>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}{{ t('apiStats.requests') }}</span
|
||||
>
|
||||
<span>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}次</span>
|
||||
<span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,10 +41,7 @@
|
||||
<!-- 其他Keys汇总 -->
|
||||
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span
|
||||
>{{ t('apiStats.otherKeys') }} {{ otherKeysCount }} {{ t('apiStats.individual')
|
||||
}}{{ t('apiStats.keys') }}</span
|
||||
>
|
||||
<span>其他 {{ otherKeysCount }} 个Keys</span>
|
||||
<span>{{ otherPercentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +54,7 @@
|
||||
>
|
||||
<div class="text-center">
|
||||
<i class="fas fa-chart-pie mb-2 text-2xl" />
|
||||
<p>{{ t('apiStats.usageRatioOnlyInMultiMode') }}</p>
|
||||
<p>使用占比仅在多Key查询时显示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +63,7 @@
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-chart-pie mr-2" />
|
||||
{{ t('apiStats.noData') }}
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -76,11 +71,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
<div class="wide-card-title mb-6">
|
||||
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200">
|
||||
<i class="fas fa-chart-line mr-3" />
|
||||
{{ t('apiStats.usageStatsQuery') }}
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.apiKeyDescription') }}
|
||||
</p>
|
||||
<p class="text-base text-gray-600 dark:text-gray-400">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
@@ -18,7 +16,7 @@
|
||||
<!-- API Key 标签 -->
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-key mr-2" />
|
||||
{{ multiKeyMode ? t('apiStats.enterApiKeys') : t('apiStats.enterApiKey') }}
|
||||
{{ multiKeyMode ? '输入您的 API Keys(每行一个或用逗号分隔)' : '输入您的 API Key' }}
|
||||
</label>
|
||||
|
||||
<!-- 模式切换和查询按钮组 -->
|
||||
@@ -30,20 +28,20 @@
|
||||
<button
|
||||
class="mode-switch-btn"
|
||||
:class="{ active: !multiKeyMode }"
|
||||
:title="t('apiStats.singleModeTitle')"
|
||||
title="单一模式"
|
||||
@click="multiKeyMode = false"
|
||||
>
|
||||
<i class="fas fa-key" />
|
||||
<span class="ml-2 hidden sm:inline">{{ t('apiStats.singleMode') }}</span>
|
||||
<span class="ml-2 hidden sm:inline">单一</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-switch-btn"
|
||||
:class="{ active: multiKeyMode }"
|
||||
:title="t('apiStats.aggregateModeTitle')"
|
||||
title="聚合模式"
|
||||
@click="multiKeyMode = true"
|
||||
>
|
||||
<i class="fas fa-layer-group" />
|
||||
<span class="ml-2 hidden sm:inline">{{ t('apiStats.aggregateMode') }}</span>
|
||||
<span class="ml-2 hidden sm:inline">聚合</span>
|
||||
<span
|
||||
v-if="multiKeyMode && parsedApiKeys.length > 0"
|
||||
class="ml-1 rounded-full bg-white/20 px-1.5 py-0.5 text-xs font-semibold"
|
||||
@@ -64,7 +62,7 @@
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full"
|
||||
:disabled="loading"
|
||||
:placeholder="t('apiStats.apiKeyPlaceholder')"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
type="password"
|
||||
@keyup.enter="queryStats"
|
||||
/>
|
||||
@@ -75,14 +73,14 @@
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full resize-y"
|
||||
:disabled="loading"
|
||||
:placeholder="t('apiStats.apiKeysPlaceholder')"
|
||||
placeholder="请输入您的 API Keys,支持以下格式: cr_xxx cr_yyy 或 cr_xxx, cr_yyy"
|
||||
rows="4"
|
||||
@keyup.ctrl.enter="queryStats"
|
||||
/>
|
||||
<button
|
||||
v-if="apiKey && !loading"
|
||||
class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
:title="t('apiStats.clearInput')"
|
||||
title="清空输入"
|
||||
@click="clearInput"
|
||||
>
|
||||
<i class="fas fa-times-circle" />
|
||||
@@ -99,7 +97,7 @@
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner" />
|
||||
<i v-else class="fas fa-search" />
|
||||
{{ loading ? t('common.loading') : t('apiStats.queryButton') }}
|
||||
{{ loading ? '查询中...' : '查询统计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +105,11 @@
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2" />
|
||||
{{ multiKeyMode ? t('apiStats.securityNoticeMulti') : t('apiStats.securityNoticeSingle') }}
|
||||
{{
|
||||
multiKeyMode
|
||||
? '您的 API Keys 仅用于查询统计数据,不会被存储。聚合模式下部分个体化信息将不显示。'
|
||||
: '您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- 多 Key 模式额外提示 -->
|
||||
@@ -116,7 +118,7 @@
|
||||
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
>
|
||||
<i class="fas fa-lightbulb mr-2" />
|
||||
<span>{{ t('apiStats.multiKeyTip') }}</span>
|
||||
<span>提示:最多支持同时查询 30 个 API Keys。使用 Ctrl+Enter 快速查询。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,11 +127,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||
const { queryStats, clearInput } = apiStatsStore
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
|
||||
{{ multiKeyMode ? t('apiStats.limitConfigAggregate') : t('apiStats.limitConfig') }}
|
||||
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
|
||||
</h3>
|
||||
|
||||
<!-- 多 Key 模式下的聚合统计信息 -->
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-layer-group mr-2 text-blue-500" />
|
||||
{{ t('apiStats.apiKeysOverview') }}
|
||||
API Keys 概况
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-800 dark:text-blue-200"
|
||||
@@ -31,17 +31,13 @@
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ aggregatedStats.totalKeys }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.totalKeys') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">总计 Keys</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-bold text-green-600">
|
||||
{{ aggregatedStats.activeKeys }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.activeKeys') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">激活 Keys</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,15 +48,13 @@
|
||||
>
|
||||
<div class="mb-3 flex items-center">
|
||||
<i class="fas fa-chart-pie mr-2 text-purple-500" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiStats.aggregateStatsSummary')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">聚合统计摘要</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-database mr-1 text-gray-400" />
|
||||
{{ t('apiStats.totalRequests') }}
|
||||
总请求数
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
||||
@@ -69,7 +63,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-coins mr-1 text-yellow-500" />
|
||||
{{ t('apiStats.totalTokens') }}
|
||||
总 Tokens
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
||||
@@ -78,7 +72,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-dollar-sign mr-1 text-green-500" />
|
||||
{{ t('apiStats.totalCost') }}
|
||||
总费用
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ aggregatedStats.usage.formattedCost }}
|
||||
@@ -94,7 +88,7 @@
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" />
|
||||
<span class="text-red-700 dark:text-red-300">
|
||||
{{ t('apiStats.invalidKeysCount', { count: invalidKeys.length }) }}
|
||||
{{ invalidKeys.length }} 个无效的 API Key
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +97,7 @@
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('apiStats.aggregateStatsNote') }}
|
||||
每个 API Key 有独立的限制设置,聚合模式下不显示单个限制配置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,9 +106,9 @@
|
||||
<!-- 每日费用限制 -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.dailyCostLimit')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>每日费用限制</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
|
||||
<span v-if="statsData.limits.dailyCostLimit > 0">
|
||||
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{
|
||||
@@ -155,7 +149,7 @@
|
||||
:current-cost="statsData.limits.currentWindowCost"
|
||||
:current-requests="statsData.limits.currentWindowRequests"
|
||||
:current-tokens="statsData.limits.currentWindowTokens"
|
||||
:label="t('apiStats.timeWindowLimit')"
|
||||
label="时间窗口限制"
|
||||
:rate-limit-window="statsData.limits.rateLimitWindow"
|
||||
:request-limit="statsData.limits.rateLimitRequests"
|
||||
:show-progress="true"
|
||||
@@ -169,21 +163,19 @@
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<span v-if="statsData.limits.rateLimitCost > 0">
|
||||
{{ t('apiStats.orRelationshipRequests') }}
|
||||
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
|
||||
</span>
|
||||
<span v-else-if="statsData.limits.tokenLimit > 0">
|
||||
{{ t('apiStats.orRelationshipTokens') }}
|
||||
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
|
||||
</span>
|
||||
<span v-else>{{ t('apiStats.onlyRequestsLimit') }}</span>
|
||||
<span v-else> 仅限制请求次数 </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他限制信息 -->
|
||||
<div class="space-y-2 border-t border-gray-100 pt-2 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.concurrencyLimit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">并发限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span v-if="statsData.limits.concurrencyLimit > 0">
|
||||
{{ statsData.limits.concurrencyLimit }}
|
||||
@@ -194,9 +186,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.modelLimit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">模型限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span
|
||||
v-if="
|
||||
@@ -206,22 +196,16 @@
|
||||
class="text-orange-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
||||
{{
|
||||
t('apiStats.restrictedModelsCount', {
|
||||
count: statsData.restrictions.restrictedModels.length
|
||||
})
|
||||
}}
|
||||
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.allowAllModels') }}
|
||||
允许所有模型
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.clientLimit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span
|
||||
v-if="
|
||||
@@ -231,15 +215,11 @@
|
||||
class="text-orange-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
||||
{{
|
||||
t('apiStats.restrictedClientsCount', {
|
||||
count: statsData.restrictions.allowedClients.length
|
||||
})
|
||||
}}
|
||||
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.allowAllClients') }}
|
||||
允许所有客户端
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -261,7 +241,7 @@
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
>
|
||||
<i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.detailedLimitInfo') }}
|
||||
详细限制信息
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2">
|
||||
@@ -277,7 +257,7 @@
|
||||
class="mb-2 flex items-center text-sm font-bold text-amber-800 dark:text-amber-300 md:mb-3 md:text-base"
|
||||
>
|
||||
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.restrictedModelsList') }}
|
||||
受限模型列表
|
||||
</h4>
|
||||
<div class="space-y-1 md:space-y-2">
|
||||
<div
|
||||
@@ -291,7 +271,7 @@
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('apiStats.restrictedModelsNote') }}
|
||||
此 API Key 不能访问以上列出的模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -307,7 +287,7 @@
|
||||
class="mb-2 flex items-center text-sm font-bold text-blue-800 dark:text-blue-300 md:mb-3 md:text-base"
|
||||
>
|
||||
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.allowedClientsList') }}
|
||||
允许的客户端
|
||||
</h4>
|
||||
<div class="space-y-1 md:space-y-2">
|
||||
<div
|
||||
@@ -321,7 +301,7 @@
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400 md:mt-3">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('apiStats.allowedClientsNote') }}
|
||||
此 API Key 只能被以上列出的客户端使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,12 +311,9 @@
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.modelUsageStats') }}
|
||||
模型使用统计
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
@@ -19,9 +19,7 @@
|
||||
<i
|
||||
class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl"
|
||||
/>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
{{ t('apiStats.loadingModelStats') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 md:text-base">加载模型统计数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计数据 -->
|
||||
@@ -33,42 +31,38 @@
|
||||
{{ model.model }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ model.requests }}{{ t('apiStats.requestCount') }}
|
||||
{{ model.requests }} 次请求
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-3 flex-shrink-0 text-right">
|
||||
<div class="text-base font-bold text-green-600 md:text-lg">
|
||||
{{ model.formatted?.total || '$0.000000' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ t('apiStats.totalCost') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm">
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ t('apiStats.inputTokens') }}</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">输入 Token</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.inputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ t('apiStats.outputTokens') }}</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">输出 Token</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.outputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.cacheCreateTokens') }}
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">缓存创建</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.cacheCreateTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ t('apiStats.cacheReadTokens') }}</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">缓存读取</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.cacheReadTokens) }}
|
||||
</div>
|
||||
@@ -81,11 +75,7 @@
|
||||
<div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 md:py-8">
|
||||
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
|
||||
<p class="text-sm md:text-base">
|
||||
{{
|
||||
t('apiStats.noModelData', {
|
||||
period: statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth')
|
||||
})
|
||||
}}
|
||||
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,11 +83,8 @@
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -11,57 +11,45 @@
|
||||
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500'
|
||||
"
|
||||
/>
|
||||
{{ multiKeyMode ? t('apiStats.batchQuerySummary') : t('apiStats.apiKeyInfo') }}
|
||||
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}
|
||||
</h3>
|
||||
|
||||
<!-- 多 Key 模式下的概要信息 -->
|
||||
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.queryKeysCount')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys 数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ aggregatedStats.totalKeys }} {{ t('apiStats.individual') }}
|
||||
{{ aggregatedStats.totalKeys }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.activeKeysCount')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys 数</span>
|
||||
<span class="text-sm font-medium text-green-600 md:text-base">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ aggregatedStats.activeKeys }} {{ t('apiStats.individual') }}
|
||||
{{ aggregatedStats.activeKeys }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.invalidKeysCount')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys 数</span>
|
||||
<span class="text-sm font-medium text-red-600 md:text-base">
|
||||
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
|
||||
{{ invalidKeys.length }} {{ t('apiStats.individual') }}
|
||||
{{ invalidKeys.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.totalRequests')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总 Token 数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.totalCost')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
|
||||
<span class="text-sm font-medium text-indigo-600 md:text-base">
|
||||
{{ aggregatedStats.usage.formattedCost }}
|
||||
</span>
|
||||
@@ -72,9 +60,7 @@
|
||||
v-if="individualStats.length > 1"
|
||||
class="border-t border-gray-200 pt-2 dark:border-gray-700"
|
||||
>
|
||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiStats.keyContribution') }}
|
||||
</div>
|
||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">各 Key 贡献占比</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="stat in topContributors"
|
||||
@@ -93,18 +79,14 @@
|
||||
<!-- 单 Key 模式下的详细信息 -->
|
||||
<div v-else class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.name')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
|
||||
<span
|
||||
class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"
|
||||
>{{ statsData.name }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.status')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">状态</span>
|
||||
<span
|
||||
class="text-sm font-medium md:text-base"
|
||||
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
|
||||
@@ -113,39 +95,35 @@
|
||||
class="mr-1 text-xs md:text-sm"
|
||||
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
|
||||
/>
|
||||
{{ statsData.isActive ? t('apiStats.active') : t('apiStats.inactive') }}
|
||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.permissions')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">权限</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatPermissions(statsData.permissions)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.createdAt')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">创建时间</span>
|
||||
<span
|
||||
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
|
||||
>{{ formatDate(statsData.createdAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.expiresAt')
|
||||
}}</span>
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>过期时间</span
|
||||
>
|
||||
<!-- 未激活状态 -->
|
||||
<div
|
||||
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
|
||||
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
|
||||
>
|
||||
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.notActivated') }}
|
||||
未激活
|
||||
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>({{ t('apiStats.firstUseDays', { days: statsData.activationDays || 30 }) }})</span
|
||||
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
||||
>
|
||||
</div>
|
||||
<!-- 已设置过期时间 -->
|
||||
@@ -155,7 +133,7 @@
|
||||
class="text-sm font-medium text-red-600 md:text-base"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.expired') }}
|
||||
已过期
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
|
||||
@@ -174,7 +152,7 @@
|
||||
<!-- 永不过期 -->
|
||||
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
|
||||
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.neverExpires') }}
|
||||
永不过期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,10 +165,10 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.usageStatsOverview') }}
|
||||
使用统计概览
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3 md:gap-4">
|
||||
@@ -199,9 +177,7 @@
|
||||
{{ formatNumber(currentPeriodData.requests) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{
|
||||
statsPeriod === 'daily' ? t('apiStats.todayRequests') : t('apiStats.monthlyRequests')
|
||||
}}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
@@ -209,7 +185,7 @@
|
||||
{{ formatNumber(currentPeriodData.allTokens) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? t('apiStats.todayTokens') : t('apiStats.monthlyTokens') }}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
@@ -217,7 +193,7 @@
|
||||
{{ currentPeriodData.formattedCost || '$0.000000' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? t('apiStats.todayCost') : t('apiStats.monthlyCost') }}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
@@ -225,11 +201,7 @@
|
||||
{{ formatNumber(currentPeriodData.inputTokens) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{
|
||||
statsPeriod === 'daily'
|
||||
? t('apiStats.todayInputTokens')
|
||||
: t('apiStats.monthlyInputTokens')
|
||||
}}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,12 +213,9 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const {
|
||||
statsData,
|
||||
@@ -276,21 +245,13 @@ const calculateContribution = (stat) => {
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return t('apiStats.none')
|
||||
if (!dateString) return '无'
|
||||
|
||||
try {
|
||||
const date = dayjs(dateString)
|
||||
// 根据当前语言环境选择日期格式
|
||||
const locale = t('common.locale', 'zh-CN')
|
||||
if (locale === 'en') {
|
||||
return date.format('YYYY-MM-DD HH:mm')
|
||||
} else if (locale === 'zh-TW') {
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
} else {
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
}
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
} catch (error) {
|
||||
return t('apiStats.formatError')
|
||||
return '格式错误'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,10 +306,10 @@ const formatPermissions = (permissions) => {
|
||||
const permissionMap = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
all: t('apiStats.allModels')
|
||||
all: '全部模型'
|
||||
}
|
||||
|
||||
return permissionMap[permissions] || permissions || t('apiStats.unknown')
|
||||
return permissionMap[permissions] || permissions || '未知'
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.tokenDistribution') }}
|
||||
Token 使用分布
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
<div class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-arrow-right mr-1 text-xs text-green-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.inputToken') }}
|
||||
输入 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.inputTokens)
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-arrow-left mr-1 text-xs text-blue-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.outputToken') }}
|
||||
输出 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.outputTokens)
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.cacheCreateToken') }}
|
||||
缓存创建 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.cacheCreateTokens)
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.cacheReadToken') }}
|
||||
缓存读取 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.cacheReadTokens)
|
||||
@@ -51,9 +51,9 @@
|
||||
</div>
|
||||
<div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700 md:mt-4 md:pt-4">
|
||||
<div class="flex items-center justify-between font-bold text-gray-900 dark:text-gray-100">
|
||||
<span class="text-sm md:text-base">{{
|
||||
statsPeriod === 'daily' ? t('apiStats.todayTotal') : t('apiStats.monthlyTotal')
|
||||
}}</span>
|
||||
<span class="text-sm md:text-base"
|
||||
>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
|
||||
>
|
||||
<span class="text-lg md:text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,11 +62,8 @@
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="$t('common.accountSelector.searchPlaceholder')"
|
||||
placeholder="搜索账号名称..."
|
||||
style="padding-left: 40px; padding-right: 36px"
|
||||
type="text"
|
||||
@input="handleSearch"
|
||||
@@ -68,9 +68,7 @@
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
|
||||
@click="selectAccount(null)"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{
|
||||
props.defaultOptionText || $t('common.accountSelector.useSharedPool')
|
||||
}}</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 分组选项 -->
|
||||
@@ -78,7 +76,7 @@
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ $t('common.accountSelector.schedulingGroups') }}
|
||||
调度分组
|
||||
</div>
|
||||
<div
|
||||
v-for="group in filteredGroups"
|
||||
@@ -90,8 +88,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>{{ group.memberCount || 0
|
||||
}}{{ $t('common.accountSelector.membersUnit') }}</span
|
||||
>{{ group.memberCount || 0 }} 个成员</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,8 +101,10 @@
|
||||
>
|
||||
{{
|
||||
platform === 'claude'
|
||||
? $t('common.accountSelector.claudeOAuthAccounts')
|
||||
: $t('common.accountSelector.oauthAccounts')
|
||||
? 'Claude OAuth 专属账号'
|
||||
: platform === 'openai'
|
||||
? 'OpenAI 专属账号'
|
||||
: 'OAuth 专属账号'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
@@ -143,7 +142,7 @@
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ $t('common.accountSelector.claudeConsoleAccounts') }}
|
||||
Claude Console 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredConsoleAccounts"
|
||||
@@ -177,13 +176,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 账号(仅 OpenAI) -->
|
||||
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
OpenAI-Responses 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOpenAIResponsesAccounts"
|
||||
:key="account.id"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
|
||||
}"
|
||||
@click="selectAccount(`responses:${account.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
account.isActive === 'true' || account.isActive === true
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: account.status === 'rate_limited'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
"
|
||||
>
|
||||
{{ getAccountStatusText(account) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ formatDate(account.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="searchQuery && !hasResults"
|
||||
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-search mb-2 text-2xl" />
|
||||
<p class="text-sm">{{ $t('common.accountSelector.noResultsFound') }}</p>
|
||||
<p class="text-sm">没有找到匹配的账号</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,9 +232,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t: $t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -206,7 +241,7 @@ const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['claude', 'gemini'].includes(value)
|
||||
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
|
||||
},
|
||||
accounts: {
|
||||
type: Array,
|
||||
@@ -222,11 +257,11 @@ const props = defineProps({
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null
|
||||
default: '请选择账号'
|
||||
},
|
||||
defaultOptionText: {
|
||||
type: String,
|
||||
default: null
|
||||
default: '使用共享账号池'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -243,16 +278,13 @@ const lastDirection = ref('') // 记住上次的显示方向
|
||||
// 获取选中的标签
|
||||
const selectedLabel = computed(() => {
|
||||
// 如果没有选中值,显示默认选项文本
|
||||
if (!props.modelValue)
|
||||
return props.defaultOptionText || $t('common.accountSelector.useSharedPool')
|
||||
if (!props.modelValue) return props.defaultOptionText
|
||||
|
||||
// 分组
|
||||
if (props.modelValue.startsWith('group:')) {
|
||||
const groupId = props.modelValue.substring(6)
|
||||
const group = props.groups.find((g) => g.id === groupId)
|
||||
return group
|
||||
? `${group.name} (${group.memberCount || 0}${$t('common.accountSelector.membersUnit')})`
|
||||
: ''
|
||||
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
|
||||
}
|
||||
|
||||
// Console 账号
|
||||
@@ -264,6 +296,15 @@ const selectedLabel = computed(() => {
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账号
|
||||
if (props.modelValue.startsWith('responses:')) {
|
||||
const accountId = props.modelValue.substring(10)
|
||||
const account = props.accounts.find(
|
||||
(a) => a.id === accountId && a.platform === 'openai-responses'
|
||||
)
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
}
|
||||
|
||||
// OAuth 账号
|
||||
const account = props.accounts.find((a) => a.id === props.modelValue)
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
@@ -271,26 +312,36 @@ const selectedLabel = computed(() => {
|
||||
|
||||
// 获取账户状态文本
|
||||
const getAccountStatusText = (account) => {
|
||||
if (!account) return $t('common.accountSelector.accountStatus.unknown')
|
||||
if (!account) return '未知'
|
||||
|
||||
// 处理 OpenAI-Responses 账号(isActive 可能是字符串)
|
||||
const isActive = account.isActive === 'true' || account.isActive === true
|
||||
|
||||
// 优先使用 isActive 判断
|
||||
if (account.isActive === false) {
|
||||
if (!isActive) {
|
||||
// 根据 status 提供更详细的状态信息
|
||||
switch (account.status) {
|
||||
case 'unauthorized':
|
||||
return $t('common.accountSelector.accountStatus.unauthorized')
|
||||
return '未授权'
|
||||
case 'error':
|
||||
return $t('common.accountSelector.accountStatus.tokenError')
|
||||
return 'Token错误'
|
||||
case 'created':
|
||||
return $t('common.accountSelector.accountStatus.pending')
|
||||
return '待验证'
|
||||
case 'rate_limited':
|
||||
return $t('common.accountSelector.accountStatus.rateLimited')
|
||||
return '限流中'
|
||||
case 'quota_exceeded':
|
||||
return '额度超限'
|
||||
default:
|
||||
return $t('common.accountSelector.accountStatus.error')
|
||||
return '异常'
|
||||
}
|
||||
}
|
||||
|
||||
return $t('common.accountSelector.accountStatus.active')
|
||||
// 对于激活的账号,如果是限流状态也要显示
|
||||
if (account.status === 'rate_limited') {
|
||||
return '限流中'
|
||||
}
|
||||
|
||||
return '正常'
|
||||
}
|
||||
|
||||
// 按创建时间倒序排序账号
|
||||
@@ -302,18 +353,42 @@ const sortedAccounts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤的分组
|
||||
// 过滤的分组(根据平台类型过滤)
|
||||
const filteredGroups = computed(() => {
|
||||
if (!searchQuery.value) return props.groups
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
// 只显示与当前平台匹配的分组
|
||||
let groups = props.groups.filter((group) => {
|
||||
// 如果分组有platform属性,则必须匹配当前平台
|
||||
// 如果没有platform属性,则认为是旧数据,根据平台判断
|
||||
if (group.platform) {
|
||||
return group.platform === props.platform
|
||||
}
|
||||
// 向后兼容:如果没有platform字段,通过其他方式判断
|
||||
return true
|
||||
})
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
groups = groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 过滤的 OAuth 账号
|
||||
const filteredOAuthAccounts = computed(() => {
|
||||
let accounts = sortedAccounts.value.filter((a) =>
|
||||
props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console'
|
||||
)
|
||||
let accounts = []
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
|
||||
} else if (props.platform === 'openai') {
|
||||
// 对于 OpenAI,只显示 openai 类型的账号
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
||||
} else {
|
||||
// 其他平台显示所有非特殊类型的账号
|
||||
accounts = sortedAccounts.value.filter(
|
||||
(a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform)
|
||||
)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
@@ -337,12 +412,27 @@ const filteredConsoleAccounts = computed(() => {
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 过滤的 OpenAI-Responses 账号
|
||||
const filteredOpenAIResponsesAccounts = computed(() => {
|
||||
if (props.platform !== 'openai') return []
|
||||
|
||||
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 是否有搜索结果
|
||||
const hasResults = computed(() => {
|
||||
return (
|
||||
filteredGroups.value.length > 0 ||
|
||||
filteredOAuthAccounts.value.length > 0 ||
|
||||
filteredConsoleAccounts.value.length > 0
|
||||
filteredConsoleAccounts.value.length > 0 ||
|
||||
filteredOpenAIResponsesAccounts.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
@@ -354,12 +444,12 @@ const formatDate = (dateString) => {
|
||||
const diffInHours = (now - date) / (1000 * 60 * 60)
|
||||
|
||||
if (diffInHours < 24) {
|
||||
return $t('common.accountSelector.dateFormat.today')
|
||||
return '今天创建'
|
||||
} else if (diffInHours < 48) {
|
||||
return $t('common.accountSelector.dateFormat.yesterday')
|
||||
return '昨天创建'
|
||||
} else if (diffInHours < 168) {
|
||||
// 7天内
|
||||
return `${Math.floor(diffInHours / 24)}${$t('common.accountSelector.dateFormat.daysAgo')}`
|
||||
return `${Math.floor(diffInHours / 24)} 天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
@@ -49,26 +49,22 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
const isVisible = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const title = ref('')
|
||||
const message = ref('')
|
||||
const confirmText = ref(t('common.confirmDialog.confirm'))
|
||||
const cancelText = ref(t('common.confirmDialog.cancel'))
|
||||
const confirmText = ref('确认')
|
||||
const cancelText = ref('取消')
|
||||
let resolvePromise = null
|
||||
|
||||
// 显示确认对话框
|
||||
const showConfirm = (
|
||||
titleText,
|
||||
messageText,
|
||||
confirmTextParam = t('common.confirmDialog.confirm'),
|
||||
cancelTextParam = t('common.confirmDialog.cancel')
|
||||
confirmTextParam = '确认',
|
||||
cancelTextParam = '取消'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
title.value = titleText
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-yellow-600 hover:to-orange-600"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
@@ -60,17 +55,14 @@ const props = defineProps({
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '继续'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '取消'
|
||||
}
|
||||
})
|
||||
|
||||
const confirmLabel = computed(() => props.confirmText || t('common.confirmModal.continue'))
|
||||
const cancelLabel = computed(() => props.cancelText || t('common.confirmModal.cancel'))
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<span
|
||||
class="select-none whitespace-nowrap text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{{ selectedLabel || placeholderText }}
|
||||
{{ selectedLabel || placeholder }}
|
||||
</span>
|
||||
<i
|
||||
:class="[
|
||||
@@ -65,9 +65,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -80,7 +77,7 @@ const props = defineProps({
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '请选择'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
@@ -99,8 +96,6 @@ const triggerRef = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const dropdownStyle = ref({})
|
||||
|
||||
const placeholderText = computed(() => props.placeholder || t('common.customDropdown.placeholder'))
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
||||
return selected ? selected.label : ''
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<div class="language-switch" :class="containerClass">
|
||||
<!-- 下拉菜单模式 -->
|
||||
<div v-if="mode === 'dropdown'" class="relative">
|
||||
<button
|
||||
ref="dropdownTrigger"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:border-gray-500"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ currentLocaleInfo.flag }}</span>
|
||||
<i
|
||||
:class="[
|
||||
'fas fa-chevron-down text-xs transition-transform duration-200',
|
||||
showDropdown ? 'rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 下拉选项 -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-40 rounded-lg border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="locale in supportedLocales"
|
||||
:key="locale.code"
|
||||
class="flex w-full items-center gap-3 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400':
|
||||
locale.code === localeStore.currentLocale
|
||||
}"
|
||||
@click="switchLocale(locale.code)"
|
||||
>
|
||||
<span class="text-base font-medium">{{ locale.flag }}</span>
|
||||
<span class="flex-1 text-left">{{ locale.name }}</span>
|
||||
<i
|
||||
v-if="locale.code === localeStore.currentLocale"
|
||||
class="fas fa-check text-xs text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 按钮模式 -->
|
||||
<div v-else-if="mode === 'button'" class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="locale in supportedLocales"
|
||||
:key="locale.code"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-all duration-200"
|
||||
:class="[
|
||||
locale.code === localeStore.currentLocale
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
]"
|
||||
@click="switchLocale(locale.code)"
|
||||
>
|
||||
<span>{{ locale.flag }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 图标模式 -->
|
||||
<div v-else-if="mode === 'icon'" class="relative">
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-all duration-200 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span class="text-lg">{{ currentLocaleInfo.flag }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 简化的下拉选项 -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-36 rounded-lg border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="locale in supportedLocales"
|
||||
:key="locale.code"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400':
|
||||
locale.code === localeStore.currentLocale
|
||||
}"
|
||||
@click="switchLocale(locale.code)"
|
||||
>
|
||||
<span class="font-medium">{{ locale.flag }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocaleStore } from '@/stores/locale'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'dropdown', // dropdown | button | icon
|
||||
validator: (value) => ['dropdown', 'button', 'icon'].includes(value)
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium', // small | medium | large
|
||||
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
// 发出事件
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
// 存储和响应式数据
|
||||
const { t } = useI18n()
|
||||
const localeStore = useLocaleStore()
|
||||
const showDropdown = ref(false)
|
||||
const dropdownTrigger = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const currentLocaleInfo = computed(() => localeStore.getCurrentLocaleInfo(t))
|
||||
const supportedLocales = computed(() => localeStore.getSupportedLocales(t))
|
||||
|
||||
const containerClass = computed(() => {
|
||||
const classes = []
|
||||
|
||||
if (props.size === 'small') {
|
||||
classes.push('text-xs')
|
||||
} else if (props.size === 'large') {
|
||||
classes.push('text-base')
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
// 切换语言
|
||||
const switchLocale = (locale) => {
|
||||
localeStore.setLocale(locale)
|
||||
showDropdown.value = false
|
||||
emit('change', locale)
|
||||
}
|
||||
|
||||
// 切换下拉菜单显示
|
||||
const toggleDropdown = () => {
|
||||
showDropdown.value = !showDropdown.value
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownTrigger.value && !dropdownTrigger.value.contains(event.target)) {
|
||||
showDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-switch {
|
||||
/* 自定义样式可以在这里添加 */
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<template v-if="!loading">
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:alt="$t('common.logoTitle.logoAlt')"
|
||||
alt="Logo"
|
||||
class="h-8 w-8 object-contain"
|
||||
:src="logoSrc"
|
||||
@error="handleLogoError"
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// Props
|
||||
defineProps({
|
||||
@@ -89,37 +88,32 @@ defineProps({
|
||||
// Store
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
|
||||
// 主题选项配置
|
||||
const themeOptions = computed(() => [
|
||||
const themeOptions = [
|
||||
{
|
||||
value: 'light',
|
||||
label: t('common.themeToggle.light.label'),
|
||||
shortLabel: t('common.themeToggle.light.shortLabel'),
|
||||
label: '浅色模式',
|
||||
shortLabel: '浅色',
|
||||
icon: 'fas fa-sun'
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: t('common.themeToggle.dark.label'),
|
||||
shortLabel: t('common.themeToggle.dark.shortLabel'),
|
||||
label: '深色模式',
|
||||
shortLabel: '深色',
|
||||
icon: 'fas fa-moon'
|
||||
},
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('common.themeToggle.auto.label'),
|
||||
shortLabel: t('common.themeToggle.auto.shortLabel'),
|
||||
label: '跟随系统',
|
||||
shortLabel: '自动',
|
||||
icon: 'fas fa-circle-half-stroke'
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const themeTooltip = computed(() => {
|
||||
const current = themeOptions.value.find((opt) => opt.value === themeStore.themeMode)
|
||||
return current
|
||||
? `${t('common.themeToggle.clickToSwitch')} - ${current.label}`
|
||||
: t('common.themeToggle.toggleTheme')
|
||||
const current = themeOptions.find((opt) => opt.value === themeStore.themeMode)
|
||||
return current ? `点击切换主题 - ${current.label}` : '切换主题'
|
||||
})
|
||||
|
||||
// 方法
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<i :class="getIconClass(toast.type)" />
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div v-if="toast.title || getDefaultTitle(toast.type)" class="toast-title">
|
||||
{{ toast.title || getDefaultTitle(toast.type) }}
|
||||
<div v-if="toast.title" class="toast-title">
|
||||
{{ toast.title }}
|
||||
</div>
|
||||
<div class="toast-message">
|
||||
{{ toast.message }}
|
||||
@@ -35,9 +35,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
const toasts = ref([])
|
||||
@@ -54,11 +51,6 @@ const getIconClass = (type) => {
|
||||
return iconMap[type] || iconMap.info
|
||||
}
|
||||
|
||||
// 获取默认标题
|
||||
const getDefaultTitle = (type) => {
|
||||
return t(`common.toastNotification.defaultTitles.${type}`)
|
||||
}
|
||||
|
||||
// 添加Toast
|
||||
const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
const id = ++toastIdCounter
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-robot mr-2 text-purple-500" />
|
||||
{{ $t('dashboard.modelDistribution.title') }}
|
||||
模型使用分布
|
||||
</h2>
|
||||
|
||||
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
||||
<el-radio-button label="daily">
|
||||
{{ $t('dashboard.modelDistribution.periods.daily') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="total">
|
||||
{{ $t('dashboard.modelDistribution.periods.total') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="daily"> 今日 </el-radio-button>
|
||||
<el-radio-button label="total"> 累计 </el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
@@ -21,16 +17,16 @@
|
||||
class="py-12 text-center text-gray-500"
|
||||
>
|
||||
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
|
||||
<p>{{ $t('dashboard.modelDistribution.noData') }}</p>
|
||||
<p>暂无模型使用数据</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Pie Chart -->
|
||||
<!-- 饼图 -->
|
||||
<div class="relative" style="height: 300px">
|
||||
<canvas ref="chartCanvas" />
|
||||
</div>
|
||||
|
||||
<!-- Data List -->
|
||||
<!-- 数据列表 -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(stat, index) in sortedStats"
|
||||
@@ -42,14 +38,8 @@
|
||||
<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) }}
|
||||
{{ $t('dashboard.modelDistribution.units.requests') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ formatNumber(stat.totalTokens) }}
|
||||
{{ $t('dashboard.modelDistribution.units.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>
|
||||
@@ -60,13 +50,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
@@ -123,8 +110,8 @@ const createChart = () => {
|
||||
).toFixed(1)
|
||||
return [
|
||||
`${stat.model}: ${percentage}%`,
|
||||
`${t('dashboard.modelDistribution.chart.tooltip.requests')}: ${formatNumber(stat.requests)}`,
|
||||
`${t('dashboard.modelDistribution.chart.tooltip.tokens')}: ${formatNumber(stat.totalTokens)}`
|
||||
`请求: ${formatNumber(stat.requests)}`,
|
||||
`Tokens: ${formatNumber(stat.totalTokens)}`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-chart-area mr-2 text-blue-500" />
|
||||
{{ $t('dashboard.usageTrend.title') }}
|
||||
使用趋势
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
||||
<el-radio-button label="day">
|
||||
{{ $t('dashboard.usageTrend.granularity.byDay') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="hour">
|
||||
{{ $t('dashboard.usageTrend.granularity.byHour') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="day"> 按天 </el-radio-button>
|
||||
<el-radio-button label="hour"> 按小时 </el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select
|
||||
@@ -25,7 +21,7 @@
|
||||
<el-option
|
||||
v-for="period in periodOptions"
|
||||
:key="period.days"
|
||||
:label="$t('dashboard.usageTrend.periodOptions.recentDays', { days: period.days })"
|
||||
:label="`最近${period.days}天`"
|
||||
:value="period.days"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -39,25 +35,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
const { t } = useI18n()
|
||||
|
||||
const trendPeriod = ref(7)
|
||||
const granularity = ref('day')
|
||||
|
||||
const periodOptions = computed(() => [
|
||||
{ days: 1, label: t('dashboard.usageTrend.periodOptions.last24Hours') },
|
||||
{ days: 7, label: t('dashboard.usageTrend.periodOptions.last7Days') },
|
||||
{ days: 30, label: t('dashboard.usageTrend.periodOptions.last30Days') }
|
||||
])
|
||||
const periodOptions = [
|
||||
{ days: 1, label: '24小时' },
|
||||
{ days: 7, label: '7天' },
|
||||
{ days: 30, label: '30天' }
|
||||
]
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
||||
@@ -87,7 +81,7 @@ const createChart = () => {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.usageTrend.chartLabels.requests'),
|
||||
label: '请求次数',
|
||||
data: dashboardStore.trendData.map((item) => item.requests),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||
@@ -95,7 +89,7 @@ const createChart = () => {
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: t('dashboard.usageTrend.chartLabels.tokens'),
|
||||
label: 'Token使用量',
|
||||
data: dashboardStore.trendData.map((item) => item.tokens),
|
||||
borderColor: '#f093fb',
|
||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||
@@ -133,7 +127,7 @@ const createChart = () => {
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.usageTrend.chartLabels.requestsAxis')
|
||||
text: '请求次数'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
@@ -142,7 +136,7 @@ const createChart = () => {
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.usageTrend.chartLabels.tokensAxis')
|
||||
text: 'Token使用量'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:subtitle="t('header.adminPanel')"
|
||||
subtitle="管理后台"
|
||||
:title="oemSettings.siteName"
|
||||
title-class="text-white dark:text-gray-100"
|
||||
>
|
||||
@@ -27,10 +27,10 @@
|
||||
class="inline-flex animate-pulse items-center gap-1 rounded-full border border-green-600 bg-green-500 px-2 py-0.5 text-xs text-white transition-colors hover:bg-green-600"
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
:title="t('header.newVersionAvailable')"
|
||||
title="有新版本可用"
|
||||
>
|
||||
<i class="fas fa-arrow-up text-[10px]" />
|
||||
<span>{{ t('header.newVersion') }}</span>
|
||||
<span>新版本</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,11 +38,6 @@
|
||||
</div>
|
||||
<!-- 主题切换和用户菜单 -->
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<!-- 语言切换按钮 -->
|
||||
<div class="flex items-center">
|
||||
<LanguageSwitch mode="dropdown" size="medium" />
|
||||
</div>
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle mode="dropdown" />
|
||||
@@ -60,7 +55,7 @@
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
>
|
||||
<i class="fas fa-user-circle text-sm sm:text-base" />
|
||||
<span class="hidden sm:inline">{{ currentUser.username || t('common.admin') }}</span>
|
||||
<span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span>
|
||||
<i
|
||||
class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
|
||||
:class="{ 'rotate-180': userMenuOpen }"
|
||||
@@ -77,9 +72,7 @@
|
||||
<!-- 版本信息 -->
|
||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{
|
||||
t('header.currentVersion')
|
||||
}}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">当前版本</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-300"
|
||||
>v{{ versionInfo.current || '...' }}</span
|
||||
>
|
||||
@@ -87,7 +80,7 @@
|
||||
<div v-if="versionInfo.hasUpdate" class="mt-2">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-arrow-up mr-1" />{{ t('header.hasUpdate') }}
|
||||
<i class="fas fa-arrow-up mr-1" />有新版本
|
||||
</span>
|
||||
<span class="font-mono text-green-600 dark:text-green-400"
|
||||
>v{{ versionInfo.latest }}</span
|
||||
@@ -98,14 +91,14 @@
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt mr-1" />{{ t('header.viewUpdate') }}
|
||||
<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 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-1" />{{ t('header.checkingUpdate') }}
|
||||
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
|
||||
</div>
|
||||
<div v-else class="mt-2 text-center">
|
||||
<!-- 已是最新版提醒 -->
|
||||
@@ -116,7 +109,7 @@
|
||||
class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5 dark:border-green-800 dark:bg-green-900/30"
|
||||
>
|
||||
<p class="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
<i class="fas fa-check-circle mr-1" />{{ t('header.alreadyLatest') }}
|
||||
<i class="fas fa-check-circle mr-1" />当前已是最新版本
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -125,7 +118,7 @@
|
||||
class="text-xs text-blue-500 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
@click="checkForUpdates()"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('header.checkUpdate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />检查更新
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -136,7 +129,7 @@
|
||||
@click="openChangePasswordModal"
|
||||
>
|
||||
<i class="fas fa-key text-blue-500" />
|
||||
<span>{{ t('header.changeAccountInfo') }}</span>
|
||||
<span>修改账户信息</span>
|
||||
</button>
|
||||
|
||||
<hr class="my-2 border-gray-200 dark:border-gray-700" />
|
||||
@@ -146,7 +139,7 @@
|
||||
@click="logout"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt text-red-500" />
|
||||
<span>{{ t('header.logout') }}</span>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,9 +160,7 @@
|
||||
>
|
||||
<i class="fas fa-key text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ t('header.changePasswordModal.title') }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改账户信息</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||
@@ -184,72 +175,68 @@
|
||||
@submit.prevent="changePassword"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.currentUsername')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>当前用户名</label
|
||||
>
|
||||
<input
|
||||
class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
|
||||
disabled
|
||||
type="text"
|
||||
:value="currentUser.username || t('common.admin')"
|
||||
:value="currentUser.username || 'Admin'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('header.changePasswordModal.currentUsernameHint') }}
|
||||
当前用户名,输入新用户名以修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.newUsername')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>新用户名</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.newUsername"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.newUsernamePlaceholder')"
|
||||
placeholder="输入新用户名(留空保持不变)"
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('header.changePasswordModal.newUsernameHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">留空表示不修改用户名</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.currentPassword')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>当前密码</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.currentPasswordPlaceholder')"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.newPassword')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>新密码</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.newPasswordPlaceholder')"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('header.changePasswordModal.newPasswordHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">密码长度至少8位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.confirmPassword')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>确认新密码</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.confirmPasswordPlaceholder')"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
@@ -261,7 +248,7 @@
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -270,11 +257,7 @@
|
||||
>
|
||||
<div v-if="changePasswordLoading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
changePasswordLoading
|
||||
? t('header.changePasswordModal.saving')
|
||||
: t('header.changePasswordModal.save')
|
||||
}}
|
||||
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -285,20 +268,17 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = computed(() => authStore.user || {})
|
||||
const currentUser = computed(() => authStore.user || { username: 'Admin' })
|
||||
|
||||
// OEM设置
|
||||
const oemSettings = computed(() => authStore.oemSettings || {})
|
||||
@@ -405,12 +385,12 @@ const closeChangePasswordModal = () => {
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
|
||||
showToast(t('header.changePasswordModal.passwordMismatch'), 'error')
|
||||
showToast('两次输入的密码不一致', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (changePasswordForm.newPassword.length < 8) {
|
||||
showToast(t('header.changePasswordModal.passwordTooShort'), 'error')
|
||||
showToast('新密码长度至少8位', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -425,8 +405,8 @@ const changePassword = async () => {
|
||||
|
||||
if (data.success) {
|
||||
const message = changePasswordForm.newUsername
|
||||
? t('header.changePasswordModal.accountInfoChangeSuccess')
|
||||
: t('header.changePasswordModal.passwordChangeSuccess')
|
||||
? '账户信息修改成功,请重新登录'
|
||||
: '密码修改成功,请重新登录'
|
||||
showToast(message, 'success')
|
||||
closeChangePasswordModal()
|
||||
|
||||
@@ -436,10 +416,10 @@ const changePassword = async () => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} else {
|
||||
showToast(data.message || t('header.changePasswordModal.changeFailed'), 'error')
|
||||
showToast(data.message || '修改失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('header.changePasswordModal.changePasswordFailed'), 'error')
|
||||
showToast('修改密码失败', 'error')
|
||||
} finally {
|
||||
changePasswordLoading.value = false
|
||||
}
|
||||
@@ -447,10 +427,10 @@ const changePassword = async () => {
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
if (confirm(t('header.logoutConfirm'))) {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
showToast(t('header.logoutSuccess'), 'success')
|
||||
showToast('已安全退出', 'success')
|
||||
}
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
@@ -22,13 +22,10 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -127,7 +124,7 @@ const handleTabChange = async (tabKey) => {
|
||||
} catch (err) {
|
||||
// 如果路由切换失败,恢复activeTab状态
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
console.error(t('layout.mainLayout.routing.routeChangeError'), err)
|
||||
console.error('路由切换失败:', err)
|
||||
// 恢复到当前路由对应的tab
|
||||
initActiveTab()
|
||||
}
|
||||
|
||||
@@ -38,11 +38,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
@@ -57,49 +54,24 @@ const authStore = useAuthStore()
|
||||
// 根据 LDAP 配置动态生成 tabs
|
||||
const tabs = computed(() => {
|
||||
const baseTabs = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
name: t('layout.tabBar.tabs.dashboard.name'),
|
||||
shortName: t('layout.tabBar.tabs.dashboard.shortName'),
|
||||
icon: 'fas fa-tachometer-alt'
|
||||
},
|
||||
{
|
||||
key: 'apiKeys',
|
||||
name: t('layout.tabBar.tabs.apiKeys.name'),
|
||||
shortName: t('layout.tabBar.tabs.apiKeys.shortName'),
|
||||
icon: 'fas fa-key'
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
name: t('layout.tabBar.tabs.accounts.name'),
|
||||
shortName: t('layout.tabBar.tabs.accounts.shortName'),
|
||||
icon: 'fas fa-user-circle'
|
||||
}
|
||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
|
||||
]
|
||||
|
||||
// 只有在 LDAP 启用时才显示用户管理
|
||||
if (authStore.oemSettings?.ldapEnabled) {
|
||||
baseTabs.push({
|
||||
key: 'userManagement',
|
||||
name: t('layout.tabBar.tabs.userManagement.name'),
|
||||
shortName: t('layout.tabBar.tabs.userManagement.shortName'),
|
||||
name: '用户管理',
|
||||
shortName: '用户',
|
||||
icon: 'fas fa-users'
|
||||
})
|
||||
}
|
||||
|
||||
baseTabs.push(
|
||||
{
|
||||
key: 'tutorial',
|
||||
name: t('layout.tabBar.tabs.tutorial.name'),
|
||||
shortName: t('layout.tabBar.tabs.tutorial.shortName'),
|
||||
icon: 'fas fa-graduation-cap'
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
name: t('layout.tabBar.tabs.settings.name'),
|
||||
shortName: t('layout.tabBar.tabs.settings.shortName'),
|
||||
icon: 'fas fa-cogs'
|
||||
}
|
||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
||||
)
|
||||
|
||||
return baseTabs
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ t('user.createApiKeyModal.title') }}</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -23,16 +23,13 @@
|
||||
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="name">
|
||||
{{ t('user.createApiKeyModal.form.nameLabel') }}
|
||||
{{ t('user.createApiKeyModal.form.nameRequired') }}
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
:disabled="loading"
|
||||
:placeholder="t('user.createApiKeyModal.form.namePlaceholder')"
|
||||
placeholder="Enter API key name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
@@ -40,14 +37,14 @@
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="description">
|
||||
{{ t('user.createApiKeyModal.form.descriptionLabel') }}
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
:disabled="loading"
|
||||
:placeholder="t('user.createApiKeyModal.form.descriptionPlaceholder')"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -76,7 +73,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ t('user.createApiKeyModal.buttons.cancel') }}
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -104,9 +101,9 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{{ t('user.createApiKeyModal.buttons.creating') }}
|
||||
Creating...
|
||||
</span>
|
||||
<span v-else>{{ t('user.createApiKeyModal.buttons.createApiKey') }}</span>
|
||||
<span v-else>Create API Key</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -124,13 +121,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h4 class="text-sm font-medium text-green-800">
|
||||
{{ t('user.createApiKeyModal.success.title') }}
|
||||
</h4>
|
||||
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
|
||||
<div class="mt-3">
|
||||
<p class="mb-2 text-sm text-green-700">
|
||||
<strong>{{ t('user.createApiKeyModal.success.warning.important') }}</strong>
|
||||
{{ t('user.createApiKeyModal.success.warning.message') }}
|
||||
<strong>Important:</strong> Copy your API key now. You won't be able to see it
|
||||
again!
|
||||
</p>
|
||||
<div class="rounded-md border border-green-300 bg-white p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -154,7 +149,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.createApiKeyModal.buttons.copy') }}
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +159,7 @@
|
||||
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('user.createApiKeyModal.buttons.done') }}
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +172,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
@@ -190,7 +184,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -211,7 +204,7 @@ const resetForm = () => {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
error.value = t('user.createApiKeyModal.validation.nameRequired')
|
||||
error.value = 'API key name is required'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,14 +221,13 @@ const handleSubmit = async () => {
|
||||
|
||||
if (result.success) {
|
||||
newApiKey.value = result.apiKey
|
||||
showToast(t('user.createApiKeyModal.messages.createSuccess'), 'success')
|
||||
showToast('API key created successfully!', 'success')
|
||||
} else {
|
||||
error.value = result.message || t('user.createApiKeyModal.errors.createFailed')
|
||||
error.value = result.message || 'Failed to create API key'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create API key error:', err)
|
||||
error.value =
|
||||
err.response?.data?.message || err.message || t('user.createApiKeyModal.errors.createFailed')
|
||||
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -244,10 +236,10 @@ const handleSubmit = async () => {
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showToast(t('user.createApiKeyModal.messages.copySuccess'), 'success')
|
||||
showToast('API key copied to clipboard!', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showToast(t('user.createApiKeyModal.messages.copyFailed'), 'error')
|
||||
showToast('Failed to copy to clipboard', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">
|
||||
{{ t('user.userApiKeysManager.title') }}
|
||||
</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">
|
||||
{{ t('user.userApiKeysManager.description') }}
|
||||
Manage your API keys to access Claude Relay services
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
@@ -23,7 +21,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.userApiKeysManager.buttons.createApiKey') }}
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +43,8 @@
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
{{ t('user.userApiKeysManager.warnings.maxKeysReached', { maxApiKeys }) }}
|
||||
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
|
||||
existing key to create a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +72,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">{{ t('user.userApiKeysManager.loading') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys List -->
|
||||
@@ -101,37 +100,29 @@
|
||||
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
|
||||
>
|
||||
{{ t('user.userApiKeysManager.status.deleted') }}
|
||||
Deleted
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!apiKey.isActive"
|
||||
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
{{ t('user.userApiKeysManager.status.deleted') }}
|
||||
Deleted
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ apiKey.description || t('user.userApiKeysManager.status.noDescription') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
|
||||
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
|
||||
<span
|
||||
>{{ t('user.userApiKeysManager.dateLabels.created') }}:
|
||||
{{ formatDate(apiKey.createdAt) }}</span
|
||||
>
|
||||
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
|
||||
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||
>{{ t('user.userApiKeysManager.dateLabels.deleted') }}:
|
||||
{{ formatDate(apiKey.deletedAt) }}</span
|
||||
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
|
||||
>
|
||||
<span v-else-if="apiKey.lastUsedAt"
|
||||
>{{ t('user.userApiKeysManager.dateLabels.lastUsed') }}:
|
||||
{{ formatDate(apiKey.lastUsedAt) }}</span
|
||||
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
|
||||
>
|
||||
<span v-else>{{ t('user.userApiKeysManager.status.neverUsed') }}</span>
|
||||
<span v-else>Never used</span>
|
||||
<span
|
||||
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
|
||||
>{{ t('user.userApiKeysManager.dateLabels.expires') }}:
|
||||
{{ formatDate(apiKey.expiresAt) }}</span
|
||||
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,10 +131,7 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Usage Stats -->
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
<div>
|
||||
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||
{{ t('user.userApiKeysManager.usage.requests') }}
|
||||
</div>
|
||||
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
|
||||
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +139,7 @@
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
|
||||
:title="t('user.userApiKeysManager.actions.viewApiKey')"
|
||||
title="View API Key"
|
||||
@click="showApiKey(apiKey)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -177,7 +165,7 @@
|
||||
allowUserDeleteApiKeys
|
||||
"
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
||||
:title="t('user.userApiKeysManager.actions.deleteApiKey')"
|
||||
title="Delete API Key"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -211,12 +199,8 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ t('user.userApiKeysManager.emptyState.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ t('user.userApiKeysManager.emptyState.description') }}
|
||||
</p>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@@ -230,7 +214,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.userApiKeysManager.buttons.createApiKey') }}
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,10 +236,10 @@
|
||||
<!-- Confirm Delete Modal -->
|
||||
<ConfirmModal
|
||||
confirm-class="bg-red-600 hover:bg-red-700"
|
||||
:confirm-text="t('user.userApiKeysManager.buttons.delete')"
|
||||
:message="t('user.userApiKeysManager.confirmDelete.message', { name: selectedApiKey?.name })"
|
||||
confirm-text="Delete"
|
||||
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
|
||||
:show="showDeleteModal"
|
||||
:title="t('user.userApiKeysManager.confirmDelete.title')"
|
||||
title="Delete API Key"
|
||||
@cancel="showDeleteModal = false"
|
||||
@confirm="handleDeleteConfirm"
|
||||
/>
|
||||
@@ -264,15 +248,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
||||
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -310,12 +291,7 @@ const formatNumber = (num) => {
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
const localeMap = {
|
||||
'zh-cn': 'zh-CN',
|
||||
'zh-tw': 'zh-TW',
|
||||
en: 'en-US'
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString(localeMap[locale.value] || 'en-US', {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -330,7 +306,7 @@ const loadApiKeys = async () => {
|
||||
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
showToast(t('user.userApiKeysManager.messages.loadFailed'), 'error')
|
||||
showToast('Failed to load API keys', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -351,12 +327,12 @@ const handleDeleteConfirm = async () => {
|
||||
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
|
||||
|
||||
if (result.success) {
|
||||
showToast(t('user.userApiKeysManager.messages.deleteSuccess'), 'success')
|
||||
showToast('API key deleted successfully', 'success')
|
||||
await loadApiKeys()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error)
|
||||
showToast(t('user.userApiKeysManager.messages.deleteFailed'), 'error')
|
||||
showToast('Failed to delete API key', 'error')
|
||||
} finally {
|
||||
showDeleteModal.value = false
|
||||
selectedApiKey.value = null
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">{{ t('user.userUsageStats.title') }}</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">{{ t('user.userUsageStats.subtitle') }}</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<select
|
||||
@@ -11,10 +11,10 @@
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
@change="loadUsageStats"
|
||||
>
|
||||
<option value="day">{{ t('user.userUsageStats.periodSelection.day') }}</option>
|
||||
<option value="week">{{ t('user.userUsageStats.periodSelection.week') }}</option>
|
||||
<option value="month">{{ t('user.userUsageStats.periodSelection.month') }}</option>
|
||||
<option value="quarter">{{ t('user.userUsageStats.periodSelection.quarter') }}</option>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
<option value="month">Last 30 Days</option>
|
||||
<option value="quarter">Last 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">{{ t('user.userUsageStats.loadingStats') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
@@ -66,9 +66,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.totalRequests') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||
</dd>
|
||||
@@ -98,9 +96,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.inputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -130,9 +126,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.outputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -162,9 +156,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.totalCost') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||
</dd>
|
||||
@@ -178,9 +170,7 @@
|
||||
<!-- Daily Usage Chart -->
|
||||
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||
{{ t('user.userUsageStats.usageTrend.title') }}
|
||||
</h3>
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
|
||||
|
||||
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
|
||||
<div
|
||||
@@ -200,14 +190,10 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ t('user.userUsageStats.usageTrend.chartTitle') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ t('user.userUsageStats.usageTrend.dailyTrendsDescription') }}
|
||||
</p>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{{ t('user.userUsageStats.usageTrend.chartIntegrationNote') }}
|
||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,9 +206,7 @@
|
||||
class="rounded-lg bg-white shadow"
|
||||
>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||
{{ t('user.userUsageStats.modelUsage.title') }}
|
||||
</h3>
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="model in usageStats.modelStats"
|
||||
@@ -238,13 +222,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-900">
|
||||
{{
|
||||
t('user.userUsageStats.modelUsage.requestsCount', {
|
||||
count: formatNumber(model.requests)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
|
||||
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,9 +233,7 @@
|
||||
<!-- Detailed Usage Table -->
|
||||
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||
{{ t('user.userUsageStats.apiKeyUsage.title') }}
|
||||
</h3>
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
@@ -266,37 +242,37 @@
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.apiKey') }}
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.requests') }}
|
||||
Requests
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.inputTokens') }}
|
||||
Input Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.outputTokens') }}
|
||||
Output Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.cost') }}
|
||||
Cost
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.status') }}
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -331,10 +307,10 @@
|
||||
>
|
||||
{{
|
||||
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
||||
? t('user.userUsageStats.apiKeyUsage.status.deleted')
|
||||
? 'Deleted'
|
||||
: apiKey.isActive
|
||||
? t('user.userUsageStats.apiKeyUsage.status.active')
|
||||
: t('user.userUsageStats.apiKeyUsage.status.disabled')
|
||||
? 'Active'
|
||||
: 'Disabled'
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
@@ -363,11 +339,10 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ t('user.userUsageStats.noData.title') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ t('user.userUsageStats.noData.description') }}
|
||||
You haven't made any API requests yet. Create an API key and start using the service to see
|
||||
usage statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,12 +350,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -409,7 +381,7 @@ const loadUsageStats = async () => {
|
||||
userApiKeys.value = apiKeys
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage stats:', error)
|
||||
showToast(t('user.userUsageStats.loadFailed'), 'error')
|
||||
showToast('Failed to load usage statistics', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ t('user.viewApiKeyModal.title') }}</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -24,35 +24,29 @@
|
||||
<div v-if="apiKey" class="space-y-4">
|
||||
<!-- API Key Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.name')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="apiKey.description">
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.description')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.apiKey')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<div class="mt-1 flex items-center space-x-2">
|
||||
<div class="flex-1">
|
||||
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||
<code class="break-all font-mono text-sm text-gray-900">{{
|
||||
apiKey.key || t('user.viewApiKeyModal.apiKeyDisplay.notAvailable')
|
||||
apiKey.key || 'Not available'
|
||||
}}</code>
|
||||
</div>
|
||||
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||
<code class="font-mono text-sm text-gray-900">{{
|
||||
apiKey.keyPreview || t('user.viewApiKeyModal.apiKeyDisplay.keyPreview')
|
||||
apiKey.keyPreview || 'cr_****'
|
||||
}}</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,11 +90,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{
|
||||
showFullKey
|
||||
? t('user.viewApiKeyModal.buttons.hide')
|
||||
: t('user.viewApiKeyModal.buttons.show')
|
||||
}}
|
||||
{{ showFullKey ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showFullKey && apiKey.key"
|
||||
@@ -115,20 +105,18 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.viewApiKeyModal.buttons.copy') }}
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
|
||||
{{ t('user.viewApiKeyModal.apiKeyDisplay.fullKeyNotice') }}
|
||||
Full API key is only shown when first created or regenerated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.status')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Status</label>
|
||||
<div class="mt-1">
|
||||
<span
|
||||
:class="[
|
||||
@@ -136,47 +124,33 @@
|
||||
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
apiKey.isActive
|
||||
? t('user.viewApiKeyModal.status.active')
|
||||
: t('user.viewApiKeyModal.status.disabled')
|
||||
}}
|
||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats -->
|
||||
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.usageStatistics')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.requests') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Requests:</span>
|
||||
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.inputTokens') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Input Tokens:</span>
|
||||
<span class="ml-2 font-medium">{{
|
||||
formatNumber(apiKey.usage.inputTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.outputTokens') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Output Tokens:</span>
|
||||
<span class="ml-2 font-medium">{{
|
||||
formatNumber(apiKey.usage.outputTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.totalCost') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Total Cost:</span>
|
||||
<span class="ml-2 font-medium"
|
||||
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
|
||||
>
|
||||
@@ -187,17 +161,15 @@
|
||||
<!-- Timestamps -->
|
||||
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">{{ t('user.viewApiKeyModal.timestamps.created') }}:</span>
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.timestamps.lastUsed') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Last Used:</span>
|
||||
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="apiKey.expiresAt" class="flex justify-between">
|
||||
<span class="text-gray-500">{{ t('user.viewApiKeyModal.timestamps.expires') }}:</span>
|
||||
<span class="text-gray-500">Expires:</span>
|
||||
<span
|
||||
:class="[
|
||||
'font-medium',
|
||||
@@ -214,7 +186,7 @@
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ t('user.viewApiKeyModal.buttons.close') }}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,11 +197,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -256,26 +225,22 @@ const formatNumber = (num) => {
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
const { locale } = useI18n()
|
||||
return new Date(dateString).toLocaleDateString(
|
||||
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showToast(t('user.viewApiKeyModal.messages.copySuccess'), 'success')
|
||||
showToast('Copied to clipboard!', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showToast(t('user.viewApiKeyModal.messages.copyFailed'), 'error')
|
||||
showToast('Failed to copy to clipboard', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user