Revert "Merge pull request #424 from Wangnov/feat/i18n"

This reverts commit 1d915d8327, reversing
changes made to 009f7c84f6.
This commit is contained in:
shaw
2025-09-12 09:21:53 +08:00
parent 1d915d8327
commit 9c4dc714f8
80 changed files with 7026 additions and 19087 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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')
}
}

View File

@@ -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=...&#10;方式2仅复制 code 参数的值&#10;系统会自动识别并提取所需信息"
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
}

View File

@@ -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: {

View File

@@ -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'

View File

@@ -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: {

View File

@@ -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')
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 || []

View File

@@ -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} 天后自动过期。`
)
}

View File

@@ -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')
}

View File

@@ -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
}

View File

@@ -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单位

View File

@@ -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}分钟`
}
}

View File

@@ -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)

View File

@@ -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支持以下格式&#10;cr_xxx&#10;cr_yyy&#10;或&#10;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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>

View File

@@ -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)

View File

@@ -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' })
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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 : ''

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}` : '切换主题'
})
// 方法

View File

@@ -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

View File

@@ -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)}`
]
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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')
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>