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

View File

@@ -1,18 +0,0 @@
# Ignore frontend directory as it has its own prettier config and plugins
web/admin-spa/
# Ignore node_modules
node_modules/
# Ignore build outputs
dist/
build/
coverage/
# Ignore logs
logs/
*.log
# Ignore temporary files
*.tmp
*.temp

View File

@@ -1 +1 @@
1.1.138 1.1.137

View File

@@ -18,8 +18,7 @@ module.exports = {
rules: { rules: {
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off', 'vue/no-v-html': 'off',
// 允许在所有环境中使用 console 语句以避免构建警告 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-console': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'prettier/prettier': 'error', 'prettier/prettier': 'error',
'vue/attributes-order': [ 'vue/attributes-order': [

View File

@@ -1,102 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: (typeof import('vue'))['EffectScope']
const acceptHMRUpdate: (typeof import('pinia'))['acceptHMRUpdate']
const computed: (typeof import('vue'))['computed']
const createApp: (typeof import('vue'))['createApp']
const createPinia: (typeof import('pinia'))['createPinia']
const customRef: (typeof import('vue'))['customRef']
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
const defineComponent: (typeof import('vue'))['defineComponent']
const defineStore: (typeof import('pinia'))['defineStore']
const effectScope: (typeof import('vue'))['effectScope']
const getActivePinia: (typeof import('pinia'))['getActivePinia']
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
const getCurrentScope: (typeof import('vue'))['getCurrentScope']
const h: (typeof import('vue'))['h']
const inject: (typeof import('vue'))['inject']
const isProxy: (typeof import('vue'))['isProxy']
const isReactive: (typeof import('vue'))['isReactive']
const isReadonly: (typeof import('vue'))['isReadonly']
const isRef: (typeof import('vue'))['isRef']
const mapActions: (typeof import('pinia'))['mapActions']
const mapGetters: (typeof import('pinia'))['mapGetters']
const mapState: (typeof import('pinia'))['mapState']
const mapStores: (typeof import('pinia'))['mapStores']
const mapWritableState: (typeof import('pinia'))['mapWritableState']
const markRaw: (typeof import('vue'))['markRaw']
const nextTick: (typeof import('vue'))['nextTick']
const onActivated: (typeof import('vue'))['onActivated']
const onBeforeMount: (typeof import('vue'))['onBeforeMount']
const onBeforeRouteLeave: (typeof import('vue-router'))['onBeforeRouteLeave']
const onBeforeRouteUpdate: (typeof import('vue-router'))['onBeforeRouteUpdate']
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
const onDeactivated: (typeof import('vue'))['onDeactivated']
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
const onMounted: (typeof import('vue'))['onMounted']
const onRenderTracked: (typeof import('vue'))['onRenderTracked']
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
const onScopeDispose: (typeof import('vue'))['onScopeDispose']
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
const onUnmounted: (typeof import('vue'))['onUnmounted']
const onUpdated: (typeof import('vue'))['onUpdated']
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup']
const provide: (typeof import('vue'))['provide']
const reactive: (typeof import('vue'))['reactive']
const readonly: (typeof import('vue'))['readonly']
const ref: (typeof import('vue'))['ref']
const resolveComponent: (typeof import('vue'))['resolveComponent']
const setActivePinia: (typeof import('pinia'))['setActivePinia']
const setMapStoreSuffix: (typeof import('pinia'))['setMapStoreSuffix']
const shallowReactive: (typeof import('vue'))['shallowReactive']
const shallowReadonly: (typeof import('vue'))['shallowReadonly']
const shallowRef: (typeof import('vue'))['shallowRef']
const storeToRefs: (typeof import('pinia'))['storeToRefs']
const toRaw: (typeof import('vue'))['toRaw']
const toRef: (typeof import('vue'))['toRef']
const toRefs: (typeof import('vue'))['toRefs']
const toValue: (typeof import('vue'))['toValue']
const triggerRef: (typeof import('vue'))['triggerRef']
const unref: (typeof import('vue'))['unref']
const useAttrs: (typeof import('vue'))['useAttrs']
const useCssModule: (typeof import('vue'))['useCssModule']
const useCssVars: (typeof import('vue'))['useCssVars']
const useId: (typeof import('vue'))['useId']
const useLink: (typeof import('vue-router'))['useLink']
const useModel: (typeof import('vue'))['useModel']
const useRoute: (typeof import('vue-router'))['useRoute']
const useRouter: (typeof import('vue-router'))['useRouter']
const useSlots: (typeof import('vue'))['useSlots']
const useTemplateRef: (typeof import('vue'))['useTemplateRef']
const watch: (typeof import('vue'))['watch']
const watchEffect: (typeof import('vue'))['watchEffect']
const watchPostEffect: (typeof import('vue'))['watchPostEffect']
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type {
Component,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue'
import('vue')
}

View File

@@ -1,16 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ElConfigProvider: (typeof import('element-plus/es'))['ElConfigProvider']
ElDatePicker: (typeof import('element-plus/es'))['ElDatePicker']
ElTooltip: (typeof import('element-plus/es'))['ElTooltip']
RouterLink: (typeof import('vue-router'))['RouterLink']
RouterView: (typeof import('vue-router'))['RouterView']
}
}

View File

@@ -15,7 +15,6 @@
"element-plus": "^2.4.4", "element-plus": "^2.4.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0" "xlsx-js-style": "^1.2.0"
@@ -658,50 +657,6 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@intlify/core-base": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "9.14.5",
"@intlify/shared": "9.14.5"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "9.14.5",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -3834,10 +3789,9 @@
}, },
"node_modules/prettier-plugin-tailwindcss": { "node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14", "version": "0.6.14",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=14.21.3" "node": ">=14.21.3"
}, },
@@ -5227,26 +5181,6 @@
"eslint": ">=6.0.0" "eslint": ">=6.0.0"
} }
}, },
"node_modules/vue-i18n": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "9.14.5",
"@intlify/shared": "9.14.5",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz", "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz",
@@ -5444,7 +5378,7 @@
}, },
"node_modules/xlsx-js-style": { "node_modules/xlsx-js-style": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {

View File

@@ -18,7 +18,6 @@
"element-plus": "^2.4.4", "element-plus": "^2.4.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0" "xlsx-js-style": "^1.2.0"

View File

@@ -1,39 +1,25 @@
<template> <template>
<div id="app"> <div id="app">
<el-config-provider :locale="elLocale">
<router-view /> <router-view />
<!-- 全局组件 --> <!-- 全局组件 -->
<ToastNotification ref="toastRef" /> <ToastNotification ref="toastRef" />
<ConfirmDialog ref="confirmRef" /> <ConfirmDialog ref="confirmRef" />
</el-config-provider>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref, computed } from 'vue' import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import ToastNotification from '@/components/common/ToastNotification.vue' import ToastNotification from '@/components/common/ToastNotification.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import i18n from '@/i18n'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import zhTw from 'element-plus/dist/locale/zh-tw.mjs'
import en from 'element-plus/dist/locale/en.mjs'
const authStore = useAuthStore() const authStore = useAuthStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const toastRef = ref() const toastRef = ref()
const confirmRef = ref() const confirmRef = ref()
// Element Plus 语言随 i18n 切换
const elLocale = computed(() => {
const l = i18n.global.locale.value
if (l === 'zh-tw') return zhTw
if (l === 'en') return en
return zhCn
})
onMounted(() => { onMounted(() => {
// 初始化主题 // 初始化主题
themeStore.initTheme() themeStore.initTheme()

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" /> <i class="fas fa-layer-group text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl"> <h3 class="text-lg font-bold text-gray-900 sm:text-xl">账户分组管理</h3>
{{ t('groupManagement.title') }}
</h3>
</div> </div>
<button <button
class="p-1 text-gray-400 transition-colors hover:text-gray-600" class="p-1 text-gray-400 transition-colors hover:text-gray-600"
@@ -27,32 +25,26 @@
<div class="mb-6"> <div class="mb-6">
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true"> <button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
<i class="fas fa-plus mr-2" /> <i class="fas fa-plus mr-2" />
{{ t('groupManagement.createNewGroup') }} 创建新分组
</button> </button>
</div> </div>
<!-- 创建分组表单 --> <!-- 创建分组表单 -->
<div v-if="showCreateForm" class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4"> <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"> <h4 class="mb-4 text-lg font-semibold text-gray-900">创建新分组</h4>
{{ t('groupManagement.createGroup') }}
</h4>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">{{ <label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
t('groupManagement.groupNameRequired')
}}</label>
<input <input
v-model="createForm.name" v-model="createForm.name"
class="form-input w-full" class="form-input w-full"
:placeholder="t('groupManagement.groupNamePlaceholder')" placeholder="输入分组名称"
type="text" type="text"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">{{ <label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
t('groupManagement.platformTypeRequired')
}}</label>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" /> <input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
@@ -70,13 +62,11 @@
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">{{ <label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
t('groupManagement.descriptionOptional')
}}</label>
<textarea <textarea
v-model="createForm.description" v-model="createForm.description"
class="form-input w-full resize-none" class="form-input w-full resize-none"
:placeholder="t('groupManagement.descriptionPlaceholder')" placeholder="分组描述..."
rows="2" rows="2"
/> />
</div> </div>
@@ -88,11 +78,9 @@
@click="createGroup" @click="createGroup"
> >
<div v-if="creating" class="loading-spinner mr-2" /> <div v-if="creating" class="loading-spinner mr-2" />
{{ creating ? t('groupManagement.creating') : t('groupManagement.create') }} {{ creating ? '创建中...' : '创建' }}
</button>
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">
{{ t('groupManagement.cancel') }}
</button> </button>
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
</div> </div>
</div> </div>
</div> </div>
@@ -101,12 +89,12 @@
<div class="space-y-4"> <div class="space-y-4">
<div v-if="loading" class="py-8 text-center"> <div v-if="loading" class="py-8 text-center">
<div class="loading-spinner-lg mx-auto mb-4" /> <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>
<div v-else-if="groups.length === 0" class="rounded-lg bg-gray-50 py-8 text-center"> <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" /> <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>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -121,7 +109,7 @@
{{ group.name }} {{ group.name }}
</h4> </h4>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{{ group.description || t('groupManagement.noDescription') }} {{ group.description || '暂无描述' }}
</p> </p>
</div> </div>
<div class="ml-4 flex items-center gap-2"> <div class="ml-4 flex items-center gap-2">
@@ -150,7 +138,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span> <span>
<i class="fas fa-users mr-1" /> <i class="fas fa-users mr-1" />
{{ group.memberCount || 0 }}{{ t('groupManagement.membersCount') }} {{ group.memberCount || 0 }} 个成员
</span> </span>
<span> <span>
<i class="fas fa-clock mr-1" /> <i class="fas fa-clock mr-1" />
@@ -160,7 +148,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
class="text-blue-600 transition-colors hover:text-blue-800" class="text-blue-600 transition-colors hover:text-blue-800"
:title="t('groupManagement.edit')" title="编辑"
@click="editGroup(group)" @click="editGroup(group)"
> >
<i class="fas fa-edit" /> <i class="fas fa-edit" />
@@ -168,7 +156,7 @@
<button <button
class="text-red-600 transition-colors hover:text-red-800" class="text-red-600 transition-colors hover:text-red-800"
:disabled="group.memberCount > 0" :disabled="group.memberCount > 0"
:title="t('groupManagement.delete')" title="删除"
@click="deleteGroup(group)" @click="deleteGroup(group)"
> >
<i class="fas fa-trash" /> <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="modal-content w-full max-w-lg p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between"> <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"> <button class="text-gray-400 transition-colors hover:text-gray-600" @click="cancelEdit">
<i class="fas fa-times" /> <i class="fas fa-times" />
</button> </button>
@@ -196,21 +184,17 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">{{ <label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
t('groupManagement.groupNameRequired')
}}</label>
<input <input
v-model="editForm.name" v-model="editForm.name"
class="form-input w-full" class="form-input w-full"
:placeholder="t('groupManagement.groupNamePlaceholder')" placeholder="输入分组名称"
type="text" type="text"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">{{ <label class="mb-2 block text-sm font-semibold text-gray-700">平台类型</label>
t('groupManagement.platformTypeLabel')
}}</label>
<div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600"> <div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
{{ {{
editForm.platform === 'claude' editForm.platform === 'claude'
@@ -219,20 +203,16 @@
? 'Gemini' ? 'Gemini'
: 'OpenAI' : 'OpenAI'
}} }}
<span class="ml-2 text-xs text-gray-500">{{ <span class="ml-2 text-xs text-gray-500">(不可修改)</span>
t('groupManagement.cannotModify')
}}</span>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">{{ <label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
t('groupManagement.descriptionOptional')
}}</label>
<textarea <textarea
v-model="editForm.description" v-model="editForm.description"
class="form-input w-full resize-none" class="form-input w-full resize-none"
:placeholder="t('groupManagement.descriptionPlaceholder')" placeholder="分组描述..."
rows="2" rows="2"
/> />
</div> </div>
@@ -244,11 +224,9 @@
@click="updateGroup" @click="updateGroup"
> >
<div v-if="updating" class="loading-spinner mr-2" /> <div v-if="updating" class="loading-spinner mr-2" />
{{ updating ? t('groupManagement.updating') : t('groupManagement.update') }} {{ updating ? '更新中...' : '更新' }}
</button>
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">
{{ t('groupManagement.cancel') }}
</button> </button>
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">取消</button>
</div> </div>
</div> </div>
</div> </div>
@@ -258,12 +236,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
const { t } = useI18n()
const emit = defineEmits(['close', 'refresh']) const emit = defineEmits(['close', 'refresh'])
const show = ref(true) const show = ref(true)
@@ -303,7 +278,7 @@ const loadGroups = async () => {
const response = await apiClient.get('/admin/account-groups') const response = await apiClient.get('/admin/account-groups')
groups.value = response.data || [] groups.value = response.data || []
} catch (error) { } catch (error) {
showToast(t('groupManagement.loadGroupsFailed'), 'error') showToast('加载分组列表失败', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -312,7 +287,7 @@ const loadGroups = async () => {
// 创建分组 // 创建分组
const createGroup = async () => { const createGroup = async () => {
if (!createForm.value.name || !createForm.value.platform) { if (!createForm.value.name || !createForm.value.platform) {
showToast(t('groupManagement.fillRequiredFields'), 'error') showToast('请填写必填项', 'error')
return return
} }
@@ -324,12 +299,12 @@ const createGroup = async () => {
description: createForm.value.description description: createForm.value.description
}) })
showToast(t('groupManagement.groupCreated'), 'success') showToast('分组创建成功', 'success')
cancelCreate() cancelCreate()
await loadGroups() await loadGroups()
emit('refresh') emit('refresh')
} catch (error) { } catch (error) {
showToast(error.response?.data?.error || t('groupManagement.createGroupFailed'), 'error') showToast(error.response?.data?.error || '创建分组失败', 'error')
} finally { } finally {
creating.value = false creating.value = false
} }
@@ -359,7 +334,7 @@ const editGroup = (group) => {
// 更新分组 // 更新分组
const updateGroup = async () => { const updateGroup = async () => {
if (!editForm.value.name) { if (!editForm.value.name) {
showToast(t('groupManagement.fillGroupName'), 'error') showToast('请填写分组名称', 'error')
return return
} }
@@ -370,12 +345,12 @@ const updateGroup = async () => {
description: editForm.value.description description: editForm.value.description
}) })
showToast(t('groupManagement.groupUpdated'), 'success') showToast('分组更新成功', 'success')
cancelEdit() cancelEdit()
await loadGroups() await loadGroups()
emit('refresh') emit('refresh')
} catch (error) { } catch (error) {
showToast(error.response?.data?.error || t('groupManagement.updateGroupFailed'), 'error') showToast(error.response?.data?.error || '更新分组失败', 'error')
} finally { } finally {
updating.value = false updating.value = false
} }
@@ -395,21 +370,21 @@ const cancelEdit = () => {
// 删除分组 // 删除分组
const deleteGroup = async (group) => { const deleteGroup = async (group) => {
if (group.memberCount > 0) { if (group.memberCount > 0) {
showToast(t('groupManagement.groupHasMembers'), 'error') showToast('分组内还有成员,无法删除', 'error')
return return
} }
if (!confirm(t('groupManagement.confirmDelete', { name: group.name }))) { if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
return return
} }
try { try {
await apiClient.delete(`/admin/account-groups/${group.id}`) await apiClient.delete(`/admin/account-groups/${group.id}`)
showToast(t('groupManagement.groupDeleted'), 'success') showToast('分组删除成功', 'success')
await loadGroups() await loadGroups()
emit('refresh') emit('refresh')
} catch (error) { } 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" /> <i class="fas fa-link text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200"> <h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
{{ t('oauthFlow.claudeAccountAuth') }}
</h4>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300"> <p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
{{ t('oauthFlow.claudeAuthDescription') }} 请按照以下步骤完成 Claude 账户的授权
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
@@ -32,7 +30,7 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('oauthFlow.step1Title') }} 点击下方按钮生成授权链接
</p> </p>
<button <button
v-if="!authUrl" v-if="!authUrl"
@@ -42,7 +40,7 @@
> >
<i v-if="!loading" class="fas fa-link mr-2" /> <i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" /> <div v-else class="loading-spinner mr-2" />
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }} {{ loading ? '生成中...' : '生成授权链接' }}
</button> </button>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -54,7 +52,7 @@
/> />
<button <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" 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" @click="copyAuthUrl"
> >
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" /> <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" class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl" @click="regenerateAuthUrl"
> >
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }} <i class="fas fa-sync-alt mr-1" />重新生成
</button> </button>
</div> </div>
</div> </div>
@@ -83,18 +81,18 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('oauthFlow.step2Title') }} 在浏览器中打开链接并完成授权
</p> </p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300"> <p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
{{ t('oauthFlow.step2Description') }} 请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p> </p>
<div <div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30" 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"> <p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
<strong>{{ t('oauthFlow.proxyNotice') }}</strong <strong>注意</strong
>{{ t('oauthFlow.proxyNoticeText') }} >如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p> </p>
</div> </div>
</div> </div>
@@ -113,32 +111,29 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('oauthFlow.step3Title') }} 输入 Authorization Code
</p> </p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> <p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('oauthFlow.step3Description') }} 授权完成后页面会显示一个
<strong>{{ t('oauthFlow.authorizationCode') }}</strong <strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
>{{ t('oauthFlow.step3DescriptionMiddle') }}
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label <label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" 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" />{{ <i class="fas fa-key mr-2 text-blue-500" />Authorization Code
t('oauthFlow.authorizationCode')
}}
</label> </label>
<textarea <textarea
v-model="authCode" v-model="authCode"
class="form-input w-full resize-none font-mono text-sm" class="form-input w-full resize-none font-mono text-sm"
:placeholder="t('oauthFlow.authCodePlaceholder')" placeholder="粘贴从Claude页面获取的Authorization Code..."
rows="3" rows="3"
/> />
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
{{ t('oauthFlow.authCodeHint') }} 请粘贴从Claude页面复制的Authorization Code
</p> </p>
</div> </div>
</div> </div>
@@ -162,11 +157,9 @@
<i class="fas fa-robot text-white" /> <i class="fas fa-robot text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-green-900 dark:text-green-200"> <h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">Gemini 账户授权</h4>
{{ t('oauthFlow.geminiAccountAuth') }}
</h4>
<p class="mb-4 text-sm text-green-800 dark:text-green-300"> <p class="mb-4 text-sm text-green-800 dark:text-green-300">
{{ t('oauthFlow.geminiAuthDescription') }} 请按照以下步骤完成 Gemini 账户的授权
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
@@ -182,7 +175,7 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-green-900 dark:text-green-200"> <p class="mb-2 font-medium text-green-900 dark:text-green-200">
{{ t('oauthFlow.step1Title') }} 点击下方按钮生成授权链接
</p> </p>
<button <button
v-if="!authUrl" v-if="!authUrl"
@@ -192,7 +185,7 @@
> >
<i v-if="!loading" class="fas fa-link mr-2" /> <i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" /> <div v-else class="loading-spinner mr-2" />
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }} {{ loading ? '生成中...' : '生成授权链接' }}
</button> </button>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -204,7 +197,7 @@
/> />
<button <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" 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" @click="copyAuthUrl"
> >
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" /> <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" class="text-xs text-green-600 hover:text-green-700"
@click="regenerateAuthUrl" @click="regenerateAuthUrl"
> >
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }} <i class="fas fa-sync-alt mr-1" />重新生成
</button> </button>
</div> </div>
</div> </div>
@@ -233,18 +226,18 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-green-900 dark:text-green-200"> <p class="mb-2 font-medium text-green-900 dark:text-green-200">
{{ t('oauthFlow.step2Title') }} 在浏览器中打开链接并完成授权
</p> </p>
<p class="mb-2 text-sm text-green-700 dark:text-green-300"> <p class="mb-2 text-sm text-green-700 dark:text-green-300">
{{ t('oauthFlow.step2DescriptionGemini') }} 请在新标签页中打开授权链接登录您的 Gemini 账户并授权
</p> </p>
<div <div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30" 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"> <p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
<strong>{{ t('oauthFlow.proxyNotice') }}</strong <strong>注意</strong
>{{ t('oauthFlow.proxyNoticeText') }} >如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p> </p>
</div> </div>
</div> </div>
@@ -263,31 +256,29 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-green-900 dark:text-green-200"> <p class="mb-2 font-medium text-green-900 dark:text-green-200">
{{ t('oauthFlow.step3Title') }} 输入 Authorization Code
</p> </p>
<p class="mb-3 text-sm text-green-700 dark:text-green-300"> <p class="mb-3 text-sm text-green-700 dark:text-green-300">
{{ t('oauthFlow.step3DescriptionGemini') }} 授权完成后页面会显示一个 Authorization Code请将其复制并粘贴到下方输入框
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label <label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" 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" />{{ <i class="fas fa-key mr-2 text-green-500" />Authorization Code
t('oauthFlow.authorizationCode')
}}
</label> </label>
<textarea <textarea
v-model="authCode" v-model="authCode"
class="form-input w-full resize-none font-mono text-sm" class="form-input w-full resize-none font-mono text-sm"
:placeholder="t('oauthFlow.authCodePlaceholderGemini')" placeholder="粘贴从Gemini页面获取的Authorization Code..."
rows="3" rows="3"
/> />
</div> </div>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
<p class="text-xs text-gray-600 dark:text-gray-400"> <p class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-check-circle mr-1 text-green-500" /> <i class="fas fa-check-circle mr-1 text-green-500" />
{{ t('oauthFlow.authCodeHintGemini') }} 请粘贴从Gemini页面复制的Authorization Code
</p> </p>
</div> </div>
</div> </div>
@@ -312,11 +303,9 @@
<i class="fas fa-brain text-white" /> <i class="fas fa-brain text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200"> <h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">OpenAI 账户授权</h4>
{{ t('oauthFlow.openaiAccountAuth') }}
</h4>
<p class="mb-4 text-sm text-orange-800 dark:text-orange-300"> <p class="mb-4 text-sm text-orange-800 dark:text-orange-300">
{{ t('oauthFlow.openaiAuthDescription') }} 请按照以下步骤完成 OpenAI 账户的授权
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
@@ -332,7 +321,7 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200"> <p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
{{ t('oauthFlow.step1Title') }} 点击下方按钮生成授权链接
</p> </p>
<button <button
v-if="!authUrl" v-if="!authUrl"
@@ -342,7 +331,7 @@
> >
<i v-if="!loading" class="fas fa-link mr-2" /> <i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" /> <div v-else class="loading-spinner mr-2" />
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }} {{ loading ? '生成中...' : '生成授权链接' }}
</button> </button>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -354,7 +343,7 @@
/> />
<button <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" 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" @click="copyAuthUrl"
> >
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" /> <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" class="text-xs text-orange-600 hover:text-orange-700"
@click="regenerateAuthUrl" @click="regenerateAuthUrl"
> >
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }} <i class="fas fa-sync-alt mr-1" />重新生成
</button> </button>
</div> </div>
</div> </div>
@@ -383,23 +372,22 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200"> <p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
{{ t('oauthFlow.step2Title') }} 在浏览器中打开链接并完成授权
</p> </p>
<p class="mb-2 text-sm text-orange-700 dark:text-orange-300"> <p class="mb-2 text-sm text-orange-700 dark:text-orange-300">
{{ t('oauthFlow.step2DescriptionOpenAI') }} 请在新标签页中打开授权链接登录您的 OpenAI 账户并授权
</p> </p>
<div <div
class="mb-3 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30" 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"> <p class="text-xs text-amber-800 dark:text-amber-300">
<i class="fas fa-clock mr-1" /> <i class="fas fa-clock mr-1" />
<strong>{{ t('oauthFlow.openaiImportantNote') }}</strong <strong>重要提示</strong>授权后页面可能会加载较长时间请耐心等待
>{{ t('oauthFlow.openaiLoadingNote') }}
</p> </p>
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400"> <p class="mt-2 text-xs text-amber-700 dark:text-amber-400">
{{ t('oauthFlow.openaiAddressNote') }} 当浏览器地址栏变为
<strong class="font-mono">http://localhost:1455/...</strong> <strong class="font-mono">http://localhost:1455/...</strong>
{{ t('oauthFlow.openaiAddressNoteMiddle') }} 开头时表示授权已完成
</p> </p>
</div> </div>
<div <div
@@ -407,8 +395,8 @@
> >
<p class="text-xs text-yellow-800 dark:text-yellow-300"> <p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
<strong>{{ t('oauthFlow.proxyNotice') }}</strong <strong>注意</strong
>{{ t('oauthFlow.proxyNoticeText') }} >如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p> </p>
</div> </div>
</div> </div>
@@ -427,26 +415,23 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200"> <p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
{{ t('oauthFlow.step3TitleOpenAI') }} 输入授权链接或 Code
</p> </p>
<p class="mb-3 text-sm text-orange-700 dark:text-orange-300"> <p class="mb-3 text-sm text-orange-700 dark:text-orange-300">
{{ t('oauthFlow.step3DescriptionOpenAI') }} 授权完成后当页面地址变为
<strong class="font-mono">http://localhost:1455/...</strong> <strong class="font-mono">http://localhost:1455/...</strong> 时:
{{ t('oauthFlow.step3DescriptionOpenAIMiddle') }}
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label <label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" 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" />{{ <i class="fas fa-link mr-2 text-orange-500" />授权链接或 Code
t('oauthFlow.authLinkOrCode')
}}
</label> </label>
<textarea <textarea
v-model="authCode" v-model="authCode"
class="form-input w-full resize-none font-mono text-sm" 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" rows="3"
/> />
</div> </div>
@@ -455,18 +440,18 @@
> >
<p class="text-xs text-blue-700 dark:text-blue-300"> <p class="text-xs text-blue-700 dark:text-blue-300">
<i class="fas fa-lightbulb mr-1" /> <i class="fas fa-lightbulb mr-1" />
<strong>{{ t('oauthFlow.openaiTip') }}</strong <strong>提示</strong>您可以直接复制整个链接或仅复制 code
>{{ t('oauthFlow.openaiTipText') }} 参数值系统会自动识别
</p> </p>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400"> <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 >http://localhost:1455/auth/callback?code=ac_4hm8...</span
> >
</p> </p>
<p class="text-xs text-blue-600"> <p class="text-xs text-blue-600">
{{ t('oauthFlow.openaiCodeExample') Code 示例<span class="font-mono"
}}<span class="font-mono">ac_4hm8iqmx9A2fzMy_cwye7U3W7...</span> >ac_4hm8iqmx9A2fzMy_cwye7U3W7...</span
>
</p> </p>
</div> </div>
</div> </div>
@@ -485,7 +470,7 @@
type="button" type="button"
@click="$emit('back')" @click="$emit('back')"
> >
{{ t('oauthFlow.previousStep') }} 上一步
</button> </button>
<button <button
class="btn btn-primary flex-1 px-6 py-3 font-semibold" class="btn btn-primary flex-1 px-6 py-3 font-semibold"
@@ -494,7 +479,7 @@
@click="exchangeCode" @click="exchangeCode"
> >
<div v-if="exchanging" class="loading-spinner mr-2" /> <div v-if="exchanging" class="loading-spinner mr-2" />
{{ exchanging ? t('oauthFlow.verifying') : t('oauthFlow.completeAuth') }} {{ exchanging ? '验证中...' : '完成授权' }}
</button> </button>
</div> </div>
</div> </div>
@@ -502,12 +487,9 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useAccountsStore } from '@/stores/accounts' import { useAccountsStore } from '@/stores/accounts'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
platform: { platform: {
type: String, type: String,
@@ -562,16 +544,16 @@ watch(authCode, (newValue) => {
if (code) { if (code) {
// 成功提取授权码 // 成功提取授权码
authCode.value = code authCode.value = code
showToast(t('oauthFlow.successExtractCode'), 'success') showToast('成功提取授权码!', 'success')
console.log('Successfully extracted authorization code from URL') console.log('Successfully extracted authorization code from URL')
} else { } else {
// URL 中没有 code 参数 // URL 中没有 code 参数
showToast(t('oauthFlow.errorCodeNotFound'), 'error') showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error')
} }
} catch (error) { } catch (error) {
// URL 解析失败 // URL 解析失败
console.error('Failed to parse URL:', error) console.error('Failed to parse URL:', error)
showToast(t('oauthFlow.errorLinkFormat'), 'error') showToast('链接格式错误,请检查是否为完整的 URL', 'error')
} }
} else if (props.platform === 'gemini' || props.platform === 'openai') { } else if (props.platform === 'gemini' || props.platform === 'openai') {
// Gemini 和 OpenAI 平台可能使用不同的回调URL // Gemini 和 OpenAI 平台可能使用不同的回调URL
@@ -582,14 +564,14 @@ watch(authCode, (newValue) => {
if (code) { if (code) {
authCode.value = code authCode.value = code
showToast(t('oauthFlow.successExtractCode'), 'success') showToast('成功提取授权码!', 'success')
} }
} catch (error) { } catch (error) {
// 不是有效的URL保持原值 // 不是有效的URL保持原值
} }
} else { } else {
// 错误的 URL不是正确的 localhost 回调地址) // 错误的 URL不是正确的 localhost 回调地址)
showToast(t('oauthFlow.errorWrongUrlFormat'), 'error') showToast('请粘贴以 http://localhost:1455 或 http://localhost:45462 开头的链接', 'error')
} }
} }
// 如果不是 URL保持原值兼容直接输入授权码 // 如果不是 URL保持原值兼容直接输入授权码
@@ -625,7 +607,7 @@ const generateAuthUrl = async () => {
sessionId.value = result.sessionId sessionId.value = result.sessionId
} }
} catch (error) { } catch (error) {
showToast(error.message || t('oauthFlow.generateAuthFailed'), 'error') showToast(error.message || '生成授权链接失败', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -643,7 +625,7 @@ const copyAuthUrl = async () => {
try { try {
await navigator.clipboard.writeText(authUrl.value) await navigator.clipboard.writeText(authUrl.value)
copied.value = true copied.value = true
showToast(t('oauthFlow.linkCopied'), 'success') showToast('链接已复制', 'success')
setTimeout(() => { setTimeout(() => {
copied.value = false copied.value = false
}, 2000) }, 2000)
@@ -656,7 +638,7 @@ const copyAuthUrl = async () => {
document.execCommand('copy') document.execCommand('copy')
document.body.removeChild(input) document.body.removeChild(input)
copied.value = true copied.value = true
showToast(t('oauthFlow.linkCopied'), 'success') showToast('链接已复制', 'success')
setTimeout(() => { setTimeout(() => {
copied.value = false copied.value = false
}, 2000) }, 2000)
@@ -713,7 +695,7 @@ const exchangeCode = async () => {
emit('success', tokenInfo) emit('success', tokenInfo)
} catch (error) { } catch (error) {
showToast(error.message || t('oauthFlow.authFailed'), 'error') showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
} finally { } finally {
exchanging.value = false exchanging.value = false
} }

View File

@@ -1,18 +1,14 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300"> <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">代理设置 (可选)</h4>
{{ t('proxyConfig.title') }}
</h4>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="proxy.enabled" v-model="proxy.enabled"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox" type="checkbox"
/> />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">启用代理</span>
t('proxyConfig.enableProxy')
}}</span>
</label> </label>
</div> </div>
@@ -26,10 +22,10 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('proxyConfig.configDescription') }} 配置代理以访问受限的网络资源支持 SOCKS5 HTTP 代理
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('proxyConfig.stabilityNotice') }} 请确保代理服务器稳定可用否则会影响账户的正常使用
</p> </p>
</div> </div>
</div> </div>
@@ -74,9 +70,9 @@
<div class="my-3 border-t border-gray-200 dark:border-gray-600"></div> <div class="my-3 border-t border-gray-200 dark:border-gray-600"></div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
t('proxyConfig.proxyType') >代理类型</label
}}</label> >
<select <select
v-model="proxy.type" v-model="proxy.type"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" 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 class="grid grid-cols-2 gap-4">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
t('proxyConfig.hostAddress') >主机地址</label
}}</label> >
<input <input
v-model="proxy.host" 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" 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" type="text"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
t('proxyConfig.port') >端口</label
}}</label> >
<input <input
v-model="proxy.port" 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" 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" type="number"
/> />
</div> </div>
@@ -124,31 +120,31 @@
class="ml-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" class="ml-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
for="proxyAuth" for="proxyAuth"
> >
{{ t('proxyConfig.needsAuth') }} 需要身份验证
</label> </label>
</div> </div>
<div v-if="showAuth" class="grid grid-cols-2 gap-4"> <div v-if="showAuth" class="grid grid-cols-2 gap-4">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
t('proxyConfig.username') >用户名</label
}}</label> >
<input <input
v-model="proxy.username" 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" 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" type="text"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
t('proxyConfig.password') >密码</label
}}</label> >
<div class="relative"> <div class="relative">
<input <input
v-model="proxy.password" 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" 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'" :type="showPassword ? 'text' : 'password'"
/> />
<button <button
@@ -168,8 +164,8 @@
> >
<p class="text-xs text-blue-700 dark:text-blue-300"> <p class="text-xs text-blue-700 dark:text-blue-300">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
<strong>{{ t('proxyConfig.tip') }}</strong <strong>提示</strong
>{{ t('proxyConfig.apiRequestNotice') }} >代理设置将用于所有与此账户相关的API请求请确保代理服务器支持HTTPS流量转发
</p> </p>
</div> </div>
</div> </div>
@@ -178,9 +174,6 @@
<script setup> <script setup>
import { ref, watch, onUnmounted } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
modelValue: { 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="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3"> <div class="mt-3">
<div class="mb-4 flex items-center justify-between"> <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')"> <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"> <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -54,7 +54,7 @@
: 'bg-blue-100 text-blue-800' : 'bg-blue-100 text-blue-800'
]" ]"
> >
{{ $t('user.changeRoleModal.currentRole', { role: user.role }) }} Current: {{ user.role }}
</span> </span>
</div> </div>
</div> </div>
@@ -64,9 +64,7 @@
<!-- Role Selection --> <!-- Role Selection -->
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700"> <label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
{{ $t('user.changeRoleModal.newRole') }}
</label>
<div class="space-y-2"> <div class="space-y-2">
<label class="flex items-center"> <label class="flex items-center">
<input <input
@@ -77,12 +75,8 @@
value="user" value="user"
/> />
<div class="ml-3"> <div class="ml-3">
<div class="text-sm font-medium text-gray-900"> <div class="text-sm font-medium text-gray-900">User</div>
{{ $t('user.changeRoleModal.roles.user') }} <div class="text-xs text-gray-500">Regular user with basic permissions</div>
</div>
<div class="text-xs text-gray-500">
{{ $t('user.changeRoleModal.roles.userDesc') }}
</div>
</div> </div>
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
@@ -94,12 +88,8 @@
value="admin" value="admin"
/> />
<div class="ml-3"> <div class="ml-3">
<div class="text-sm font-medium text-gray-900"> <div class="text-sm font-medium text-gray-900">Administrator</div>
{{ $t('user.changeRoleModal.roles.admin') }} <div class="text-xs text-gray-500">Full access to manage users and system</div>
</div>
<div class="text-xs text-gray-500">
{{ $t('user.changeRoleModal.roles.adminDesc') }}
</div>
</div> </div>
</label> </label>
</div> </div>
@@ -121,15 +111,15 @@
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800"> <h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
{{ $t('user.changeRoleModal.roleChangeWarning.title') }}
</h3>
<div class="mt-2 text-sm text-yellow-700"> <div class="mt-2 text-sm text-yellow-700">
<p v-if="selectedRole === 'admin'"> <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>
<p v-else> <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> </p>
</div> </div>
</div> </div>
@@ -160,7 +150,7 @@
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >
{{ $t('user.changeRoleModal.cancel') }} Cancel
</button> </button>
<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" 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" fill="currentColor"
></path> ></path>
</svg> </svg>
{{ $t('user.changeRoleModal.updating') }} Updating...
</span> </span>
<span v-else>{{ $t('user.changeRoleModal.updateRole') }}</span> <span v-else>Update Role</span>
</button> </button>
</div> </div>
</form> </form>
@@ -204,9 +194,6 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -239,7 +226,7 @@ const handleSubmit = async () => {
}) })
if (response.success) { if (response.success) {
showToast(t('user.changeRoleModal.roleUpdated', { role: selectedRole.value }), 'success') showToast(`User role updated to ${selectedRole.value}`, 'success')
emit('updated') emit('updated')
} else { } else {
error.value = response.message || 'Failed to update user role' 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 class="mb-6 flex items-center justify-between">
<div> <div>
<h3 class="text-lg font-medium text-gray-900"> <h3 class="text-lg font-medium text-gray-900">
{{ Usage Statistics - {{ user?.displayName || user?.username }}
$t('user.usageStatsModal.titleWithUser', {
displayName: user?.displayName || user?.username
})
}}
</h3> </h3>
<p class="text-sm text-gray-500">@{{ user?.username }} {{ user?.role }}</p> <p class="text-sm text-gray-500">@{{ user?.username }} {{ user?.role }}</p>
</div> </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" class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats" @change="loadUsageStats"
> >
<option value="day">{{ $t('user.usageStatsModal.periodSelection.day') }}</option> <option value="day">Last 24 Hours</option>
<option value="week">{{ $t('user.usageStatsModal.periodSelection.week') }}</option> <option value="week">Last 7 Days</option>
<option value="month">{{ $t('user.usageStatsModal.periodSelection.month') }}</option> <option value="month">Last 30 Days</option>
<option value="quarter"> <option value="quarter">Last 90 Days</option>
{{ $t('user.usageStatsModal.periodSelection.quarter') }}
</option>
</select> </select>
</div> </div>
@@ -66,7 +60,7 @@
fill="currentColor" fill="currentColor"
></path> ></path>
</svg> </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> </div>
<!-- Stats Content --> <!-- Stats Content -->
@@ -93,9 +87,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-blue-600"> <dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
{{ $t('user.usageStatsModal.summaryCards.requests') }}
</dt>
<dd class="text-lg font-medium text-blue-900"> <dd class="text-lg font-medium text-blue-900">
{{ formatNumber(usageStats?.totalRequests || 0) }} {{ formatNumber(usageStats?.totalRequests || 0) }}
</dd> </dd>
@@ -125,9 +117,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-green-600"> <dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
{{ $t('user.usageStatsModal.summaryCards.inputTokens') }}
</dt>
<dd class="text-lg font-medium text-green-900"> <dd class="text-lg font-medium text-green-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }} {{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd> </dd>
@@ -157,9 +147,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-purple-600"> <dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
{{ $t('user.usageStatsModal.summaryCards.outputTokens') }}
</dt>
<dd class="text-lg font-medium text-purple-900"> <dd class="text-lg font-medium text-purple-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }} {{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd> </dd>
@@ -189,9 +177,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-yellow-600"> <dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
{{ $t('user.usageStatsModal.summaryCards.totalCost') }}
</dt>
<dd class="text-lg font-medium text-yellow-900"> <dd class="text-lg font-medium text-yellow-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }} ${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd> </dd>
@@ -208,9 +194,7 @@
class="rounded-lg border border-gray-200 bg-white" class="rounded-lg border border-gray-200 bg-white"
> >
<div class="border-b border-gray-200 px-4 py-5 sm:px-6"> <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"> <h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
{{ $t('user.usageStatsModal.apiKeysTable.title') }}
</h4>
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <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" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ $t('user.usageStatsModal.apiKeysTable.headers.apiKey') }} API Key
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ $t('user.usageStatsModal.apiKeysTable.headers.status') }} Status
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ $t('user.usageStatsModal.apiKeysTable.headers.requests') }} Requests
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ $t('user.usageStatsModal.apiKeysTable.headers.tokens') }} Tokens
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ $t('user.usageStatsModal.apiKeysTable.headers.cost') }} Cost
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ $t('user.usageStatsModal.apiKeysTable.headers.lastUsed') }} Last Used
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -269,35 +253,21 @@
: 'bg-red-100 text-red-800' : 'bg-red-100 text-red-800'
]" ]"
> >
{{ {{ apiKey.isActive ? 'Active' : 'Disabled' }}
apiKey.isActive
? $t('user.usageStatsModal.apiKeysTable.status.active')
: $t('user.usageStatsModal.apiKeysTable.status.disabled')
}}
</span> </span>
</td> </td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900"> <td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }} {{ formatNumber(apiKey.usage?.requests || 0) }}
</td> </td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900"> <td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
<div> <div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
{{ $t('user.usageStatsModal.apiKeysTable.tokensFormat.input') }}: <div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
</div>
<div>
{{ $t('user.usageStatsModal.apiKeysTable.tokensFormat.output') }}:
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
</div>
</td> </td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900"> <td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }} ${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td> </td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{{ {{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
apiKey.lastUsedAt
? formatDate(apiKey.lastUsedAt)
: $t('user.usageStatsModal.apiKeysTable.never')
}}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -308,9 +278,7 @@
<!-- Chart Placeholder --> <!-- Chart Placeholder -->
<div class="rounded-lg border border-gray-200 bg-white"> <div class="rounded-lg border border-gray-200 bg-white">
<div class="border-b border-gray-200 px-4 py-5 sm:px-6"> <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"> <h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
{{ $t('user.usageStatsModal.usageTrend.title') }}
</h4>
</div> </div>
<div class="p-6"> <div class="p-6">
<div <div
@@ -330,16 +298,12 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900"> <h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
{{ $t('user.usageStatsModal.usageTrend.chartTitle') }}
</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{{ Daily usage trends for {{ selectedPeriod }} period
$t('user.usageStatsModal.usageTrend.dailyTrends', { period: selectedPeriod })
}}
</p> </p>
<p class="mt-2 text-xs text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -361,11 +325,9 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900"> <h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
{{ $t('user.usageStatsModal.noData.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500"> <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> </p>
</div> </div>
</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" 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')" @click="$emit('close')"
> >
{{ $t('user.usageStatsModal.close') }} Close
</button> </button>
</div> </div>
</div> </div>
@@ -387,7 +349,6 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
// import { useI18n } from 'vue-i18n' - using $t in template instead
const props = defineProps({ const props = defineProps({
show: { show: {

View File

@@ -12,17 +12,13 @@
<i class="fas fa-layer-group text-lg text-white" /> <i class="fas fa-layer-group text-lg text-white" />
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-gray-900"> <h3 class="text-xl font-bold text-gray-900">批量创建成功</h3>
{{ $t('apiKeys.batchApiKeyModal.title') }} <p class="text-sm text-gray-600">成功创建 {{ apiKeys.length }} API Key</p>
</h3>
<p class="text-sm text-gray-600">
{{ $t('apiKeys.batchApiKeyModal.successMessage', { count: apiKeys.length }) }}
</p>
</div> </div>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600" class="text-gray-400 transition-colors hover:text-gray-600"
:title="$t('apiKeys.batchApiKeyModal.directCloseTooltip')" title="直接关闭(不推荐)"
@click="handleDirectClose" @click="handleDirectClose"
> >
<i class="fas fa-times text-xl" /> <i class="fas fa-times text-xl" />
@@ -38,11 +34,10 @@
<i class="fas fa-exclamation-triangle text-sm text-white" /> <i class="fas fa-exclamation-triangle text-sm text-white" />
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h5 class="mb-1 font-semibold text-amber-900"> <h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
{{ $t('apiKeys.batchApiKeyModal.importantReminder') }}
</h5>
<p class="text-sm text-amber-800"> <p class="text-sm text-amber-800">
{{ $t('apiKeys.batchApiKeyModal.warningMessage') }} 这是您唯一能看到所有 API Key 的机会关闭此窗口后系统将不再显示完整的 API
Key请立即下载并妥善保存
</p> </p>
</div> </div>
</div> </div>
@@ -55,9 +50,7 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-xs font-medium text-blue-600"> <p class="text-xs font-medium text-blue-600">创建数量</p>
{{ $t('apiKeys.batchApiKeyModal.createdCount') }}
</p>
<p class="mt-1 text-2xl font-bold text-blue-900"> <p class="mt-1 text-2xl font-bold text-blue-900">
{{ apiKeys.length }} {{ apiKeys.length }}
</p> </p>
@@ -75,9 +68,7 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-xs font-medium text-green-600"> <p class="text-xs font-medium text-green-600">基础名称</p>
{{ $t('apiKeys.batchApiKeyModal.baseName') }}
</p>
<p class="mt-1 truncate text-lg font-bold text-green-900"> <p class="mt-1 truncate text-lg font-bold text-green-900">
{{ baseName }} {{ baseName }}
</p> </p>
@@ -95,9 +86,7 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-xs font-medium text-purple-600"> <p class="text-xs font-medium text-purple-600">权限范围</p>
{{ $t('apiKeys.batchApiKeyModal.permissionScope') }}
</p>
<p class="mt-1 text-lg font-bold text-purple-900"> <p class="mt-1 text-lg font-bold text-purple-900">
{{ getPermissionText() }} {{ getPermissionText() }}
</p> </p>
@@ -115,9 +104,7 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-xs font-medium text-orange-600"> <p class="text-xs font-medium text-orange-600">过期时间</p>
{{ $t('apiKeys.batchApiKeyModal.expiryTime') }}
</p>
<p class="mt-1 text-lg font-bold text-orange-900"> <p class="mt-1 text-lg font-bold text-orange-900">
{{ getExpiryText() }} {{ getExpiryText() }}
</p> </p>
@@ -134,9 +121,7 @@
<!-- API Keys 预览 --> <!-- API Keys 预览 -->
<div class="mb-6"> <div class="mb-6">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700">{{ <label class="text-sm font-semibold text-gray-700">API Keys 预览</label>
$t('apiKeys.batchApiKeyModal.previewTitle')
}}</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
class="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800" class="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
@@ -144,15 +129,9 @@
@click="togglePreview" @click="togglePreview"
> >
<i :class="['fas', showPreview ? 'fa-eye-slash' : 'fa-eye']" /> <i :class="['fas', showPreview ? 'fa-eye-slash' : 'fa-eye']" />
{{ {{ showPreview ? '隐藏' : '显示' }}预览
showPreview
? $t('apiKeys.batchApiKeyModal.hide')
: $t('apiKeys.batchApiKeyModal.show')
}}{{ $t('apiKeys.batchApiKeyModal.preview') }}
</button> </button>
<span class="text-xs text-gray-500">{{ <span class="text-xs text-gray-500">最多显示前10个</span>
$t('apiKeys.batchApiKeyModal.maxDisplayNote')
}}</span>
</div> </div>
</div> </div>
@@ -171,13 +150,13 @@
@click="downloadApiKeys" @click="downloadApiKeys"
> >
<i class="fas fa-download" /> <i class="fas fa-download" />
{{ $t('apiKeys.batchApiKeyModal.downloadAll') }} 下载所有 API Keys
</button> </button>
<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" 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" @click="handleClose"
> >
{{ $t('apiKeys.batchApiKeyModal.alreadySaved') }} 我已保存
</button> </button>
</div> </div>
@@ -186,7 +165,8 @@
<p class="flex items-start text-xs text-blue-700"> <p class="flex items-start text-xs text-blue-700">
<i class="fas fa-info-circle mr-2 mt-0.5 flex-shrink-0" /> <i class="fas fa-info-circle mr-2 mt-0.5 flex-shrink-0" />
<span> <span>
{{ $t('apiKeys.batchApiKeyModal.fileFormatInfo') }} 下载的文件格式为文本文件.txt每行包含一个 API Key
请将文件保存在安全的位置避免泄露
</span> </span>
</p> </p>
</div> </div>
@@ -197,11 +177,8 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
apiKeys: { apiKeys: {
type: Array, type: Array,
@@ -226,28 +203,30 @@ const baseName = computed(() => {
// 获取权限文本 // 获取权限文本
const getPermissionText = () => { 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 permissions = props.apiKeys[0].permissions
const permissionKey = `apiKeys.batchApiKeyModal.permissions.${permissions}` const permissionMap = {
return t(permissionKey, t('apiKeys.batchApiKeyModal.permissions.unknown')) all: '全部服务',
claude: '仅 Claude',
gemini: '仅 Gemini'
}
return permissionMap[permissions] || permissions
} }
// 获取过期时间文本 // 获取过期时间文本
const getExpiryText = () => { 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 const expiresAt = props.apiKeys[0].expiresAt
if (!expiresAt) return t('apiKeys.batchApiKeyModal.neverExpire') if (!expiresAt) return '永不过期'
const expiryDate = new Date(expiresAt) const expiryDate = new Date(expiresAt)
const now = new Date() const now = new Date()
const diffDays = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24)) const diffDays = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24))
if (diffDays <= 7) return t('apiKeys.batchApiKeyModal.daysFormat', { days: diffDays }) if (diffDays <= 7) return `${diffDays}`
if (diffDays <= 30) if (diffDays <= 30) return `${Math.ceil(diffDays / 7)}`
return t('apiKeys.batchApiKeyModal.weeksFormat', { weeks: Math.ceil(diffDays / 7) }) if (diffDays <= 365) return `${Math.ceil(diffDays / 30)}个月`
if (diffDays <= 365) return `${Math.ceil(diffDays / 365)}`
return t('apiKeys.batchApiKeyModal.monthsFormat', { months: Math.ceil(diffDays / 30) })
return t('apiKeys.batchApiKeyModal.yearsFormat', { years: Math.ceil(diffDays / 365) })
} }
// 切换预览显示 // 切换预览显示
@@ -263,7 +242,7 @@ const getPreviewText = () => {
}) })
if (props.apiKeys.length > 10) { 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') return lines.join('\n')
@@ -298,24 +277,26 @@ const downloadApiKeys = () => {
// 释放 URL 对象 // 释放 URL 对象
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
showToast(t('apiKeys.batchApiKeyModal.downloadSuccess'), 'success') showToast('API Keys 文件已下载', 'success')
} }
// 关闭弹窗(带确认) // 关闭弹窗(带确认)
const handleClose = async () => { const handleClose = async () => {
if (window.showConfirm) { if (window.showConfirm) {
const confirmed = await window.showConfirm( const confirmed = await window.showConfirm(
t('apiKeys.batchApiKeyModal.closeReminderTitle'), '关闭提醒',
t('apiKeys.batchApiKeyModal.closeReminderMessage'), '关闭后将无法再次查看这些 API Key请确保已经下载并妥善保存。\n\n确定要关闭吗',
t('apiKeys.batchApiKeyModal.confirmCloseButton'), '确定关闭',
t('apiKeys.batchApiKeyModal.goBackDownloadButton') '返回下载'
) )
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }
} else { } else {
// 降级方案 // 降级方案
const confirmed = confirm(t('apiKeys.batchApiKeyModal.closeReminderMessage')) const confirmed = confirm(
'关闭后将无法再次查看这些 API Key请确保已经下载并妥善保存。\n\n确定要关闭吗'
)
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }
@@ -326,17 +307,17 @@ const handleClose = async () => {
const handleDirectClose = async () => { const handleDirectClose = async () => {
if (window.showConfirm) { if (window.showConfirm) {
const confirmed = await window.showConfirm( const confirmed = await window.showConfirm(
t('apiKeys.batchApiKeyModal.directCloseTitle'), '确定要关闭吗?',
t('apiKeys.batchApiKeyModal.directCloseMessage'), '您还没有下载 API Keys关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
t('apiKeys.batchApiKeyModal.stillCloseButton'), '仍然关闭',
t('apiKeys.batchApiKeyModal.goBackDownloadButton') '返回下载'
) )
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }
} else { } else {
// 降级方案 // 降级方案
const confirmed = confirm(t('apiKeys.batchApiKeyModal.directCloseFallbackMessage')) const confirmed = confirm('您还没有下载 API Keys关闭后将无法再次查看。\n\n确定要关闭吗')
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }

View File

@@ -12,7 +12,7 @@
<i class="fas fa-edit text-sm text-white sm:text-base" /> <i class="fas fa-edit text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> <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> </h3>
</div> </div>
<button <button
@@ -32,11 +32,10 @@
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<i class="fas fa-info-circle mt-1 text-blue-500" /> <i class="fas fa-info-circle mt-1 text-blue-500" />
<div> <div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-300"> <p class="text-sm font-medium text-blue-800 dark:text-blue-300">批量编辑说明</p>
{{ $t('apiKeys.batchEditApiKeyModal.infoTitle') }}
</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-400"> <p class="mt-1 text-sm text-blue-700 dark:text-blue-400">
{{ $t('apiKeys.batchEditApiKeyModal.infoContent', { count: selectedCount }) }} 以下设置将应用到所选的 {{ selectedCount }} API
Key只有填写或修改的字段才会被更新空白字段将保持原值不变
</p> </p>
</div> </div>
</div> </div>
@@ -47,34 +46,26 @@
<label <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" 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> </label>
<div class="space-y-4"> <div class="space-y-4">
<!-- 标签操作模式选择 --> <!-- 标签操作模式选择 -->
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="replace" /> <input v-model="tagOperation" class="mr-2" type="radio" value="replace" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">替换标签</span>
$t('apiKeys.batchEditApiKeyModal.tagOperations.replace')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="add" /> <input v-model="tagOperation" class="mr-2" type="radio" value="add" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">添加标签</span>
$t('apiKeys.batchEditApiKeyModal.tagOperations.add')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="remove" /> <input v-model="tagOperation" class="mr-2" type="radio" value="remove" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">移除标签</span>
$t('apiKeys.batchEditApiKeyModal.tagOperations.remove')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="none" /> <input v-model="tagOperation" class="mr-2" type="radio" value="none" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">不修改标签</span>
$t('apiKeys.batchEditApiKeyModal.tagOperations.none')
}}</span>
</label> </label>
</div> </div>
@@ -85,10 +76,10 @@
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ {{
tagOperation === 'replace' tagOperation === 'replace'
? $t('apiKeys.batchEditApiKeyModal.newTagsList') ? '新标签列表:'
: tagOperation === 'add' : tagOperation === 'add'
? $t('apiKeys.batchEditApiKeyModal.tagsToAdd') ? '要添加的标签:'
: $t('apiKeys.batchEditApiKeyModal.tagsToRemove') : '要移除的标签:'
}} }}
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -112,7 +103,7 @@
<!-- 可选择的已有标签 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0"> <div v-if="unselectedTags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ $t('apiKeys.batchEditApiKeyModal.clickToSelectTags') }} 点击选择已有标签:
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
@@ -131,13 +122,13 @@
<!-- 创建新标签 --> <!-- 创建新标签 -->
<div> <div>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ $t('apiKeys.batchEditApiKeyModal.createNewTag') }} 创建新标签:
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" 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" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
/> />
@@ -164,48 +155,46 @@
> >
<i class="fas fa-tachometer-alt text-xs text-white" /> <i class="fas fa-tachometer-alt text-xs text-white" />
</div> </div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200"> <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">速率限制设置</h4>
{{ $t('apiKeys.batchEditApiKeyModal.rateLimitTitle') }}
</h4>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"> <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
{{ $t('apiKeys.batchEditApiKeyModal.rateLimitWindow') }} 时间窗口 (分钟)
</label> </label>
<input <input
v-model="form.rateLimitWindow" 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" class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1" min="1"
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')" placeholder="不修改"
type="number" type="number"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
$t('apiKeys.batchEditApiKeyModal.rateLimitRequests') >请求次数限制</label
}}</label> >
<input <input
v-model="form.rateLimitRequests" 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" class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1" min="1"
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')" placeholder="不修改"
type="number" type="number"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
$t('apiKeys.batchEditApiKeyModal.rateLimitCost') >费用限制 (美元)</label
}}</label> >
<input <input
v-model="form.rateLimitCost" 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" class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0" min="0"
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')" placeholder="不修改"
step="0.01" step="0.01"
type="number" type="number"
/> />
@@ -217,13 +206,13 @@
<!-- 每日费用限制 --> <!-- 每日费用限制 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
{{ $t('apiKeys.batchEditApiKeyModal.dailyCostLimit') }} 每日费用限制 (美元)
</label> </label>
<input <input
v-model="form.dailyCostLimit" v-model="form.dailyCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0" min="0"
:placeholder="$t('apiKeys.batchEditApiKeyModal.dailyCostLimitPlaceholder')" placeholder="不修改 (0 表示无限制)"
step="0.01" step="0.01"
type="number" type="number"
/> />
@@ -232,31 +221,31 @@
<!-- Opus 模型周费用限制 --> <!-- Opus 模型周费用限制 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
{{ $t('apiKeys.batchEditApiKeyModal.weeklyOpusCostLimit') }} Opus 模型周费用限制 (美元)
</label> </label>
<input <input
v-model="form.weeklyOpusCostLimit" v-model="form.weeklyOpusCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0" min="0"
:placeholder="$t('apiKeys.batchEditApiKeyModal.weeklyOpusCostLimitPlaceholder')" placeholder="不修改 (0 表示无限制)"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.batchEditApiKeyModal.opusLimitDescription') }} 设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户
</p> </p>
</div> </div>
<!-- 并发限制 --> <!-- 并发限制 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.batchEditApiKeyModal.concurrencyLimit') >并发限制</label
}}</label> >
<input <input
v-model="form.concurrencyLimit" v-model="form.concurrencyLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0" min="0"
:placeholder="$t('apiKeys.batchEditApiKeyModal.concurrencyLimitPlaceholder')" placeholder="不修改 (0 表示无限制)"
type="number" type="number"
/> />
</div> </div>
@@ -264,27 +253,19 @@
<!-- 激活状态 --> <!-- 激活状态 -->
<div> <div>
<div class="mb-3 flex items-center gap-4"> <div class="mb-3 flex items-center gap-4">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="text-sm font-semibold text-gray-700 dark:text-gray-300">激活状态</label>
$t('apiKeys.batchEditApiKeyModal.activeStatus')
}}</label>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.isActive" class="mr-2" type="radio" :value="true" /> <input v-model="form.isActive" class="mr-2" type="radio" :value="true" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">激活</span>
$t('apiKeys.batchEditApiKeyModal.statusOptions.active')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.isActive" class="mr-2" type="radio" :value="false" /> <input v-model="form.isActive" class="mr-2" type="radio" :value="false" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">禁用</span>
$t('apiKeys.batchEditApiKeyModal.statusOptions.disabled')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.isActive" class="mr-2" type="radio" :value="null" /> <input v-model="form.isActive" class="mr-2" type="radio" :value="null" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">不修改</span>
$t('apiKeys.batchEditApiKeyModal.statusOptions.noChange')
}}</span>
</label> </label>
</div> </div>
</div> </div>
@@ -292,39 +273,29 @@
<!-- 服务权限 --> <!-- 服务权限 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.batchEditApiKeyModal.servicePermissions') >服务权限</label
}}</label> >
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="" /> <input v-model="form.permissions" class="mr-2" type="radio" value="" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700">不修改</span>
$t('apiKeys.batchEditApiKeyModal.permissionOptions.noChange')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="all" /> <input v-model="form.permissions" class="mr-2" type="radio" value="all" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700">全部服务</span>
$t('apiKeys.batchEditApiKeyModal.permissionOptions.all')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" /> <input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700"> Claude</span>
$t('apiKeys.batchEditApiKeyModal.permissionOptions.claude')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" /> <input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700"> Gemini</span>
$t('apiKeys.batchEditApiKeyModal.permissionOptions.gemini')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" /> <input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700"> OpenAI</span>
$t('apiKeys.batchEditApiKeyModal.permissionOptions.openai')
}}</span>
</label> </label>
</div> </div>
</div> </div>
@@ -332,13 +303,13 @@
<!-- 专属账号绑定 --> <!-- 专属账号绑定 -->
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.batchEditApiKeyModal.accountBinding') >专属账号绑定</label
}}</label> >
<button <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" 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" :disabled="accountsLoading"
:title="$t('apiKeys.batchEditApiKeyModal.refreshAccounts')" title="刷新账号列表"
type="button" type="button"
@click="refreshAccounts" @click="refreshAccounts"
> >
@@ -349,46 +320,31 @@
'text-xs' 'text-xs'
]" ]"
/> />
<span>{{ <span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
accountsLoading
? $t('apiKeys.batchEditApiKeyModal.refreshing')
: $t('apiKeys.batchEditApiKeyModal.refreshAccounts')
}}</span>
</button> </button>
</div> </div>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.batchEditApiKeyModal.claudeAccount') >Claude 专属账号</label
}}</label> >
<select <select
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" 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'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
> >
<option value=""> <option value="">不修改</option>
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }} <option value="SHARED_POOL">使用共享账号池</option>
</option> <optgroup v-if="localAccounts.claudeGroups.length > 0" label="账号分组">
<option value="SHARED_POOL">
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
</option>
<optgroup
v-if="localAccounts.claudeGroups.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
>
<option <option
v-for="group in localAccounts.claudeGroups" v-for="group in localAccounts.claudeGroups"
:key="group.id" :key="group.id"
:value="`group:${group.id}`" :value="`group:${group.id}`"
> >
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix') 分组 - {{ group.name }}
}}{{ group.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup <optgroup v-if="localAccounts.claude.length > 0" label="专属账号">
v-if="localAccounts.claude.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
>
<option <option
v-for="account in localAccounts.claude" v-for="account in localAccounts.claude"
:key="account.id" :key="account.id"
@@ -404,37 +360,26 @@
</select> </select>
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.batchEditApiKeyModal.geminiAccount') >Gemini 专属账号</label
}}</label> >
<select <select
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" 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'" :disabled="form.permissions === 'claude' || form.permissions === 'openai'"
> >
<option value=""> <option value="">不修改</option>
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }} <option value="SHARED_POOL">使用共享账号池</option>
</option> <optgroup v-if="localAccounts.geminiGroups.length > 0" label="账号分组">
<option value="SHARED_POOL">
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
</option>
<optgroup
v-if="localAccounts.geminiGroups.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
>
<option <option
v-for="group in localAccounts.geminiGroups" v-for="group in localAccounts.geminiGroups"
:key="group.id" :key="group.id"
:value="`group:${group.id}`" :value="`group:${group.id}`"
> >
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix') 分组 - {{ group.name }}
}}{{ group.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup <optgroup v-if="localAccounts.gemini.length > 0" label="专属账号">
v-if="localAccounts.gemini.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
>
<option <option
v-for="account in localAccounts.gemini" v-for="account in localAccounts.gemini"
:key="account.id" :key="account.id"
@@ -446,37 +391,26 @@
</select> </select>
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.batchEditApiKeyModal.openaiAccount') >OpenAI 专属账号</label
}}</label> >
<select <select
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" 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'" :disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
> >
<option value=""> <option value="">不修改</option>
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }} <option value="SHARED_POOL">使用共享账号池</option>
</option> <optgroup v-if="localAccounts.openaiGroups.length > 0" label="账号分组">
<option value="SHARED_POOL">
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
</option>
<optgroup
v-if="localAccounts.openaiGroups.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
>
<option <option
v-for="group in localAccounts.openaiGroups" v-for="group in localAccounts.openaiGroups"
:key="group.id" :key="group.id"
:value="`group:${group.id}`" :value="`group:${group.id}`"
> >
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix') 分组 - {{ group.name }}
}}{{ group.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup <optgroup v-if="localAccounts.openai.length > 0" label="专属账号">
v-if="localAccounts.openai.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
>
<option <option
v-for="account in localAccounts.openai" v-for="account in localAccounts.openai"
:key="account.id" :key="account.id"
@@ -488,24 +422,17 @@
</select> </select>
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.batchEditApiKeyModal.bedrockAccount') >Bedrock 专属账号</label
}}</label> >
<select <select
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" 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'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
> >
<option value=""> <option value="">不修改</option>
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }} <option value="SHARED_POOL">使用共享账号池</option>
</option> <optgroup v-if="localAccounts.bedrock.length > 0" label="专属账号">
<option value="SHARED_POOL">
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
</option>
<optgroup
v-if="localAccounts.bedrock.length > 0"
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
>
<option <option
v-for="account in localAccounts.bedrock" v-for="account in localAccounts.bedrock"
:key="account.id" :key="account.id"
@@ -525,7 +452,7 @@
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >
{{ $t('apiKeys.batchEditApiKeyModal.cancel') }} 取消
</button> </button>
<button <button
class="btn btn-primary flex-1 px-6 py-3 font-semibold" 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" /> <div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" /> <i v-else class="fas fa-save mr-2" />
{{ {{ loading ? '保存中...' : '批量保存' }}
loading
? $t('apiKeys.batchEditApiKeyModal.saving')
: $t('apiKeys.batchEditApiKeyModal.batchSave')
}}
</button> </button>
</div> </div>
</form> </form>
@@ -549,13 +472,10 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useApiKeysStore } from '@/stores/apiKeys' import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
selectedKeys: { selectedKeys: {
type: Array, type: Array,
@@ -700,9 +620,9 @@ const refreshAccounts = async () => {
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
} }
showToast(t('apiKeys.batchEditApiKeyModal.refreshAccountsSuccess'), 'success') showToast('账号列表已刷新', 'success')
} catch (error) { } catch (error) {
showToast(t('apiKeys.batchEditApiKeyModal.refreshAccountsFailed'), 'error') showToast('刷新账号列表失败', 'error')
} finally { } finally {
accountsLoading.value = false accountsLoading.value = false
} }
@@ -802,33 +722,24 @@ const batchUpdateApiKeys = async () => {
const { successCount, failedCount, errors } = result.data const { successCount, failedCount, errors } = result.data
if (successCount > 0) { if (successCount > 0) {
showToast( showToast(`成功批量编辑 ${successCount} 个 API Keys`, 'success')
t('apiKeys.batchEditApiKeyModal.batchEditSuccess', { count: successCount }),
'success'
)
if (failedCount > 0) { if (failedCount > 0) {
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n') const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
showToast( showToast(`${failedCount} 个编辑失败:\n${errorMessages}`, 'warning')
t('apiKeys.batchEditApiKeyModal.batchEditPartialFail', {
failedCount,
errors: errorMessages
}),
'warning'
)
} }
} else { } else {
showToast(t('apiKeys.batchEditApiKeyModal.batchEditAllFailed'), 'error') showToast('所有 API Keys 编辑失败', 'error')
} }
emit('success') emit('success')
emit('close') emit('close')
} else { } else {
showToast(result.message || t('apiKeys.batchEditApiKeyModal.batchEditFailed'), 'error') showToast(result.message || '批量编辑失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('apiKeys.batchEditApiKeyModal.batchEditFailed'), 'error') showToast('批量编辑失败', 'error')
console.error(t('apiKeys.batchEditApiKeyModal.batchEditErrorLog'), error) console.error('批量编辑 API Keys 失败:', error)
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -10,7 +10,7 @@
<i class="fas fa-key text-sm text-white sm:text-base" /> <i class="fas fa-key text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
{{ $t('apiKeys.createApiKeyModal.title') }} 创建新的 API Key
</h3> </h3>
</div> </div>
<button <button
@@ -37,7 +37,7 @@
> >
<label <label
class="flex h-full items-center text-xs font-semibold text-gray-700 dark:text-gray-300 sm:text-sm" 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"> <div class="flex items-center gap-3 sm:gap-4">
<label class="flex cursor-pointer items-center"> <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" 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" /> <i class="fas fa-key mr-1 text-xs" />
{{ $t('apiKeys.createApiKeyModal.singleCreate') }} 单个创建
</span> </span>
</label> </label>
<label class="flex cursor-pointer items-center"> <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" 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" /> <i class="fas fa-layer-group mr-1 text-xs" />
{{ $t('apiKeys.createApiKeyModal.batchCreate') }} 批量创建
</span> </span>
</label> </label>
</div> </div>
@@ -75,30 +75,32 @@
<div v-if="form.createType === 'batch'" class="mt-3"> <div v-if="form.createType === 'batch'" class="mt-3">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex-1"> <div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.createApiKeyModal.batchCount') >创建数量</label
}}</label> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model.number="form.batchCount" 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" 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" max="500"
min="2" min="2"
:placeholder="$t('apiKeys.createApiKeyModal.batchCountPlaceholder')" placeholder="输入数量 (2-500)"
required required
type="number" type="number"
/> />
<div class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400"> <div class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.maxSupported') }} 最大支持 500
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<p class="mt-2 flex items-start text-xs text-amber-600 dark:text-amber-400"> <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" /> <i class="fas fa-info-circle mr-1 mt-0.5 flex-shrink-0" />
<span>{{ <span
$t('apiKeys.createApiKeyModal.batchHint', { name: form.name || 'MyKey' }) >批量创建时每个 Key 的名称会自动添加序号后缀例如{{
}}</span> form.name || 'MyKey'
}}_1, {{ form.name || 'MyKey' }}_2 ...</span
>
</p> </p>
</div> </div>
</div> </div>
@@ -106,24 +108,23 @@
<div> <div>
<label <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm" 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">*</span></label
<span class="text-red-500">{{
$t('apiKeys.createApiKeyModal.nameRequired')
}}</span></label
> >
<div>
<input <input
v-model="form.name" 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="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 }" :class="{ 'border-red-500': errors.name }"
:placeholder=" :placeholder="
form.createType === 'batch' form.createType === 'batch'
? $t('apiKeys.createApiKeyModal.batchNamePlaceholder') ? '输入基础名称(将自动添加序号)'
: $t('apiKeys.createApiKeyModal.singleNamePlaceholder') : '为您的 API Key 取一个名称'
" "
required required
type="text" type="text"
@input="errors.name = ''" @input="errors.name = ''"
/> />
</div>
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400"> <p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }} {{ errors.name }}
</p> </p>
@@ -131,14 +132,14 @@
<!-- 标签 --> <!-- 标签 -->
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.tags') >标签</label
}}</label> >
<div class="space-y-4"> <div class="space-y-4">
<!-- 已选择的标签 --> <!-- 已选择的标签 -->
<div v-if="form.tags.length > 0"> <div v-if="form.tags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.selectedTags') }} 已选择的标签:
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
@@ -161,7 +162,7 @@
<!-- 可选择的已有标签 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0"> <div v-if="unselectedTags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.clickToSelectTags') }} 点击选择已有标签:
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
@@ -180,13 +181,13 @@
<!-- 创建新标签 --> <!-- 创建新标签 -->
<div> <div>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.createNewTag') }} 创建新标签:
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" 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" 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" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
/> />
@@ -201,7 +202,7 @@
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.tagHint') }} 用于标记不同团队或用途方便筛选管理
</p> </p>
</div> </div>
</div> </div>
@@ -217,76 +218,68 @@
<i class="fas fa-tachometer-alt text-xs text-white" /> <i class="fas fa-tachometer-alt text-xs text-white" />
</div> </div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200"> <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
{{ $t('apiKeys.createApiKeyModal.rateLimitTitle') }} 速率限制设置 (可选)
</h4> </h4>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.rateLimitWindow') >时间窗口 (分钟)</label
}}</label> >
<input <input
v-model="form.rateLimitWindow" 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" 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" min="1"
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitWindowPlaceholder')" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
{{ $t('apiKeys.createApiKeyModal.rateLimitWindowHint') }}
</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.rateLimitRequests') >请求次数限制</label
}}</label> >
<input <input
v-model="form.rateLimitRequests" 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" 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" min="1"
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitRequestsPlaceholder')" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
{{ $t('apiKeys.createApiKeyModal.rateLimitRequestsHint') }}
</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.rateLimitCost') >费用限制 (美元)</label
}}</label> >
<input <input
v-model="form.rateLimitCost" 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" 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" min="0"
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitCostPlaceholder')" placeholder="无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
{{ $t('apiKeys.createApiKeyModal.rateLimitCostHint') }}
</p>
</div> </div>
</div> </div>
<!-- 示例说明 --> <!-- 示例说明 -->
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"> <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"> <h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
{{ $t('apiKeys.createApiKeyModal.exampleTitle') }} 💡 使用示例
</h5> </h5>
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300"> <div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
<div> <div>
<strong>{{ $t('apiKeys.createApiKeyModal.example1') }}</strong> <strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div> </div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div> <div>
<strong>{{ $t('apiKeys.createApiKeyModal.example2') }}</strong> <strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
<div>
<strong>{{ $t('apiKeys.createApiKeyModal.example3') }}</strong>
</div> </div>
</div> </div>
</div> </div>
@@ -294,9 +287,9 @@
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.dailyCostLimit') >每日费用限制 (美元)</label
}}</label> >
<div class="space-y-2"> <div class="space-y-2">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -325,27 +318,27 @@
type="button" type="button"
@click="form.dailyCostLimit = ''" @click="form.dailyCostLimit = ''"
> >
{{ $t('apiKeys.createApiKeyModal.custom') }} 自定义
</button> </button>
</div> </div>
<input <input
v-model="form.dailyCostLimit" 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" 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" min="0"
:placeholder="$t('apiKeys.createApiKeyModal.dailyCostLimitPlaceholder')" placeholder="0 表示无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.dailyCostHint') }} 设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.weeklyOpusCostLimit') >Opus 模型周费用限制 (美元)</label
}}</label> >
<div class="space-y-2"> <div class="space-y-2">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -374,55 +367,55 @@
type="button" type="button"
@click="form.weeklyOpusCostLimit = ''" @click="form.weeklyOpusCostLimit = ''"
> >
{{ $t('apiKeys.createApiKeyModal.custom') }} 自定义
</button> </button>
</div> </div>
<input <input
v-model="form.weeklyOpusCostLimit" 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" 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" min="0"
:placeholder="$t('apiKeys.createApiKeyModal.weeklyOpusCostLimitPlaceholder')" placeholder="0 表示无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.weeklyOpusHint') }} 设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.concurrencyLimit') >并发限制 (可选)</label
}}</label> >
<input <input
v-model="form.concurrencyLimit" 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" 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" min="0"
:placeholder="$t('apiKeys.createApiKeyModal.concurrencyLimitPlaceholder')" placeholder="0 表示无限制"
type="number" type="number"
/> />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.concurrencyHint') }} 设置此 API Key 可同时处理的最大请求数0 或留空表示无限制
</p> </p>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.description') >备注 (可选)</label
}}</label> >
<textarea <textarea
v-model="form.description" 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" 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" rows="2"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.expirationSettings') >过期设置</label
}}</label> >
<!-- 过期模式选择 --> <!-- 过期模式选择 -->
<div <div
class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800" 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" type="radio"
value="fixed" value="fixed"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">固定时间过期</span>
$t('apiKeys.createApiKeyModal.fixedTimeExpiry')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -446,19 +437,17 @@
type="radio" type="radio"
value="activation" value="activation"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">首次使用后激活</span>
$t('apiKeys.createApiKeyModal.activationExpiry')
}}</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<span v-if="form.expirationMode === 'fixed'"> <span v-if="form.expirationMode === 'fixed'">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
{{ $t('apiKeys.createApiKeyModal.fixedModeHint') }} 固定时间模式Key 创建后立即生效按设定时间过期
</span> </span>
<span v-else> <span v-else>
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
{{ $t('apiKeys.createApiKeyModal.activationModeHint') }} 激活模式Key 首次使用时激活激活后按设定天数过期适合批量销售
</span> </span>
</p> </p>
</div> </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" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateExpireAt" @change="updateExpireAt"
> >
<option value="">{{ $t('apiKeys.createApiKeyModal.neverExpire') }}</option> <option value="">永不过期</option>
<option value="1d">{{ $t('apiKeys.createApiKeyModal.1d') }}</option> <option value="1d">1 </option>
<option value="7d">{{ $t('apiKeys.createApiKeyModal.7d') }}</option> <option value="7d">7 </option>
<option value="30d">{{ $t('apiKeys.createApiKeyModal.30d') }}</option> <option value="30d">30 </option>
<option value="90d">{{ $t('apiKeys.createApiKeyModal.90d') }}</option> <option value="90d">90 </option>
<option value="180d">{{ $t('apiKeys.createApiKeyModal.180d') }}</option> <option value="180d">180 </option>
<option value="365d">{{ $t('apiKeys.createApiKeyModal.365d') }}</option> <option value="365d">365 </option>
<option value="custom">{{ $t('apiKeys.createApiKeyModal.customDate') }}</option> <option value="custom">自定义日期</option>
</select> </select>
<div v-if="form.expireDuration === 'custom'" class="mt-3"> <div v-if="form.expireDuration === 'custom'" class="mt-3">
<input <input
@@ -489,11 +478,7 @@
/> />
</div> </div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ 将于 {{ formatExpireDate(form.expiresAt) }} 过期
$t('apiKeys.createApiKeyModal.willExpireOn', {
date: formatExpireDate(form.expiresAt)
})
}}
</p> </p>
</div> </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" class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
max="3650" max="3650"
min="1" min="1"
:placeholder="$t('apiKeys.createApiKeyModal.activationDays')" placeholder="输入天数"
type="number" type="number"
/> />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ <span class="text-sm text-gray-600 dark:text-gray-400"></span>
$t('apiKeys.createApiKeyModal.daysUnit')
}}</span>
</div> </div>
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
<button <button
@@ -520,24 +503,20 @@
type="button" type="button"
@click="form.activationDays = days" @click="form.activationDays = days"
> >
{{ days }}{{ $t('apiKeys.createApiKeyModal.daysUnit') }} {{ days }}
</button> </button>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-clock mr-1" /> <i class="fas fa-clock mr-1" />
{{ Key 将在首次使用后激活激活后 {{ form.activationDays || 30 }} 天过期
$t('apiKeys.createApiKeyModal.activationHint', {
days: form.activationDays || 30
})
}}
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.servicePermissions') >服务权限</label
}}</label> >
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -546,9 +525,7 @@
type="radio" type="radio"
value="all" value="all"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
$t('apiKeys.createApiKeyModal.allServices')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -557,9 +534,7 @@
type="radio" type="radio"
value="claude" value="claude"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
$t('apiKeys.createApiKeyModal.claudeOnly')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -568,9 +543,7 @@
type="radio" type="radio"
value="gemini" value="gemini"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
$t('apiKeys.createApiKeyModal.geminiOnly')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -579,25 +552,23 @@
type="radio" type="radio"
value="openai" value="openai"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
$t('apiKeys.createApiKeyModal.openaiOnly')
}}</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.permissionHint') }} 控制此 API Key 可以访问哪些服务
</p> </p>
</div> </div>
<div> <div>
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.dedicatedAccountBinding') >专属账号绑定 (可选)</label
}}</label> >
<button <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" 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" :disabled="accountsLoading"
title="{{ $t('apiKeys.createApiKeyModal.refreshAccounts') }}" title="刷新账号列表"
type="button" type="button"
@click="refreshAccounts" @click="refreshAccounts"
> >
@@ -608,73 +579,69 @@
'text-xs' 'text-xs'
]" ]"
/> />
<span>{{ <span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
accountsLoading
? $t('apiKeys.createApiKeyModal.refreshing')
: $t('apiKeys.createApiKeyModal.refreshAccounts')
}}</span>
</button> </button>
</div> </div>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.createApiKeyModal.claudeDedicatedAccount') >Claude 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
:accounts="localAccounts.claude" :accounts="localAccounts.claude"
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
:groups="localAccounts.claudeGroups" :groups="localAccounts.claudeGroups"
:placeholder="$t('apiKeys.createApiKeyModal.selectClaudeAccount')" placeholder="请选择Claude账号"
platform="claude" platform="claude"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.createApiKeyModal.geminiDedicatedAccount') >Gemini 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
:accounts="localAccounts.gemini" :accounts="localAccounts.gemini"
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'" :disabled="form.permissions === 'claude' || form.permissions === 'openai'"
:groups="localAccounts.geminiGroups" :groups="localAccounts.geminiGroups"
:placeholder="$t('apiKeys.createApiKeyModal.selectGeminiAccount')" placeholder="请选择Gemini账号"
platform="gemini" platform="gemini"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.createApiKeyModal.openaiDedicatedAccount') >OpenAI 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
:accounts="localAccounts.openai" :accounts="localAccounts.openai"
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'" :disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
:groups="localAccounts.openaiGroups" :groups="localAccounts.openaiGroups"
:placeholder="$t('apiKeys.createApiKeyModal.selectOpenaiAccount')" placeholder="请选择OpenAI账号"
platform="openai" platform="openai"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
$t('apiKeys.createApiKeyModal.bedrockDedicatedAccount') >Bedrock 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock" :accounts="localAccounts.bedrock"
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
:groups="[]" :groups="[]"
:placeholder="$t('apiKeys.createApiKeyModal.selectBedrockAccount')" placeholder="请选择Bedrock账号"
platform="bedrock" platform="bedrock"
/> />
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.createApiKeyModal.accountBindingHint') }} 选择专属账号后此API Key将只使用该账号不选择则使用共享账号池
</p> </p>
</div> </div>
@@ -690,15 +657,13 @@
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="enableModelRestriction" for="enableModelRestriction"
> >
{{ $t('apiKeys.createApiKeyModal.enableModelRestriction') }} 启用模型限制
</label> </label>
</div> </div>
<div v-if="form.enableModelRestriction" class="space-y-3"> <div v-if="form.enableModelRestriction" class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-600">{{ <label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label>
$t('apiKeys.createApiKeyModal.restrictedModelsList')
}}</label>
<div <div
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2" 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> </button>
</span> </span>
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400"> <span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400">
{{ $t('apiKeys.createApiKeyModal.noRestrictedModels') }} 暂无限制的模型
</span> </span>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -736,7 +701,7 @@
v-if="availableQuickModels.length === 0" v-if="availableQuickModels.length === 0"
class="text-sm italic text-gray-400" class="text-sm italic text-gray-400"
> >
{{ $t('apiKeys.createApiKeyModal.allCommonModelsRestricted') }} 所有常用模型已在限制列表中
</span> </span>
</div> </div>
@@ -745,7 +710,7 @@
<input <input
v-model="form.modelInput" v-model="form.modelInput"
class="form-input flex-1" class="form-input flex-1"
:placeholder="$t('apiKeys.createApiKeyModal.addRestrictedModelPlaceholder')" placeholder="输入模型名称,按回车添加"
type="text" type="text"
@keydown.enter.prevent="addRestrictedModel" @keydown.enter.prevent="addRestrictedModel"
/> />
@@ -759,7 +724,7 @@
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500">
{{ $t('apiKeys.createApiKeyModal.modelRestrictionHint') }} 设置此API Key无法访问的模型例如claude-opus-4-20250514
</p> </p>
</div> </div>
</div> </div>
@@ -778,7 +743,7 @@
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="enableClientRestriction" for="enableClientRestriction"
> >
{{ $t('apiKeys.createApiKeyModal.enableClientRestriction') }} 启用客户端限制
</label> </label>
</div> </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" class="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/20"
> >
<div> <div>
<label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300"
$t('apiKeys.createApiKeyModal.allowedClients') >允许的客户端</label
}}</label> >
<div class="space-y-1"> <div class="space-y-1">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start"> <div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input <input
@@ -819,7 +784,7 @@
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >
{{ $t('apiKeys.createApiKeyModal.cancel') }} 取消
</button> </button>
<button <button
class="btn btn-primary flex-1 px-4 py-2.5 text-sm font-semibold" 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" /> <div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-plus mr-2" /> <i v-else class="fas fa-plus mr-2" />
{{ {{ loading ? '创建中...' : '创建' }}
loading
? $t('apiKeys.createApiKeyModal.creating')
: $t('apiKeys.createApiKeyModal.create')
}}
</button> </button>
</div> </div>
</form> </form>
@@ -843,15 +804,12 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useClientsStore } from '@/stores/clients' import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys' import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import AccountSelector from '@/components/common/AccountSelector.vue' import AccountSelector from '@/components/common/AccountSelector.vue'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
accounts: { accounts: {
type: Object, type: Object,
@@ -928,28 +886,58 @@ onMounted(async () => {
availableTags.value = await apiKeysStore.fetchTags() availableTags.value = await apiKeysStore.fetchTags()
// 初始化账号数据 // 初始化账号数据
if (props.accounts) { 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 = { localAccounts.value = {
claude: props.accounts.claude || [], claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [], gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [], openai: openaiAccounts,
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
claudeGroups: props.accounts.claudeGroups || [], claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || [], geminiGroups: props.accounts.geminiGroups || [],
openaiGroups: props.accounts.openaiGroups || [] openaiGroups: props.accounts.openaiGroups || []
} }
} }
// 自动加载账号数据
await refreshAccounts()
}) })
// 刷新账号列表 // 刷新账号列表
const refreshAccounts = async () => { const refreshAccounts = async () => {
accountsLoading.value = true accountsLoading.value = true
try { try {
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] = const [
await Promise.all([ claudeData,
claudeConsoleData,
geminiData,
openaiData,
openaiResponsesData,
bedrockData,
groupsData
] = await Promise.all([
apiClient.get('/admin/claude-accounts'), apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'), apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'), apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'), apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
apiClient.get('/admin/account-groups') apiClient.get('/admin/account-groups')
]) ])
@@ -986,13 +974,31 @@ const refreshAccounts = async () => {
})) }))
} }
// 合并 OpenAI 和 OpenAI-Responses 账号
const openaiAccounts = []
if (openaiData.success) { if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({ ;(openaiData.data || []).forEach((account) => {
openaiAccounts.push({
...account, ...account,
platform: 'openai',
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容 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) { if (bedrockData.success) {
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({ localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
...account, ...account,
@@ -1008,9 +1014,9 @@ const refreshAccounts = async () => {
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
} }
showToast(t('apiKeys.createApiKeyModal.refreshAccountsSuccess'), 'success') showToast('账号列表已刷新', 'success')
} catch (error) { } catch (error) {
showToast(t('apiKeys.createApiKeyModal.refreshAccountsFailed'), 'error') showToast('刷新账号列表失败', 'error')
} finally { } finally {
accountsLoading.value = false accountsLoading.value = false
} }
@@ -1071,17 +1077,13 @@ const updateCustomExpireAt = () => {
// 格式化过期日期 // 格式化过期日期
const formatExpireDate = (dateString) => { const formatExpireDate = (dateString) => {
const date = new Date(dateString) const date = new Date(dateString)
const { locale } = useI18n() return date.toLocaleString('zh-CN', {
return date.toLocaleString(
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
{
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
} })
)
} }
// 添加限制的模型 // 添加限制的模型
@@ -1139,14 +1141,14 @@ const createApiKey = async () => {
errors.value.name = '' errors.value.name = ''
if (!form.name || !form.name.trim()) { if (!form.name || !form.name.trim()) {
errors.value.name = t('apiKeys.createApiKeyModal.nameError') errors.value.name = '请输入API Key名称'
return return
} }
// 批量创建时验证数量 // 批量创建时验证数量
if (form.createType === 'batch') { if (form.createType === 'batch') {
if (!form.batchCount || form.batchCount < 2 || form.batchCount > 500) { if (!form.batchCount || form.batchCount < 2 || form.batchCount > 500) {
showToast(t('apiKeys.createApiKeyModal.batchCountError'), 'error') showToast('批量创建数量必须在 2-500 之间', 'error')
return return
} }
} }
@@ -1156,14 +1158,14 @@ const createApiKey = async () => {
let confirmed = false let confirmed = false
if (window.showConfirm) { if (window.showConfirm) {
confirmed = await window.showConfirm( confirmed = await window.showConfirm(
t('apiKeys.createApiKeyModal.costLimitConfirmTitle'), '费用限制提醒',
t('apiKeys.createApiKeyModal.costLimitConfirmMessage'), '您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
t('apiKeys.createApiKeyModal.costLimitConfirmContinue'), '继续创建',
t('apiKeys.createApiKeyModal.costLimitConfirmBack') '返回修改'
) )
} else { } else {
// 降级方案 // 降级方案
confirmed = confirm(t('apiKeys.createApiKeyModal.costLimitFallbackMessage')) confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
} }
if (!confirmed) { if (!confirmed) {
return return
@@ -1252,11 +1254,11 @@ const createApiKey = async () => {
const result = await apiClient.post('/admin/api-keys', data) const result = await apiClient.post('/admin/api-keys', data)
if (result.success) { if (result.success) {
showToast(t('apiKeys.createApiKeyModal.createSuccess'), 'success') showToast('API Key 创建成功', 'success')
emit('success', result.data) emit('success', result.data)
emit('close') emit('close')
} else { } else {
showToast(result.message || t('apiKeys.createApiKeyModal.createFailed'), 'error') showToast(result.message || '创建失败', 'error')
} }
} else { } else {
// 批量创建 // 批量创建
@@ -1270,18 +1272,15 @@ const createApiKey = async () => {
const result = await apiClient.post('/admin/api-keys/batch', data) const result = await apiClient.post('/admin/api-keys/batch', data)
if (result.success) { if (result.success) {
showToast( showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
t('apiKeys.createApiKeyModal.batchCreateSuccess', { count: result.data.length }),
'success'
)
emit('batch-success', result.data) emit('batch-success', result.data)
emit('close') emit('close')
} else { } else {
showToast(result.message || t('apiKeys.createApiKeyModal.batchCreateFailed'), 'error') showToast(result.message || '批量创建失败', 'error')
} }
} }
} catch (error) { } catch (error) {
showToast(t('apiKeys.createApiKeyModal.createFailed'), 'error') showToast('创建失败', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -12,7 +12,7 @@
<i class="fas fa-edit text-sm text-white sm:text-base" /> <i class="fas fa-edit text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
{{ t('apiKeys.editApiKeyModal.title') }} 编辑 API Key
</h3> </h3>
</div> </div>
<button <button
@@ -30,18 +30,20 @@
<div> <div>
<label <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" 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
> >
<div>
<input <input
v-model="form.name" 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="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" maxlength="100"
:placeholder="t('apiKeys.editApiKeyModal.namePlaceholder')" placeholder="请输入API Key名称"
required required
type="text" type="text"
/> />
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
{{ t('apiKeys.editApiKeyModal.nameHint') }} 用于识别此 API Key 的用途
</p> </p>
</div> </div>
@@ -49,7 +51,7 @@
<div> <div>
<label <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" 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 <select
v-model="form.ownerId" v-model="form.ownerId"
@@ -57,13 +59,11 @@
> >
<option v-for="user in availableUsers" :key="user.id" :value="user.id"> <option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.displayName }} ({{ user.username }}) {{ user.displayName }} ({{ user.username }})
<span v-if="user.role === 'admin'" class="text-gray-500">{{ <span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
t('apiKeys.editApiKeyModal.adminLabel')
}}</span>
</option> </option>
</select> </select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2"> <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> </p>
</div> </div>
@@ -71,13 +71,13 @@
<div> <div>
<label <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" 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 class="space-y-4">
<!-- 已选择的标签 --> <!-- 已选择的标签 -->
<div v-if="form.tags.length > 0"> <div v-if="form.tags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.selectedTags') }} 已选择的标签:
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
@@ -100,7 +100,7 @@
<!-- 可选择的已有标签 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0"> <div v-if="unselectedTags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.clickToSelectTags') }} 点击选择已有标签:
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
@@ -119,13 +119,13 @@
<!-- 创建新标签 --> <!-- 创建新标签 -->
<div> <div>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400"> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.createNewTag') }} 创建新标签:
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" 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" 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" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
/> />
@@ -140,7 +140,7 @@
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.tagsHint') }} 用于标记不同团队或用途方便筛选管理
</p> </p>
</div> </div>
</div> </div>
@@ -156,76 +156,68 @@
<i class="fas fa-tachometer-alt text-xs text-white" /> <i class="fas fa-tachometer-alt text-xs text-white" />
</div> </div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200"> <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
{{ t('apiKeys.editApiKeyModal.rateLimitTitle') }} 速率限制设置 (可选)
</h4> </h4>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.rateLimitWindow') >时间窗口 (分钟)</label
}}</label> >
<input <input
v-model="form.rateLimitWindow" 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" 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" min="1"
:placeholder="t('apiKeys.editApiKeyModal.noLimit')" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
{{ t('apiKeys.editApiKeyModal.rateLimitWindowHint') }}
</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.rateLimitRequests') >请求次数限制</label
}}</label> >
<input <input
v-model="form.rateLimitRequests" 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" 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" min="1"
:placeholder="t('apiKeys.editApiKeyModal.noLimit')" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
{{ t('apiKeys.editApiKeyModal.rateLimitRequestsHint') }}
</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.rateLimitCost') >费用限制 (美元)</label
}}</label> >
<input <input
v-model="form.rateLimitCost" 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" 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" min="0"
:placeholder="t('apiKeys.editApiKeyModal.noLimit')" placeholder="无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
{{ t('apiKeys.editApiKeyModal.rateLimitCostHint') }}
</p>
</div> </div>
</div> </div>
<!-- 示例说明 --> <!-- 示例说明 -->
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"> <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"> <h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
{{ t('apiKeys.editApiKeyModal.usageExamples') }} 💡 使用示例
</h5> </h5>
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300"> <div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
<div> <div>
<strong>{{ t('apiKeys.editApiKeyModal.example1') }}</strong> <strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div> </div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div> <div>
<strong>{{ t('apiKeys.editApiKeyModal.example2') }}</strong> <strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
<div>
<strong>{{ t('apiKeys.editApiKeyModal.example3') }}</strong>
</div> </div>
</div> </div>
</div> </div>
@@ -233,9 +225,9 @@
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.dailyCostLimit') >每日费用限制 (美元)</label
}}</label> >
<div class="space-y-3"> <div class="space-y-3">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -264,27 +256,27 @@
type="button" type="button"
@click="form.dailyCostLimit = ''" @click="form.dailyCostLimit = ''"
> >
{{ t('apiKeys.editApiKeyModal.custom') }} 自定义
</button> </button>
</div> </div>
<input <input
v-model="form.dailyCostLimit" 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" 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" min="0"
:placeholder="t('apiKeys.editApiKeyModal.dailyCostLimitPlaceholder')" placeholder="0 表示无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.dailyCostHint') }} 设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.weeklyOpusCostLimit') >Opus 模型周费用限制 (美元)</label
}}</label> >
<div class="space-y-3"> <div class="space-y-3">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -313,36 +305,36 @@
type="button" type="button"
@click="form.weeklyOpusCostLimit = ''" @click="form.weeklyOpusCostLimit = ''"
> >
{{ t('apiKeys.editApiKeyModal.custom') }} 自定义
</button> </button>
</div> </div>
<input <input
v-model="form.weeklyOpusCostLimit" 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" 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" min="0"
:placeholder="t('apiKeys.editApiKeyModal.weeklyOpusCostLimitPlaceholder')" placeholder="0 表示无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.weeklyOpusHint') }} 设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.concurrencyLimit') >并发限制</label
}}</label> >
<input <input
v-model="form.concurrencyLimit" 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" 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" min="0"
:placeholder="t('apiKeys.editApiKeyModal.concurrencyLimitPlaceholder')" placeholder="0 表示无限制"
type="number" type="number"
/> />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.concurrencyHint') }} 设置此 API Key 可同时处理的最大请求数
</p> </p>
</div> </div>
@@ -359,18 +351,18 @@
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editIsActive" for="editIsActive"
> >
{{ t('apiKeys.editApiKeyModal.activeStatus') }} 激活账号
</label> </label>
</div> </div>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400"> <p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.activeStatusHint') }} 取消勾选将禁用此 API Key暂停所有请求客户端返回 401 错误
</p> </p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.servicePermissions') >服务权限</label
}}</label> >
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -379,9 +371,7 @@
type="radio" type="radio"
value="all" value="all"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
t('apiKeys.editApiKeyModal.allServices')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -390,9 +380,7 @@
type="radio" type="radio"
value="claude" value="claude"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
t('apiKeys.editApiKeyModal.claudeOnly')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -401,9 +389,7 @@
type="radio" type="radio"
value="gemini" value="gemini"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
t('apiKeys.editApiKeyModal.geminiOnly')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -412,25 +398,23 @@
type="radio" type="radio"
value="openai" value="openai"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ <span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
t('apiKeys.editApiKeyModal.openaiOnly')
}}</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.permissionsHint') }} 控制此 API Key 可以访问哪些服务
</p> </p>
</div> </div>
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.editApiKeyModal.accountBinding') >专属账号绑定</label
}}</label> >
<button <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" 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" :disabled="accountsLoading"
:title="t('apiKeys.editApiKeyModal.refreshAccounts')" title="刷新账号列表"
type="button" type="button"
@click="refreshAccounts" @click="refreshAccounts"
> >
@@ -441,73 +425,69 @@
'text-xs' 'text-xs'
]" ]"
/> />
<span>{{ <span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
accountsLoading
? t('apiKeys.editApiKeyModal.refreshing')
: t('apiKeys.editApiKeyModal.refreshAccounts')
}}</span>
</button> </button>
</div> </div>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
t('apiKeys.editApiKeyModal.claudeAccount') >Claude 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
:accounts="localAccounts.claude" :accounts="localAccounts.claude"
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
:groups="localAccounts.claudeGroups" :groups="localAccounts.claudeGroups"
:placeholder="t('apiKeys.editApiKeyModal.selectClaudeAccount')" placeholder="请选择Claude账号"
platform="claude" platform="claude"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
t('apiKeys.editApiKeyModal.geminiAccount') >Gemini 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
:accounts="localAccounts.gemini" :accounts="localAccounts.gemini"
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'" :disabled="form.permissions === 'claude' || form.permissions === 'openai'"
:groups="localAccounts.geminiGroups" :groups="localAccounts.geminiGroups"
:placeholder="t('apiKeys.editApiKeyModal.selectGeminiAccount')" placeholder="请选择Gemini账号"
platform="gemini" platform="gemini"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
t('apiKeys.editApiKeyModal.openaiAccount') >OpenAI 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
:accounts="localAccounts.openai" :accounts="localAccounts.openai"
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'" :disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
:groups="localAccounts.openaiGroups" :groups="localAccounts.openaiGroups"
:placeholder="t('apiKeys.editApiKeyModal.selectOpenaiAccount')" placeholder="请选择OpenAI账号"
platform="openai" platform="openai"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
t('apiKeys.editApiKeyModal.bedrockAccount') >Bedrock 专属账号</label
}}</label> >
<AccountSelector <AccountSelector
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock" :accounts="localAccounts.bedrock"
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')" default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'" :disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
:groups="[]" :groups="[]"
:placeholder="t('apiKeys.editApiKeyModal.selectBedrockAccount')" placeholder="请选择Bedrock账号"
platform="bedrock" platform="bedrock"
/> />
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.accountBindingHint') }} 修改绑定账号将影响此API Key的请求路由
</p> </p>
</div> </div>
@@ -523,15 +503,15 @@
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editEnableModelRestriction" for="editEnableModelRestriction"
> >
{{ t('apiKeys.editApiKeyModal.enableModelRestriction') }} 启用模型限制
</label> </label>
</div> </div>
<div v-if="form.enableModelRestriction" class="space-y-3"> <div v-if="form.enableModelRestriction" class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
t('apiKeys.editApiKeyModal.restrictedModels') >限制的模型列表</label
}}</label> >
<div <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" 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" v-if="form.restrictedModels.length === 0"
class="text-sm text-gray-400 dark:text-gray-500" class="text-sm text-gray-400 dark:text-gray-500"
> >
{{ t('apiKeys.editApiKeyModal.noRestrictedModels') }} 暂无限制的模型
</span> </span>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -572,7 +552,7 @@
v-if="availableQuickModels.length === 0" v-if="availableQuickModels.length === 0"
class="text-sm italic text-gray-400 dark:text-gray-500" class="text-sm italic text-gray-400 dark:text-gray-500"
> >
{{ t('apiKeys.editApiKeyModal.allCommonModelsRestricted') }} 所有常用模型已在限制列表中
</span> </span>
</div> </div>
@@ -581,7 +561,7 @@
<input <input
v-model="form.modelInput" 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" 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" type="text"
@keydown.enter.prevent="addRestrictedModel" @keydown.enter.prevent="addRestrictedModel"
/> />
@@ -595,7 +575,7 @@
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.modelRestrictionHint') }} 设置此API Key无法访问的模型例如claude-opus-4-20250514
</p> </p>
</div> </div>
</div> </div>
@@ -614,17 +594,17 @@
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editEnableClientRestriction" for="editEnableClientRestriction"
> >
{{ t('apiKeys.editApiKeyModal.enableClientRestriction') }} 启用客户端限制
</label> </label>
</div> </div>
<div v-if="form.enableClientRestriction" class="space-y-3"> <div v-if="form.enableClientRestriction" class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400">{{ <label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
t('apiKeys.editApiKeyModal.allowedClients') >允许的客户端</label
}}</label> >
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400"> <p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.editApiKeyModal.clientRestrictionHint') }} 勾选允许使用此API Key的客户端
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start"> <div v-for="client in supportedClients" :key="client.id" class="flex items-start">
@@ -655,7 +635,7 @@
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >
{{ t('apiKeys.editApiKeyModal.cancel') }} 取消
</button> </button>
<button <button
class="btn btn-primary flex-1 px-6 py-3 font-semibold" 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" /> <div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" /> <i v-else class="fas fa-save mr-2" />
{{ {{ loading ? '保存中...' : '保存修改' }}
loading ? t('apiKeys.editApiKeyModal.saving') : t('apiKeys.editApiKeyModal.save')
}}
</button> </button>
</div> </div>
</form> </form>
@@ -677,7 +655,6 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useClientsStore } from '@/stores/clients' import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys' import { useApiKeysStore } from '@/stores/apiKeys'
@@ -697,9 +674,6 @@ const props = defineProps({
const emit = defineEmits(['close', 'success']) const emit = defineEmits(['close', 'success'])
// 国际化
const { t } = useI18n()
// const authStore = useAuthStore() // const authStore = useAuthStore()
const clientsStore = useClientsStore() const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore() const apiKeysStore = useApiKeysStore()
@@ -811,14 +785,14 @@ const updateApiKey = async () => {
let confirmed = false let confirmed = false
if (window.showConfirm) { if (window.showConfirm) {
confirmed = await window.showConfirm( confirmed = await window.showConfirm(
t('apiKeys.editApiKeyModal.costLimitConfirmTitle'), '费用限制提醒',
t('apiKeys.editApiKeyModal.costLimitConfirmMessage'), '您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
t('apiKeys.editApiKeyModal.costLimitConfirmContinue'), '继续保存',
t('apiKeys.editApiKeyModal.costLimitConfirmBack') '返回修改'
) )
} else { } else {
// 降级方案 // 降级方案
confirmed = confirm(t('apiKeys.editApiKeyModal.costLimitConfirmMessage')) confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
} }
if (!confirmed) { if (!confirmed) {
return return
@@ -924,10 +898,10 @@ const updateApiKey = async () => {
emit('success') emit('success')
emit('close') emit('close')
} else { } else {
showToast(result.message || t('apiKeys.editApiKeyModal.updateFailed'), 'error') showToast(result.message || '更新失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('apiKeys.editApiKeyModal.updateFailed'), 'error') showToast('更新失败', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -937,12 +911,20 @@ const updateApiKey = async () => {
const refreshAccounts = async () => { const refreshAccounts = async () => {
accountsLoading.value = true accountsLoading.value = true
try { try {
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] = const [
await Promise.all([ claudeData,
claudeConsoleData,
geminiData,
openaiData,
openaiResponsesData,
bedrockData,
groupsData
] = await Promise.all([
apiClient.get('/admin/claude-accounts'), apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'), apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'), apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'), apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
apiClient.get('/admin/account-groups') apiClient.get('/admin/account-groups')
]) ])
@@ -979,13 +961,31 @@ const refreshAccounts = async () => {
})) }))
} }
// 合并 OpenAI 和 OpenAI-Responses 账号
const openaiAccounts = []
if (openaiData.success) { if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({ ;(openaiData.data || []).forEach((account) => {
openaiAccounts.push({
...account, ...account,
platform: 'openai',
isDedicated: account.accountType === 'dedicated' 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) { if (bedrockData.success) {
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({ localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
...account, ...account,
@@ -1001,9 +1001,9 @@ const refreshAccounts = async () => {
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
} }
showToast(t('apiKeys.editApiKeyModal.refreshAccountsSuccess'), 'success') showToast('账号列表已刷新', 'success')
} catch (error) { } catch (error) {
showToast(t('apiKeys.editApiKeyModal.refreshAccountsFailed'), 'error') showToast('刷新账号列表失败', 'error')
} finally { } finally {
accountsLoading.value = false accountsLoading.value = false
} }
@@ -1017,7 +1017,7 @@ const loadUsers = async () => {
availableUsers.value = response.data || [] availableUsers.value = response.data || []
} }
} catch (error) { } catch (error) {
console.error('Failed to load users:', error) // console.error('Failed to load users:', error)
availableUsers.value = [ availableUsers.value = [
{ {
id: 'admin', id: 'admin',
@@ -1043,7 +1043,7 @@ onMounted(async () => {
supportedClients.value = clients || [] supportedClients.value = clients || []
availableTags.value = tags || [] availableTags.value = tags || []
} catch (error) { } catch (error) {
console.error('Error loading initial data:', error) // console.error('Error loading initial data:', error)
// Fallback to empty arrays if loading fails // Fallback to empty arrays if loading fails
supportedClients.value = [] supportedClients.value = []
availableTags.value = [] availableTags.value = []
@@ -1051,10 +1051,29 @@ onMounted(async () => {
// 初始化账号数据 // 初始化账号数据
if (props.accounts) { 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 = { localAccounts.value = {
claude: props.accounts.claude || [], claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [], gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [], openai: openaiAccounts,
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
claudeGroups: props.accounts.claudeGroups || [], claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || [], geminiGroups: props.accounts.geminiGroups || [],
@@ -1062,6 +1081,9 @@ onMounted(async () => {
} }
} }
// 自动加载账号数据
await refreshAccounts()
form.name = props.apiKey.name form.name = props.apiKey.name
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户 // 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
@@ -1071,7 +1093,7 @@ onMounted(async () => {
// 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置 // 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置
if (props.apiKey.tokenLimit > 0 && !props.apiKey.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 || '' form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
@@ -1087,7 +1109,10 @@ onMounted(async () => {
form.claudeAccountId = props.apiKey.claudeAccountId || '' form.claudeAccountId = props.apiKey.claudeAccountId || ''
} }
form.geminiAccountId = props.apiKey.geminiAccountId || '' form.geminiAccountId = props.apiKey.geminiAccountId || ''
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
form.openaiAccountId = props.apiKey.openaiAccountId || '' form.openaiAccountId = props.apiKey.openaiAccountId || ''
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化 form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
form.restrictedModels = props.apiKey.restrictedModels || [] form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || [] form.allowedClients = props.apiKey.allowedClients || []

View File

@@ -18,11 +18,9 @@
<i class="fas fa-clock text-white" /> <i class="fas fa-clock text-white" />
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100"> <h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改过期时间</h3>
{{ $t('apiKeys.expiryEditModal.title') }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -41,20 +39,14 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400"> <p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">当前状态</p>
{{ $t('apiKeys.expiryEditModal.currentStatus') }}
</p>
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200"> <p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
<!-- 未激活状态 --> <!-- 未激活状态 -->
<template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated"> <template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated">
<i class="fas fa-pause-circle mr-1 text-blue-500" /> <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"> <span class="ml-2 text-xs font-normal text-gray-600">
{{ (激活后 {{ apiKey.activationDays || 30 }} 天过期)
$t('apiKeys.expiryEditModal.activationDaysHint', {
days: apiKey.activationDays || 30
})
}}
</span> </span>
</template> </template>
<!-- 已设置过期时间 --> <!-- 已设置过期时间 -->
@@ -71,7 +63,7 @@
<!-- 永不过期 --> <!-- 永不过期 -->
<template v-else> <template v-else>
<i class="fas fa-infinity mr-1 text-gray-500" /> <i class="fas fa-infinity mr-1 text-gray-500" />
{{ $t('apiKeys.expiryEditModal.neverExpire') }} 永不过期
</template> </template>
</p> </p>
</div> </div>
@@ -97,23 +89,19 @@
@click="handleActivateNow" @click="handleActivateNow"
> >
<i class="fas fa-rocket mr-2" /> <i class="fas fa-rocket mr-2" />
{{ 立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
$t('apiKeys.expiryEditModal.activateButton', { days: apiKey.activationDays || 30 })
}}
</button> </button>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
{{ 点击立即激活此 API Key激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
$t('apiKeys.expiryEditModal.activationInfo', { days: apiKey.activationDays || 30 })
}}
</p> </p>
</div> </div>
<!-- 快捷选项 --> <!-- 快捷选项 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
{{ $t('apiKeys.expiryEditModal.selectNewDuration') }} >选择新的期限</label
</label> >
<div class="mb-3 grid grid-cols-3 gap-2"> <div class="mb-3 grid grid-cols-3 gap-2">
<button <button
v-for="option in quickOptions" v-for="option in quickOptions"
@@ -138,16 +126,16 @@
@click="selectQuickOption('custom')" @click="selectQuickOption('custom')"
> >
<i class="fas fa-calendar-alt mr-1" /> <i class="fas fa-calendar-alt mr-1" />
{{ $t('apiKeys.expiryEditModal.custom') }} 自定义
</button> </button>
</div> </div>
</div> </div>
<!-- 自定义日期选择 --> <!-- 自定义日期选择 -->
<div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn"> <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"> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
{{ $t('apiKeys.expiryEditModal.selectDateAndTime') }} >选择日期和时间</label
</label> >
<input <input
v-model="localForm.customExpireDate" v-model="localForm.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" 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" @change="updateCustomExpiryPreview"
/> />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $t('apiKeys.expiryEditModal.selectFutureDateTime') }} 选择一个未来的日期和时间作为过期时间
</p> </p>
</div> </div>
@@ -169,7 +157,7 @@
<div> <div>
<p class="mb-1 text-xs font-medium text-blue-700 dark:text-blue-400"> <p class="mb-1 text-xs font-medium text-blue-700 dark:text-blue-400">
<i class="fas fa-arrow-right mr-1" /> <i class="fas fa-arrow-right mr-1" />
{{ $t('apiKeys.expiryEditModal.newExpiryTime') }} 新的过期时间
</p> </p>
<p class="text-sm font-semibold text-blue-900 dark:text-blue-200"> <p class="text-sm font-semibold text-blue-900 dark:text-blue-200">
<template v-if="localForm.expiresAt"> <template v-if="localForm.expiresAt">
@@ -184,7 +172,7 @@
</template> </template>
<template v-else> <template v-else>
<i class="fas fa-infinity mr-1" /> <i class="fas fa-infinity mr-1" />
{{ $t('apiKeys.expiryEditModal.neverExpire') }} 永不过期
</template> </template>
</p> </p>
</div> </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" 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')" @click="$emit('close')"
> >
{{ $t('apiKeys.expiryEditModal.cancel') }} 取消
</button> </button>
<button <button
class="btn btn-primary flex-1 px-4 py-2.5 font-semibold" 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" /> <div v-if="saving" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" /> <i v-else class="fas fa-save mr-2" />
{{ {{ saving ? '保存中...' : '保存更改' }}
saving
? $t('apiKeys.expiryEditModal.saving')
: $t('apiKeys.expiryEditModal.saveChanges')
}}
</button> </button>
</div> </div>
</div> </div>
@@ -226,9 +210,6 @@
<script setup> <script setup>
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -254,14 +235,14 @@ const localForm = reactive({
// 快捷选项 // 快捷选项
const quickOptions = [ const quickOptions = [
{ value: '', label: t('apiKeys.expiryEditModal.neverExpireOption') }, { value: '', label: '永不过期' },
{ value: '1d', label: t('apiKeys.expiryEditModal.oneDay') }, { value: '1d', label: '1 天' },
{ value: '7d', label: t('apiKeys.expiryEditModal.sevenDays') }, { value: '7d', label: '7 天' },
{ value: '30d', label: t('apiKeys.expiryEditModal.thirtyDays') }, { value: '30d', label: '30 天' },
{ value: '90d', label: t('apiKeys.expiryEditModal.ninetyDays') }, { value: '90d', label: '90 天' },
{ value: '180d', label: t('apiKeys.expiryEditModal.oneHundredEightyDays') }, { value: '180d', label: '180 天' },
{ value: '365d', label: t('apiKeys.expiryEditModal.threeSixtyFiveDays') }, { value: '365d', label: '1 年' },
{ value: '730d', label: t('apiKeys.expiryEditModal.twoYears') } { value: '730d', label: '2 年' }
] ]
// 计算最小日期时间 // 计算最小日期时间
@@ -356,17 +337,13 @@ const updateCustomExpiryPreview = () => {
const formatExpireDate = (dateString) => { const formatExpireDate = (dateString) => {
if (!dateString) return '' if (!dateString) return ''
const date = new Date(dateString) const date = new Date(dateString)
const { locale } = useI18n() return date.toLocaleString('zh-CN', {
return date.toLocaleString(
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
{
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
} })
)
} }
// 检查是否已过期 // 检查是否已过期
@@ -386,22 +363,22 @@ const getExpiryStatus = (expiresAt) => {
if (diffMs < 0) { if (diffMs < 0) {
return { return {
text: t('apiKeys.expiryEditModal.expired'), text: '已过期',
class: 'text-red-600' class: 'text-red-600'
} }
} else if (diffDays <= 7) { } else if (diffDays <= 7) {
return { return {
text: t('apiKeys.expiryEditModal.daysToExpire', { days: diffDays }), text: `${diffDays} 天后过期`,
class: 'text-orange-600' class: 'text-orange-600'
} }
} else if (diffDays <= 30) { } else if (diffDays <= 30) {
return { return {
text: t('apiKeys.expiryEditModal.daysToExpire', { days: diffDays }), text: `${diffDays} 天后过期`,
class: 'text-yellow-600' class: 'text-yellow-600'
} }
} else { } else {
return { return {
text: t('apiKeys.expiryEditModal.monthsToExpire', { months: Math.ceil(diffDays / 30) }), text: `${Math.ceil(diffDays / 30)} 个月后过期`,
class: 'text-green-600' class: 'text-green-600'
} }
} }
@@ -422,19 +399,15 @@ const handleActivateNow = async () => {
let confirmed = true let confirmed = true
if (window.showConfirm) { if (window.showConfirm) {
confirmed = await window.showConfirm( confirmed = await window.showConfirm(
t('apiKeys.expiryEditModal.activateConfirmTitle'), '激活 API Key',
t('apiKeys.expiryEditModal.activateConfirmMessage', { `确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
days: props.apiKey.activationDays || 30 '确定激活',
}), '取消'
t('apiKeys.expiryEditModal.confirmActivate'),
t('apiKeys.expiryEditModal.confirmCancel')
) )
} else { } else {
// 降级方案 // 降级方案
confirmed = confirm( confirmed = confirm(
t('apiKeys.expiryEditModal.activateConfirmMessage', { `确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
days: props.apiKey.activationDays || 30
})
) )
} }

View File

@@ -12,17 +12,13 @@
<i class="fas fa-check text-lg text-white" /> <i class="fas fa-check text-lg text-white" />
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100"> <h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">API Key 创建成功</h3>
{{ t('apiKeys.newApiKeyModal.title') }} <p class="text-sm text-gray-600 dark:text-gray-400">请妥善保存您的 API Key</p>
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('apiKeys.newApiKeyModal.subtitle') }}
</p>
</div> </div>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" 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" @click="handleDirectClose"
> >
<i class="fas fa-times text-xl" /> <i class="fas fa-times text-xl" />
@@ -40,11 +36,10 @@
<i class="fas fa-exclamation-triangle text-sm text-white" /> <i class="fas fa-exclamation-triangle text-sm text-white" />
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400"> <h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">重要提醒</h5>
{{ t('apiKeys.newApiKeyModal.warningTitle') }}
</h5>
<p class="text-sm text-amber-800 dark:text-amber-300"> <p class="text-sm text-amber-800 dark:text-amber-300">
{{ t('apiKeys.newApiKeyModal.warningMessage') }} 这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API
Key请立即复制并妥善保存
</p> </p>
</div> </div>
</div> </div>
@@ -53,9 +48,9 @@
<!-- API Key 信息 --> <!-- API Key 信息 -->
<div class="mb-6 space-y-4"> <div class="mb-6 space-y-4">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.newApiKeyModal.apiKeyName') >API Key 名称</label
}}</label> >
<div <div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800" 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>
<div v-if="apiKey.description"> <div v-if="apiKey.description">
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.newApiKeyModal.remarks') >备注</label
}}</label> >
<div <div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800" 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">{{ <span class="text-gray-700 dark:text-gray-300">{{
apiKey.description || t('apiKeys.newApiKeyModal.noDescription') apiKey.description || '无描述'
}}</span> }}</span>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('apiKeys.newApiKeyModal.apiKey') >API Key</label
}}</label> >
<div class="relative"> <div class="relative">
<div <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" 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"> <div class="absolute right-3 top-3">
<button <button
class="btn-icon-sm bg-gray-700 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600" class="btn-icon-sm bg-gray-700 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600"
:title=" :title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
showFullKey
? t('apiKeys.newApiKeyModal.hideApiKey')
: t('apiKeys.newApiKeyModal.showFullApiKey')
"
type="button" type="button"
@click="toggleKeyVisibility" @click="toggleKeyVisibility"
> >
@@ -102,7 +93,7 @@
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('apiKeys.newApiKeyModal.visibilityHint') }} 点击眼睛图标切换显示模式使用下方按钮复制完整 API Key
</p> </p>
</div> </div>
</div> </div>
@@ -114,13 +105,13 @@
@click="copyApiKey" @click="copyApiKey"
> >
<i class="fas fa-copy" /> <i class="fas fa-copy" />
{{ t('apiKeys.newApiKeyModal.copyApiKey') }} 复制 API Key
</button> </button>
<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" 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" @click="handleClose"
> >
{{ t('apiKeys.newApiKeyModal.alreadySaved') }} 我已保存
</button> </button>
</div> </div>
</div> </div>
@@ -130,11 +121,8 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
apiKey: { apiKey: {
type: Object, type: Object,
@@ -171,13 +159,13 @@ const getDisplayedApiKey = () => {
const copyApiKey = async () => { const copyApiKey = async () => {
const key = props.apiKey.apiKey || props.apiKey.key || '' const key = props.apiKey.apiKey || props.apiKey.key || ''
if (!key) { if (!key) {
showToast(t('apiKeys.newApiKeyModal.apiKeyNotFound'), 'error') showToast('API Key 不存在', 'error')
return return
} }
try { try {
await navigator.clipboard.writeText(key) await navigator.clipboard.writeText(key)
showToast(t('apiKeys.newApiKeyModal.copySuccess'), 'success') showToast('API Key 已复制到剪贴板', 'success')
} catch (error) { } catch (error) {
// console.error('Failed to copy:', error) // console.error('Failed to copy:', error)
// 降级方案:创建一个临时文本区域 // 降级方案:创建一个临时文本区域
@@ -187,9 +175,9 @@ const copyApiKey = async () => {
textArea.select() textArea.select()
try { try {
document.execCommand('copy') document.execCommand('copy')
showToast(t('apiKeys.newApiKeyModal.copySuccess'), 'success') showToast('API Key 已复制到剪贴板', 'success')
} catch (fallbackError) { } catch (fallbackError) {
showToast(t('apiKeys.newApiKeyModal.copyFailed'), 'error') showToast('复制失败,请手动复制', 'error')
} finally { } finally {
document.body.removeChild(textArea) document.body.removeChild(textArea)
} }
@@ -200,17 +188,19 @@ const copyApiKey = async () => {
const handleClose = async () => { const handleClose = async () => {
if (window.showConfirm) { if (window.showConfirm) {
const confirmed = await window.showConfirm( const confirmed = await window.showConfirm(
t('apiKeys.newApiKeyModal.closeReminderTitle'), '关闭提醒',
t('apiKeys.newApiKeyModal.closeReminderMessage'), '关闭后将无法再次查看完整的API Key请确保已经妥善保存。\n\n确定要关闭吗',
t('apiKeys.newApiKeyModal.confirmClose'), '确定关闭',
t('apiKeys.newApiKeyModal.cancel') '取消'
) )
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }
} else { } else {
// 降级方案 // 降级方案
const confirmed = confirm(t('apiKeys.newApiKeyModal.closeReminderMessage')) const confirmed = confirm(
'关闭后将无法再次查看完整的API Key请确保已经妥善保存。\n\n确定要关闭吗'
)
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }
@@ -221,17 +211,17 @@ const handleClose = async () => {
const handleDirectClose = async () => { const handleDirectClose = async () => {
if (window.showConfirm) { if (window.showConfirm) {
const confirmed = await window.showConfirm( const confirmed = await window.showConfirm(
t('apiKeys.newApiKeyModal.directCloseTitle'), '确定要关闭吗?',
t('apiKeys.newApiKeyModal.directCloseMessage'), '您还没有保存API Key关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
t('apiKeys.newApiKeyModal.stillClose'), '仍然关闭',
t('apiKeys.newApiKeyModal.goBack') '返回复制'
) )
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }
} else { } else {
// 降级方案 // 降级方案
const confirmed = confirm(t('apiKeys.newApiKeyModal.directCloseFallback')) const confirmed = confirm('您还没有保存API Key关闭后将无法再次查看。\n\n确定要关闭吗')
if (confirmed) { if (confirmed) {
emit('close') emit('close')
} }

View File

@@ -9,9 +9,7 @@
> >
<i class="fas fa-clock text-white" /> <i class="fas fa-clock text-white" />
</div> </div>
<h3 class="text-xl font-bold text-gray-900"> <h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
{{ $t('apiKeys.renewApiKeyModal.title') }}
</h3>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600" class="text-gray-400 transition-colors hover:text-gray-600"
@@ -30,18 +28,13 @@
<i class="fas fa-info text-sm text-white" /> <i class="fas fa-info text-sm text-white" />
</div> </div>
<div> <div>
<h4 class="mb-1 font-semibold text-gray-800"> <h4 class="mb-1 font-semibold text-gray-800">API Key 信息</h4>
{{ $t('apiKeys.renewApiKeyModal.apiKeyInfo') }}
</h4>
<p class="text-sm text-gray-700"> <p class="text-sm text-gray-700">
{{ apiKey.name }} {{ apiKey.name }}
</p> </p>
<p class="mt-1 text-xs text-gray-600"> <p class="mt-1 text-xs text-gray-600">
{{ $t('apiKeys.renewApiKeyModal.currentExpiry') 当前过期时间{{
}}{{ apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期'
apiKey.expiresAt
? formatExpireDate(apiKey.expiresAt)
: $t('apiKeys.renewApiKeyModal.neverExpires')
}} }}
</p> </p>
</div> </div>
@@ -49,21 +42,19 @@
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">{{ <label class="mb-3 block text-sm font-semibold text-gray-700">续期时长</label>
$t('apiKeys.renewApiKeyModal.renewDuration')
}}</label>
<select <select
v-model="form.renewDuration" v-model="form.renewDuration"
class="form-input w-full" class="form-input w-full"
@change="updateRenewExpireAt" @change="updateRenewExpireAt"
> >
<option value="7d">{{ $t('apiKeys.renewApiKeyModal.extend7Days') }}</option> <option value="7d">延长 7 </option>
<option value="30d">{{ $t('apiKeys.renewApiKeyModal.extend30Days') }}</option> <option value="30d">延长 30 </option>
<option value="90d">{{ $t('apiKeys.renewApiKeyModal.extend90Days') }}</option> <option value="90d">延长 90 </option>
<option value="180d">{{ $t('apiKeys.renewApiKeyModal.extend180Days') }}</option> <option value="180d">延长 180 </option>
<option value="365d">{{ $t('apiKeys.renewApiKeyModal.extend365Days') }}</option> <option value="365d">延长 365 </option>
<option value="custom">{{ $t('apiKeys.renewApiKeyModal.customDate') }}</option> <option value="custom">自定义日期</option>
<option value="permanent">{{ $t('apiKeys.renewApiKeyModal.setPermanent') }}</option> <option value="permanent">设为永不过期</option>
</select> </select>
<div v-if="form.renewDuration === 'custom'" class="mt-3"> <div v-if="form.renewDuration === 'custom'" class="mt-3">
<input <input
@@ -75,8 +66,7 @@
/> />
</div> </div>
<p v-if="form.newExpiresAt" class="mt-2 text-xs text-gray-500"> <p v-if="form.newExpiresAt" class="mt-2 text-xs text-gray-500">
{{ $t('apiKeys.renewApiKeyModal.newExpiry') 新的过期时间{{ formatExpireDate(form.newExpiresAt) }}
}}{{ formatExpireDate(form.newExpiresAt) }}
</p> </p>
</div> </div>
</div> </div>
@@ -87,7 +77,7 @@
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >
{{ $t('apiKeys.renewApiKeyModal.cancel') }} 取消
</button> </button>
<button <button
class="btn btn-primary flex-1 px-6 py-3 font-semibold" 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" /> <div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-clock mr-2" /> <i v-else class="fas fa-clock mr-2" />
{{ {{ loading ? '续期中...' : '确认续期' }}
loading
? $t('apiKeys.renewApiKeyModal.renewing')
: $t('apiKeys.renewApiKeyModal.confirmRenew')
}}
</button> </button>
</div> </div>
</div> </div>
@@ -111,12 +97,9 @@
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
apiKey: { apiKey: {
type: Object, type: Object,
@@ -150,10 +133,7 @@ const minDateTime = computed(() => {
// 格式化过期日期 // 格式化过期日期
const formatExpireDate = (dateString) => { const formatExpireDate = (dateString) => {
const date = new Date(dateString) const date = new Date(dateString)
// 根据当前语言设置选择合适的locale return date.toLocaleString('zh-CN', {
const locale =
t('common.locale') === 'en' ? 'en-US' : t('common.locale') === 'zh-TW' ? 'zh-TW' : 'zh-CN'
return date.toLocaleString(locale, {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -229,14 +209,14 @@ const renewApiKey = async () => {
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data) const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) { if (result.success) {
showToast(t('apiKeys.renewApiKeyModal.renewSuccess'), 'success') showToast('API Key 续期成功', 'success')
emit('success') emit('success')
emit('close') emit('close')
} else { } else {
showToast(result.message || t('apiKeys.renewApiKeyModal.renewFailed'), 'error') showToast(result.message || '续期失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('apiKeys.renewApiKeyModal.renewFailed'), 'error') showToast('续期失败', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -17,7 +17,7 @@
<i class="fas fa-chart-line text-sm text-white sm:text-base" /> <i class="fas fa-chart-line text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> <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> </h3>
</div> </div>
<button class="p-1 text-gray-400 transition-colors hover:text-gray-600" @click="close"> <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" 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"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">总请求数</span>
t('apiKeys.usageDetailModal.totalRequests')
}}</span>
<i class="fas fa-paper-plane text-blue-500" /> <i class="fas fa-paper-plane text-blue-500" />
</div> </div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100"> <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatNumber(totalRequests) }} {{ formatNumber(totalRequests) }}
</div> </div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
{{ t('apiKeys.usageDetailModal.today') }}: {{ formatNumber(dailyRequests) }} 今日: {{ formatNumber(dailyRequests) }}
{{ t('apiKeys.usageDetailModal.times') }}
</div> </div>
</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" 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"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">总Token数</span>
t('apiKeys.usageDetailModal.totalTokens')
}}</span>
<i class="fas fa-coins text-green-500" /> <i class="fas fa-coins text-green-500" />
</div> </div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100"> <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(totalTokens) }} {{ formatTokenCount(totalTokens) }}
</div> </div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
{{ t('apiKeys.usageDetailModal.today') }}: {{ formatTokenCount(dailyTokens) }} 今日: {{ formatTokenCount(dailyTokens) }}
</div> </div>
</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" 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"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">总费用</span>
t('apiKeys.usageDetailModal.totalCost')
}}</span>
<i class="fas fa-dollar-sign text-yellow-600" /> <i class="fas fa-dollar-sign text-yellow-600" />
</div> </div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100"> <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
${{ totalCost.toFixed(4) }} ${{ totalCost.toFixed(4) }}
</div> </div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400"> <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>
</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" 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"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">平均速率</span>
t('apiKeys.usageDetailModal.averageRate')
}}</span>
<i class="fas fa-tachometer-alt text-purple-500" /> <i class="fas fa-tachometer-alt text-purple-500" />
</div> </div>
<div class="space-y-1 text-sm"> <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" 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" /> <i class="fas fa-chart-pie mr-2 text-indigo-500" />
{{ t('apiKeys.usageDetailModal.tokenDistribution') }} Token 使用分布
</h4> </h4>
<div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50"> <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 justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-arrow-down mr-2 text-green-500" /> <i class="fas fa-arrow-down mr-2 text-green-500" />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ <span class="text-sm text-gray-600 dark:text-gray-400">输入 Token</span>
t('apiKeys.usageDetailModal.inputTokens')
}}</span>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(inputTokens) }} {{ formatTokenCount(inputTokens) }}
@@ -130,9 +119,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-arrow-up mr-2 text-blue-500" /> <i class="fas fa-arrow-up mr-2 text-blue-500" />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ <span class="text-sm text-gray-600 dark:text-gray-400">输出 Token</span>
t('apiKeys.usageDetailModal.outputTokens')
}}</span>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(outputTokens) }} {{ formatTokenCount(outputTokens) }}
@@ -141,9 +128,7 @@
<div v-if="cacheCreateTokens > 0" class="flex items-center justify-between"> <div v-if="cacheCreateTokens > 0" class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-save mr-2 text-purple-500" /> <i class="fas fa-save mr-2 text-purple-500" />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ <span class="text-sm text-gray-600 dark:text-gray-400">缓存创建 Token</span>
t('apiKeys.usageDetailModal.cacheCreateTokens')
}}</span>
</div> </div>
<span class="text-sm font-semibold text-purple-600"> <span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheCreateTokens) }} {{ formatTokenCount(cacheCreateTokens) }}
@@ -152,9 +137,7 @@
<div v-if="cacheReadTokens > 0" class="flex items-center justify-between"> <div v-if="cacheReadTokens > 0" class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-download mr-2 text-purple-500" /> <i class="fas fa-download mr-2 text-purple-500" />
<span class="text-sm text-gray-600 dark:text-gray-400">{{ <span class="text-sm text-gray-600 dark:text-gray-400">缓存读取 Token</span>
t('apiKeys.usageDetailModal.cacheReadTokens')
}}</span>
</div> </div>
<span class="text-sm font-semibold text-purple-600"> <span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheReadTokens) }} {{ formatTokenCount(cacheReadTokens) }}
@@ -169,14 +152,12 @@
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300" 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" /> <i class="fas fa-shield-alt mr-2 text-red-500" />
{{ t('apiKeys.usageDetailModal.limitSettings') }} 限制设置
</h4> </h4>
<div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50"> <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 v-if="apiKey.dailyCostLimit > 0" class="space-y-2">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">{{ <span class="text-gray-600 dark:text-gray-400">每日费用限制</span>
t('apiKeys.usageDetailModal.dailyCostLimit')
}}</span>
<span class="font-semibold text-gray-900 dark:text-gray-100"> <span class="font-semibold text-gray-900 dark:text-gray-100">
${{ apiKey.dailyCostLimit.toFixed(2) }} ${{ apiKey.dailyCostLimit.toFixed(2) }}
</span> </span>
@@ -195,11 +176,7 @@
/> />
</div> </div>
<div class="text-right text-xs text-gray-500 dark:text-gray-400"> <div class="text-right text-xs text-gray-500 dark:text-gray-400">
{{ 已使用 {{ dailyCostPercentage.toFixed(1) }}%
t('apiKeys.usageDetailModal.usedPercentage', {
percentage: dailyCostPercentage.toFixed(1)
})
}}
</div> </div>
</div> </div>
@@ -207,9 +184,7 @@
v-if="apiKey.concurrencyLimit > 0" v-if="apiKey.concurrencyLimit > 0"
class="flex items-center justify-between text-sm" class="flex items-center justify-between text-sm"
> >
<span class="text-gray-600 dark:text-gray-400">{{ <span class="text-gray-600 dark:text-gray-400">并发限制</span>
t('apiKeys.usageDetailModal.concurrencyLimit')
}}</span>
<span class="font-semibold text-purple-600"> <span class="font-semibold text-purple-600">
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }} {{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
</span> </span>
@@ -218,14 +193,14 @@
<div v-if="apiKey.rateLimitWindow > 0" class="space-y-2"> <div v-if="apiKey.rateLimitWindow > 0" class="space-y-2">
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300"> <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-clock mr-1 text-blue-500" /> <i class="fas fa-clock mr-1 text-blue-500" />
{{ t('apiKeys.usageDetailModal.timeWindowLimit') }} 时间窗口限制
</h5> </h5>
<WindowCountdown <WindowCountdown
:cost-limit="apiKey.rateLimitCost" :cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost" :current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests" :current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens" :current-tokens="apiKey.currentWindowTokens"
:label="t('apiKeys.usageDetailModal.windowStatus')" label="窗口状态"
:rate-limit-window="apiKey.rateLimitWindow" :rate-limit-window="apiKey.rateLimitWindow"
:request-limit="apiKey.rateLimitRequests" :request-limit="apiKey.rateLimitRequests"
:show-progress="true" :show-progress="true"
@@ -243,7 +218,7 @@
<!-- 底部按钮 --> <!-- 底部按钮 -->
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3"> <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"> <button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
{{ t('apiKeys.usageDetailModal.close') }} 关闭
</button> </button>
</div> </div>
</div> </div>
@@ -253,11 +228,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WindowCountdown from './WindowCountdown.vue' import WindowCountdown from './WindowCountdown.vue'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
@@ -302,9 +274,7 @@ const dailyCostPercentage = computed(() => {
// 方法 // 方法
const formatNumber = (num) => { const formatNumber = (num) => {
if (!num && num !== 0) return '0' if (!num && num !== 0) return '0'
// 根据当前语言环境自动选择合适的地区设置 return num.toLocaleString('zh-CN')
const currentLocale = t('common.locale')
return num.toLocaleString(currentLocale)
} }
// 格式化Token数量使用K/M单位 // 格式化Token数量使用K/M单位

View File

@@ -1,29 +1,27 @@
<template> <template>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center justify-between text-xs"> <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"> <span v-if="windowState === 'active'" class="font-medium text-gray-700">
<i class="fas fa-clock mr-1 text-blue-500" /> <i class="fas fa-clock mr-1 text-blue-500" />
{{ formatTime(remainingSeconds) }} {{ formatTime(remainingSeconds) }}
</span> </span>
<span v-else-if="windowState === 'expired'" class="font-medium text-orange-600"> <span v-else-if="windowState === 'expired'" class="font-medium text-orange-600">
<i class="fas fa-sync-alt mr-1" /> <i class="fas fa-sync-alt mr-1" />
{{ t('apiKeys.windowCountdown.expired') }} 窗口已过期
</span> </span>
<span v-else-if="windowState === 'notStarted'" class="font-medium text-gray-500"> <span v-else-if="windowState === 'notStarted'" class="font-medium text-gray-500">
<i class="fas fa-pause-circle mr-1" /> <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>
<span v-else class="font-medium text-gray-400"> {{ rateLimitWindow }} 分钟 </span>
</div> </div>
<!-- 进度条仅在有限制时显示 --> <!-- 进度条仅在有限制时显示 -->
<div v-if="showProgress" class="space-y-0.5"> <div v-if="showProgress" class="space-y-0.5">
<div v-if="hasRequestLimit" class="space-y-0.5"> <div v-if="hasRequestLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs"> <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> <span class="text-gray-600"> {{ currentRequests || 0 }}/{{ requestLimit }} </span>
</div> </div>
<div class="h-1 w-full rounded-full bg-gray-200"> <div class="h-1 w-full rounded-full bg-gray-200">
@@ -38,7 +36,7 @@
<!-- Token限制向后兼容 --> <!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5"> <div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs"> <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"> <span class="text-gray-600">
{{ formatTokenCount(currentTokens || 0) }}/{{ formatTokenCount(tokenLimit) }} {{ formatTokenCount(currentTokens || 0) }}/{{ formatTokenCount(tokenLimit) }}
</span> </span>
@@ -55,7 +53,7 @@
<!-- 费用限制新功能 --> <!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5"> <div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs"> <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"> <span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }} ${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span> </span>
@@ -73,29 +71,22 @@
<!-- 额外提示信息 --> <!-- 额外提示信息 -->
<div v-if="windowState === 'active' && showTooltip" class="text-xs text-gray-500"> <div v-if="windowState === 'active' && showTooltip" class="text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" /> <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" <span v-else-if="remainingSeconds < 300"
>{{ Math.ceil(remainingSeconds / 60) }} >{{ Math.ceil(remainingSeconds / 60) }} 分钟后重置</span
{{ t('apiKeys.windowCountdown.minutesUntilReset') }}</span
>
<span v-else
>{{ formatDetailedTime(remainingSeconds)
}}{{ t('apiKeys.windowCountdown.untilReset') }}</span
> >
<span v-else>{{ formatDetailedTime(remainingSeconds) }}后重置</span>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
default: '' default: '窗口限制'
}, },
rateLimitWindow: { rateLimitWindow: {
type: Number, type: Number,
@@ -152,10 +143,6 @@ const remainingSeconds = ref(props.windowRemainingSeconds)
let intervalId = null let intervalId = null
// 计算属性 // 计算属性
const displayLabel = computed(() => {
return props.label || t('apiKeys.windowCountdown.windowLimit')
})
const windowState = computed(() => { const windowState = computed(() => {
if (props.windowStartTime === null) { if (props.windowStartTime === null) {
return 'notStarted' // 窗口未开始 return 'notStarted' // 窗口未开始
@@ -195,9 +182,9 @@ const formatDetailedTime = (seconds) => {
const minutes = Math.floor((seconds % 3600) / 60) const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) { if (hours > 0) {
return `${hours}${t('apiKeys.windowCountdown.hours')}${minutes}${t('apiKeys.windowCountdown.minutes')}` return `${hours}小时${minutes}分钟`
} else { } else {
return `${minutes}${t('apiKeys.windowCountdown.minutes')}` return `${minutes}分钟`
} }
} }

View File

@@ -5,10 +5,10 @@
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" /> <i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
{{ t('apiStats.usageRatio') }} 使用占比
</span> </span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm" <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> </h3>
@@ -33,9 +33,7 @@
<div <div
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400" class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
> >
<span <span>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}</span>
>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}{{ t('apiStats.requests') }}</span
>
<span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span> <span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span>
</div> </div>
</div> </div>
@@ -43,10 +41,7 @@
<!-- 其他Keys汇总 --> <!-- 其他Keys汇总 -->
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700"> <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"> <div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span <span>其他 {{ otherKeysCount }} 个Keys</span>
>{{ t('apiStats.otherKeys') }} {{ otherKeysCount }} {{ t('apiStats.individual')
}}{{ t('apiStats.keys') }}</span
>
<span>{{ otherPercentage }}%</span> <span>{{ otherPercentage }}%</span>
</div> </div>
</div> </div>
@@ -59,7 +54,7 @@
> >
<div class="text-center"> <div class="text-center">
<i class="fas fa-chart-pie mb-2 text-2xl" /> <i class="fas fa-chart-pie mb-2 text-2xl" />
<p>{{ t('apiStats.usageRatioOnlyInMultiMode') }}</p> <p>使用占比仅在多Key查询时显示</p>
</div> </div>
</div> </div>
@@ -68,7 +63,7 @@
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400" 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" /> <i class="fas fa-chart-pie mr-2" />
{{ t('apiStats.noData') }} 暂无数据
</div> </div>
</div> </div>
</template> </template>
@@ -76,11 +71,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore) const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)

View File

@@ -4,11 +4,9 @@
<div class="wide-card-title mb-6"> <div class="wide-card-title mb-6">
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200"> <h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200">
<i class="fas fa-chart-line mr-3" /> <i class="fas fa-chart-line mr-3" />
{{ t('apiStats.usageStatsQuery') }} 使用统计查询
</h2> </h2>
<p class="text-base text-gray-600 dark:text-gray-400"> <p class="text-base text-gray-600 dark:text-gray-400">查询您的 API Key 使用情况和统计数据</p>
{{ t('apiStats.apiKeyDescription') }}
</p>
</div> </div>
<!-- 输入区域 --> <!-- 输入区域 -->
@@ -18,7 +16,7 @@
<!-- API Key 标签 --> <!-- API Key 标签 -->
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-key mr-2" /> <i class="fas fa-key mr-2" />
{{ multiKeyMode ? t('apiStats.enterApiKeys') : t('apiStats.enterApiKey') }} {{ multiKeyMode ? '输入您的 API Keys每行一个或用逗号分隔' : '输入您的 API Key' }}
</label> </label>
<!-- 模式切换和查询按钮组 --> <!-- 模式切换和查询按钮组 -->
@@ -30,20 +28,20 @@
<button <button
class="mode-switch-btn" class="mode-switch-btn"
:class="{ active: !multiKeyMode }" :class="{ active: !multiKeyMode }"
:title="t('apiStats.singleModeTitle')" title="单一模式"
@click="multiKeyMode = false" @click="multiKeyMode = false"
> >
<i class="fas fa-key" /> <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>
<button <button
class="mode-switch-btn" class="mode-switch-btn"
:class="{ active: multiKeyMode }" :class="{ active: multiKeyMode }"
:title="t('apiStats.aggregateModeTitle')" title="聚合模式"
@click="multiKeyMode = true" @click="multiKeyMode = true"
> >
<i class="fas fa-layer-group" /> <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 <span
v-if="multiKeyMode && parsedApiKeys.length > 0" v-if="multiKeyMode && parsedApiKeys.length > 0"
class="ml-1 rounded-full bg-white/20 px-1.5 py-0.5 text-xs font-semibold" 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" v-model="apiKey"
class="wide-card-input w-full" class="wide-card-input w-full"
:disabled="loading" :disabled="loading"
:placeholder="t('apiStats.apiKeyPlaceholder')" placeholder="请输入您的 API Key (cr_...)"
type="password" type="password"
@keyup.enter="queryStats" @keyup.enter="queryStats"
/> />
@@ -75,14 +73,14 @@
v-model="apiKey" v-model="apiKey"
class="wide-card-input w-full resize-y" class="wide-card-input w-full resize-y"
:disabled="loading" :disabled="loading"
:placeholder="t('apiStats.apiKeysPlaceholder')" placeholder="请输入您的 API Keys支持以下格式&#10;cr_xxx&#10;cr_yyy&#10;或&#10;cr_xxx, cr_yyy"
rows="4" rows="4"
@keyup.ctrl.enter="queryStats" @keyup.ctrl.enter="queryStats"
/> />
<button <button
v-if="apiKey && !loading" 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" 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" @click="clearInput"
> >
<i class="fas fa-times-circle" /> <i class="fas fa-times-circle" />
@@ -99,7 +97,7 @@
> >
<i v-if="loading" class="fas fa-spinner loading-spinner" /> <i v-if="loading" class="fas fa-spinner loading-spinner" />
<i v-else class="fas fa-search" /> <i v-else class="fas fa-search" />
{{ loading ? t('common.loading') : t('apiStats.queryButton') }} {{ loading ? '查询中...' : '查询统计' }}
</button> </button>
</div> </div>
</div> </div>
@@ -107,7 +105,11 @@
<!-- 安全提示 --> <!-- 安全提示 -->
<div class="security-notice mt-4"> <div class="security-notice mt-4">
<i class="fas fa-shield-alt mr-2" /> <i class="fas fa-shield-alt mr-2" />
{{ multiKeyMode ? t('apiStats.securityNoticeMulti') : t('apiStats.securityNoticeSingle') }} {{
multiKeyMode
? '您的 API Keys 仅用于查询统计数据,不会被存储。聚合模式下部分个体化信息将不显示。'
: '您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途'
}}
</div> </div>
<!-- Key 模式额外提示 --> <!-- 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" 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" /> <i class="fas fa-lightbulb mr-2" />
<span>{{ t('apiStats.multiKeyTip') }}</span> <span>提示最多支持同时查询 30 API Keys使用 Ctrl+Enter 快速查询</span>
</div> </div>
</div> </div>
</div> </div>
@@ -125,11 +127,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore) const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore)
const { queryStats, clearInput } = 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" 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" /> <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> </h3>
<!-- Key 模式下的聚合统计信息 --> <!-- Key 模式下的聚合统计信息 -->
@@ -18,7 +18,7 @@
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-layer-group mr-2 text-blue-500" /> <i class="fas fa-layer-group mr-2 text-blue-500" />
{{ t('apiStats.apiKeysOverview') }} API Keys 概况
</span> </span>
<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" 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"> <div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ aggregatedStats.totalKeys }} {{ aggregatedStats.totalKeys }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400"> <div class="text-xs text-gray-600 dark:text-gray-400">总计 Keys</div>
{{ t('apiStats.totalKeys') }}
</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-lg font-bold text-green-600"> <div class="text-lg font-bold text-green-600">
{{ aggregatedStats.activeKeys }} {{ aggregatedStats.activeKeys }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400"> <div class="text-xs text-gray-600 dark:text-gray-400">激活 Keys</div>
{{ t('apiStats.activeKeys') }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -52,15 +48,13 @@
> >
<div class="mb-3 flex items-center"> <div class="mb-3 flex items-center">
<i class="fas fa-chart-pie mr-2 text-purple-500" /> <i class="fas fa-chart-pie mr-2 text-purple-500" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">聚合统计摘要</span>
t('apiStats.aggregateStatsSummary')
}}</span>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-database mr-1 text-gray-400" /> <i class="fas fa-database mr-1 text-gray-400" />
{{ t('apiStats.totalRequests') }} 总请求数
</span> </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(aggregatedStats.usage.requests) }} {{ formatNumber(aggregatedStats.usage.requests) }}
@@ -69,7 +63,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-coins mr-1 text-yellow-500" /> <i class="fas fa-coins mr-1 text-yellow-500" />
{{ t('apiStats.totalTokens') }} Tokens
</span> </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(aggregatedStats.usage.allTokens) }} {{ formatNumber(aggregatedStats.usage.allTokens) }}
@@ -78,7 +72,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-dollar-sign mr-1 text-green-500" /> <i class="fas fa-dollar-sign mr-1 text-green-500" />
{{ t('apiStats.totalCost') }} 总费用
</span> </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ aggregatedStats.usage.formattedCost }} {{ aggregatedStats.usage.formattedCost }}
@@ -94,7 +88,7 @@
> >
<i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" /> <i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" />
<span class="text-red-700 dark:text-red-300"> <span class="text-red-700 dark:text-red-300">
{{ t('apiStats.invalidKeysCount', { count: invalidKeys.length }) }} {{ invalidKeys.length }} 个无效的 API Key
</span> </span>
</div> </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" 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" /> <i class="fas fa-info-circle mr-1" />
{{ t('apiStats.aggregateStatsNote') }} 每个 API Key 有独立的限制设置聚合模式下不显示单个限制配置
</div> </div>
</div> </div>
@@ -112,9 +106,9 @@
<!-- 每日费用限制 --> <!-- 每日费用限制 -->
<div> <div>
<div class="mb-2 flex items-center justify-between"> <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">{{ <span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
t('apiStats.dailyCostLimit') >每日费用限制</span
}}</span> >
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm"> <span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
<span v-if="statsData.limits.dailyCostLimit > 0"> <span v-if="statsData.limits.dailyCostLimit > 0">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{ ${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{
@@ -155,7 +149,7 @@
:current-cost="statsData.limits.currentWindowCost" :current-cost="statsData.limits.currentWindowCost"
:current-requests="statsData.limits.currentWindowRequests" :current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens" :current-tokens="statsData.limits.currentWindowTokens"
:label="t('apiStats.timeWindowLimit')" label="时间窗口限制"
:rate-limit-window="statsData.limits.rateLimitWindow" :rate-limit-window="statsData.limits.rateLimitWindow"
:request-limit="statsData.limits.rateLimitRequests" :request-limit="statsData.limits.rateLimitRequests"
:show-progress="true" :show-progress="true"
@@ -169,21 +163,19 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
<span v-if="statsData.limits.rateLimitCost > 0"> <span v-if="statsData.limits.rateLimitCost > 0">
{{ t('apiStats.orRelationshipRequests') }} 请求次数和费用限制为"或"的关系,任一达到限制即触发限流
</span> </span>
<span v-else-if="statsData.limits.tokenLimit > 0"> <span v-else-if="statsData.limits.tokenLimit > 0">
{{ t('apiStats.orRelationshipTokens') }} 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</span> </span>
<span v-else>{{ t('apiStats.onlyRequestsLimit') }}</span> <span v-else> 仅限制请求次数 </span>
</div> </div>
</div> </div>
<!-- 其他限制信息 --> <!-- 其他限制信息 -->
<div class="space-y-2 border-t border-gray-100 pt-2 dark:border-gray-700"> <div class="space-y-2 border-t border-gray-100 pt-2 dark:border-gray-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">并发限制</span>
t('apiStats.concurrencyLimit')
}}</span>
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm font-medium text-gray-900 md:text-base">
<span v-if="statsData.limits.concurrencyLimit > 0"> <span v-if="statsData.limits.concurrencyLimit > 0">
{{ statsData.limits.concurrencyLimit }} {{ statsData.limits.concurrencyLimit }}
@@ -194,9 +186,7 @@
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">模型限制</span>
t('apiStats.modelLimit')
}}</span>
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm font-medium text-gray-900 md:text-base">
<span <span
v-if=" v-if="
@@ -206,22 +196,16 @@
class="text-orange-600" class="text-orange-600"
> >
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" /> <i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
{{ 限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
t('apiStats.restrictedModelsCount', {
count: statsData.restrictions.restrictedModels.length
})
}}
</span> </span>
<span v-else class="text-green-600"> <span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" /> <i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
{{ t('apiStats.allowAllModels') }} 允许所有模型
</span> </span>
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
t('apiStats.clientLimit')
}}</span>
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm font-medium text-gray-900 md:text-base">
<span <span
v-if=" v-if="
@@ -231,15 +215,11 @@
class="text-orange-600" class="text-orange-600"
> >
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" /> <i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
{{ 限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
t('apiStats.restrictedClientsCount', {
count: statsData.restrictions.allowedClients.length
})
}}
</span> </span>
<span v-else class="text-green-600"> <span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" /> <i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
{{ t('apiStats.allowAllClients') }} 允许所有客户端
</span> </span>
</span> </span>
</div> </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" 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" /> <i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
{{ t('apiStats.detailedLimitInfo') }} 详细限制信息
</h3> </h3>
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2"> <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" 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" /> <i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
{{ t('apiStats.restrictedModelsList') }} 受限模型列表
</h4> </h4>
<div class="space-y-1 md:space-y-2"> <div class="space-y-1 md:space-y-2">
<div <div
@@ -291,7 +271,7 @@
</div> </div>
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3"> <p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
{{ t('apiStats.restrictedModelsNote') }} 此 API Key 不能访问以上列出的模型
</p> </p>
</div> </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" 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" /> <i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
{{ t('apiStats.allowedClientsList') }} 允许的客户端
</h4> </h4>
<div class="space-y-1 md:space-y-2"> <div class="space-y-1 md:space-y-2">
<div <div
@@ -321,7 +301,7 @@
</div> </div>
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400 md:mt-3"> <p class="mt-2 text-xs text-blue-700 dark:text-blue-400 md:mt-3">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
{{ t('apiStats.allowedClientsNote') }} API Key 只能被以上列出的客户端使用
</p> </p>
</div> </div>
</div> </div>
@@ -331,12 +311,9 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue' import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore) const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)

View File

@@ -6,10 +6,10 @@
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" /> <i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
{{ t('apiStats.modelUsageStats') }} 模型使用统计
</span> </span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm" <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> </h3>
</div> </div>
@@ -19,9 +19,7 @@
<i <i
class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl" 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"> <p class="text-sm text-gray-600 dark:text-gray-400 md:text-base">加载模型统计数据中...</p>
{{ t('apiStats.loadingModelStats') }}
</p>
</div> </div>
<!-- 模型统计数据 --> <!-- 模型统计数据 -->
@@ -33,42 +31,38 @@
{{ model.model }} {{ model.model }}
</h4> </h4>
<p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> <p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ model.requests }}{{ t('apiStats.requestCount') }} {{ model.requests }} 次请求
</p> </p>
</div> </div>
<div class="ml-3 flex-shrink-0 text-right"> <div class="ml-3 flex-shrink-0 text-right">
<div class="text-base font-bold text-green-600 md:text-lg"> <div class="text-base font-bold text-green-600 md:text-lg">
{{ model.formatted?.total || '$0.000000' }} {{ model.formatted?.total || '$0.000000' }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">总费用</div>
{{ t('apiStats.totalCost') }}
</div>
</div> </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="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="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"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.inputTokens) }} {{ formatNumber(model.inputTokens) }}
</div> </div>
</div> </div>
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700"> <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"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.outputTokens) }} {{ formatNumber(model.outputTokens) }}
</div> </div>
</div> </div>
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700"> <div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600 dark:text-gray-400"> <div class="text-gray-600 dark:text-gray-400">缓存创建</div>
{{ t('apiStats.cacheCreateTokens') }}
</div>
<div class="font-medium text-gray-900 dark:text-gray-100"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.cacheCreateTokens) }} {{ formatNumber(model.cacheCreateTokens) }}
</div> </div>
</div> </div>
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700"> <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"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.cacheReadTokens) }} {{ formatNumber(model.cacheReadTokens) }}
</div> </div>
@@ -81,11 +75,7 @@
<div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 md:py-8"> <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" /> <i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
<p class="text-sm md:text-base"> <p class="text-sm md:text-base">
{{ 暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
t('apiStats.noModelData', {
period: statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth')
})
}}
</p> </p>
</div> </div>
</div> </div>
@@ -93,11 +83,8 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore) 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 ? '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> </h3>
<!-- Key 模式下的概要信息 --> <!-- Key 模式下的概要信息 -->
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3"> <div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys </span>
t('apiStats.queryKeysCount')
}}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ aggregatedStats.totalKeys }} {{ t('apiStats.individual') }} {{ aggregatedStats.totalKeys }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys </span>
t('apiStats.activeKeysCount')
}}</span>
<span class="text-sm font-medium text-green-600 md:text-base"> <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" /> <i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
{{ aggregatedStats.activeKeys }} {{ t('apiStats.individual') }} {{ aggregatedStats.activeKeys }}
</span> </span>
</div> </div>
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between"> <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">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys </span>
t('apiStats.invalidKeysCount')
}}</span>
<span class="text-sm font-medium text-red-600 md:text-base"> <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" /> <i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
{{ invalidKeys.length }} {{ t('apiStats.individual') }} {{ invalidKeys.length }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
t('apiStats.totalRequests')
}}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ formatNumber(aggregatedStats.usage.requests) }} {{ formatNumber(aggregatedStats.usage.requests) }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base"> Token </span>
t('apiStats.totalTokens')
}}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
{{ formatNumber(aggregatedStats.usage.allTokens) }} {{ formatNumber(aggregatedStats.usage.allTokens) }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
t('apiStats.totalCost')
}}</span>
<span class="text-sm font-medium text-indigo-600 md:text-base"> <span class="text-sm font-medium text-indigo-600 md:text-base">
{{ aggregatedStats.usage.formattedCost }} {{ aggregatedStats.usage.formattedCost }}
</span> </span>
@@ -72,9 +60,7 @@
v-if="individualStats.length > 1" v-if="individualStats.length > 1"
class="border-t border-gray-200 pt-2 dark:border-gray-700" 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"> <div class="mb-2 text-xs text-gray-500 dark:text-gray-400"> Key 贡献占比</div>
{{ t('apiStats.keyContribution') }}
</div>
<div class="space-y-1"> <div class="space-y-1">
<div <div
v-for="stat in topContributors" v-for="stat in topContributors"
@@ -93,18 +79,14 @@
<!-- Key 模式下的详细信息 --> <!-- Key 模式下的详细信息 -->
<div v-else class="space-y-2 md:space-y-3"> <div v-else class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
t('apiStats.name')
}}</span>
<span <span
class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base" class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"
>{{ statsData.name }}</span >{{ statsData.name }}</span
> >
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">状态</span>
t('apiStats.status')
}}</span>
<span <span
class="text-sm font-medium md:text-base" class="text-sm font-medium md:text-base"
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'" :class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
@@ -113,39 +95,35 @@
class="mr-1 text-xs md:text-sm" class="mr-1 text-xs md:text-sm"
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'" :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
/> />
{{ statsData.isActive ? t('apiStats.active') : t('apiStats.inactive') }} {{ statsData.isActive ? '活跃' : '已停用' }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">权限</span>
t('apiStats.permissions')
}}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatPermissions(statsData.permissions) formatPermissions(statsData.permissions)
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{ <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">创建时间</span>
t('apiStats.createdAt')
}}</span>
<span <span
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base" class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
>{{ formatDate(statsData.createdAt) }}</span >{{ formatDate(statsData.createdAt) }}</span
> >
</div> </div>
<div class="flex items-start justify-between"> <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">{{ <span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
t('apiStats.expiresAt') >过期时间</span
}}</span> >
<!-- 未激活状态 --> <!-- 未激活状态 -->
<div <div
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated" v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base" 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" /> <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" <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> </div>
<!-- 已设置过期时间 --> <!-- 已设置过期时间 -->
@@ -155,7 +133,7 @@
class="text-sm font-medium text-red-600 md:text-base" class="text-sm font-medium text-red-600 md:text-base"
> >
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" /> <i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
{{ t('apiStats.expired') }} 已过期
</div> </div>
<div <div
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)" 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"> <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" /> <i class="fas fa-infinity mr-1 text-xs md:text-sm" />
{{ t('apiStats.neverExpires') }} 永不过期
</div> </div>
</div> </div>
</div> </div>
@@ -187,10 +165,10 @@
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" /> <i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
{{ t('apiStats.usageStatsOverview') }} 使用统计概览
</span> </span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm" <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> </h3>
<div class="grid grid-cols-2 gap-3 md:gap-4"> <div class="grid grid-cols-2 gap-3 md:gap-4">
@@ -199,9 +177,7 @@
{{ formatNumber(currentPeriodData.requests) }} {{ formatNumber(currentPeriodData.requests) }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ {{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
statsPeriod === 'daily' ? t('apiStats.todayRequests') : t('apiStats.monthlyRequests')
}}
</div> </div>
</div> </div>
<div class="stat-card text-center"> <div class="stat-card text-center">
@@ -209,7 +185,7 @@
{{ formatNumber(currentPeriodData.allTokens) }} {{ formatNumber(currentPeriodData.allTokens) }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> <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> </div>
<div class="stat-card text-center"> <div class="stat-card text-center">
@@ -217,7 +193,7 @@
{{ currentPeriodData.formattedCost || '$0.000000' }} {{ currentPeriodData.formattedCost || '$0.000000' }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> <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> </div>
<div class="stat-card text-center"> <div class="stat-card text-center">
@@ -225,11 +201,7 @@
{{ formatNumber(currentPeriodData.inputTokens) }} {{ formatNumber(currentPeriodData.inputTokens) }}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ {{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
statsPeriod === 'daily'
? t('apiStats.todayInputTokens')
: t('apiStats.monthlyInputTokens')
}}
</div> </div>
</div> </div>
</div> </div>
@@ -241,12 +213,9 @@
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { const {
statsData, statsData,
@@ -276,21 +245,13 @@ const calculateContribution = (stat) => {
// 格式化日期 // 格式化日期
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return t('apiStats.none') if (!dateString) return '无'
try { try {
const date = dayjs(dateString) 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') return date.format('YYYY年MM月DD日 HH:mm')
} else {
return date.format('YYYY年MM月DD日 HH:mm')
}
} catch (error) { } catch (error) {
return t('apiStats.formatError') return '格式错误'
} }
} }
@@ -345,10 +306,10 @@ const formatPermissions = (permissions) => {
const permissionMap = { const permissionMap = {
claude: 'Claude', claude: 'Claude',
gemini: 'Gemini', gemini: 'Gemini',
all: t('apiStats.allModels') all: '全部模型'
} }
return permissionMap[permissions] || permissions || t('apiStats.unknown') return permissionMap[permissions] || permissions || '未知'
} }
</script> </script>

View File

@@ -5,17 +5,17 @@
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" /> <i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
{{ t('apiStats.tokenDistribution') }} Token 使用分布
</span> </span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm" <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> </h3>
<div class="space-y-2 md:space-y-3"> <div class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base"> <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" /> <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>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.inputTokens) formatNumber(currentPeriodData.inputTokens)
@@ -24,7 +24,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base"> <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" /> <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>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.outputTokens) formatNumber(currentPeriodData.outputTokens)
@@ -33,7 +33,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base"> <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" /> <i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" />
{{ t('apiStats.cacheCreateToken') }} 缓存创建 Token
</span> </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.cacheCreateTokens) formatNumber(currentPeriodData.cacheCreateTokens)
@@ -42,7 +42,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base"> <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" /> <i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" />
{{ t('apiStats.cacheReadToken') }} 缓存读取 Token
</span> </span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.cacheReadTokens) formatNumber(currentPeriodData.cacheReadTokens)
@@ -51,9 +51,9 @@
</div> </div>
<div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700 md:mt-4 md:pt-4"> <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"> <div class="flex items-center justify-between font-bold text-gray-900 dark:text-gray-100">
<span class="text-sm md:text-base">{{ <span class="text-sm md:text-base"
statsPeriod === 'daily' ? t('apiStats.todayTotal') : t('apiStats.monthlyTotal') >{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
}}</span> >
<span class="text-lg md:text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span> <span class="text-lg md:text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
</div> </div>
</div> </div>
@@ -62,11 +62,8 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore) const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)

View File

@@ -41,7 +41,7 @@
ref="searchInput" ref="searchInput"
v-model="searchQuery" 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" 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" style="padding-left: 40px; padding-right: 36px"
type="text" type="text"
@input="handleSearch" @input="handleSearch"
@@ -68,9 +68,7 @@
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }" :class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
@click="selectAccount(null)" @click="selectAccount(null)"
> >
<span class="text-gray-700 dark:text-gray-300">{{ <span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
props.defaultOptionText || $t('common.accountSelector.useSharedPool')
}}</span>
</div> </div>
<!-- 分组选项 --> <!-- 分组选项 -->
@@ -78,7 +76,7 @@
<div <div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400" 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>
<div <div
v-for="group in filteredGroups" v-for="group in filteredGroups"
@@ -90,8 +88,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span> <span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400" <span class="text-xs text-gray-500 dark:text-gray-400"
>{{ group.memberCount || 0 >{{ group.memberCount || 0 }} 个成员</span
}}{{ $t('common.accountSelector.membersUnit') }}</span
> >
</div> </div>
</div> </div>
@@ -104,8 +101,10 @@
> >
{{ {{
platform === 'claude' platform === 'claude'
? $t('common.accountSelector.claudeOAuthAccounts') ? 'Claude OAuth 专属账号'
: $t('common.accountSelector.oauthAccounts') : platform === 'openai'
? 'OpenAI 专属账号'
: 'OAuth 专属账号'
}} }}
</div> </div>
<div <div
@@ -143,7 +142,7 @@
<div <div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400" 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>
<div <div
v-for="account in filteredConsoleAccounts" v-for="account in filteredConsoleAccounts"
@@ -177,13 +176,52 @@
</div> </div>
</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 <div
v-if="searchQuery && !hasResults" v-if="searchQuery && !hasResults"
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
> >
<i class="fas fa-search mb-2 text-2xl" /> <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> </div>
</div> </div>
@@ -194,9 +232,6 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t: $t } = useI18n()
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -206,7 +241,7 @@ const props = defineProps({
platform: { platform: {
type: String, type: String,
required: true, required: true,
validator: (value) => ['claude', 'gemini'].includes(value) validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
}, },
accounts: { accounts: {
type: Array, type: Array,
@@ -222,11 +257,11 @@ const props = defineProps({
}, },
placeholder: { placeholder: {
type: String, type: String,
default: null default: '请选择账号'
}, },
defaultOptionText: { defaultOptionText: {
type: String, type: String,
default: null default: '使用共享账号池'
} }
}) })
@@ -243,16 +278,13 @@ const lastDirection = ref('') // 记住上次的显示方向
// 获取选中的标签 // 获取选中的标签
const selectedLabel = computed(() => { const selectedLabel = computed(() => {
// 如果没有选中值,显示默认选项文本 // 如果没有选中值,显示默认选项文本
if (!props.modelValue) if (!props.modelValue) return props.defaultOptionText
return props.defaultOptionText || $t('common.accountSelector.useSharedPool')
// 分组 // 分组
if (props.modelValue.startsWith('group:')) { if (props.modelValue.startsWith('group:')) {
const groupId = props.modelValue.substring(6) const groupId = props.modelValue.substring(6)
const group = props.groups.find((g) => g.id === groupId) const group = props.groups.find((g) => g.id === groupId)
return group return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
? `${group.name} (${group.memberCount || 0}${$t('common.accountSelector.membersUnit')})`
: ''
} }
// Console 账号 // Console 账号
@@ -264,6 +296,15 @@ const selectedLabel = computed(() => {
return account ? `${account.name} (${getAccountStatusText(account)})` : '' 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 账号 // OAuth 账号
const account = props.accounts.find((a) => a.id === props.modelValue) const account = props.accounts.find((a) => a.id === props.modelValue)
return account ? `${account.name} (${getAccountStatusText(account)})` : '' return account ? `${account.name} (${getAccountStatusText(account)})` : ''
@@ -271,26 +312,36 @@ const selectedLabel = computed(() => {
// 获取账户状态文本 // 获取账户状态文本
const getAccountStatusText = (account) => { 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 判断 // 优先使用 isActive 判断
if (account.isActive === false) { if (!isActive) {
// 根据 status 提供更详细的状态信息 // 根据 status 提供更详细的状态信息
switch (account.status) { switch (account.status) {
case 'unauthorized': case 'unauthorized':
return $t('common.accountSelector.accountStatus.unauthorized') return '未授权'
case 'error': case 'error':
return $t('common.accountSelector.accountStatus.tokenError') return 'Token错误'
case 'created': case 'created':
return $t('common.accountSelector.accountStatus.pending') return '待验证'
case 'rate_limited': case 'rate_limited':
return $t('common.accountSelector.accountStatus.rateLimited') return '限流中'
case 'quota_exceeded':
return '额度超限'
default: 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(() => { const filteredGroups = computed(() => {
if (!searchQuery.value) return props.groups // 只显示与当前平台匹配的分组
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() const query = searchQuery.value.toLowerCase()
return props.groups.filter((group) => group.name.toLowerCase().includes(query)) groups = groups.filter((group) => group.name.toLowerCase().includes(query))
}
return groups
}) })
// 过滤的 OAuth 账号 // 过滤的 OAuth 账号
const filteredOAuthAccounts = computed(() => { const filteredOAuthAccounts = computed(() => {
let accounts = sortedAccounts.value.filter((a) => let accounts = []
props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console'
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) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
@@ -337,12 +412,27 @@ const filteredConsoleAccounts = computed(() => {
return accounts 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(() => { const hasResults = computed(() => {
return ( return (
filteredGroups.value.length > 0 || filteredGroups.value.length > 0 ||
filteredOAuthAccounts.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) const diffInHours = (now - date) / (1000 * 60 * 60)
if (diffInHours < 24) { if (diffInHours < 24) {
return $t('common.accountSelector.dateFormat.today') return '今天创建'
} else if (diffInHours < 48) { } else if (diffInHours < 48) {
return $t('common.accountSelector.dateFormat.yesterday') return '昨天创建'
} else if (diffInHours < 168) { } else if (diffInHours < 168) {
// 7天内 // 7天内
return `${Math.floor(diffInHours / 24)}${$t('common.accountSelector.dateFormat.daysAgo')}` return `${Math.floor(diffInHours / 24)} 天前`
} else { } else {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
} }

View File

@@ -49,26 +49,22 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 状态 // 状态
const isVisible = ref(false) const isVisible = ref(false)
const isProcessing = ref(false) const isProcessing = ref(false)
const title = ref('') const title = ref('')
const message = ref('') const message = ref('')
const confirmText = ref(t('common.confirmDialog.confirm')) const confirmText = ref('确认')
const cancelText = ref(t('common.confirmDialog.cancel')) const cancelText = ref('取消')
let resolvePromise = null let resolvePromise = null
// 显示确认对话框 // 显示确认对话框
const showConfirm = ( const showConfirm = (
titleText, titleText,
messageText, messageText,
confirmTextParam = t('common.confirmDialog.confirm'), confirmTextParam = '确认',
cancelTextParam = t('common.confirmDialog.cancel') cancelTextParam = '取消'
) => { ) => {
return new Promise((resolve) => { return new Promise((resolve) => {
title.value = titleText 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" 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')" @click="$emit('cancel')"
> >
{{ cancelLabel }} {{ cancelText }}
</button> </button>
<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" 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')" @click="$emit('confirm')"
> >
{{ confirmLabel }} {{ confirmText }}
</button> </button>
</div> </div>
</div> </div>
@@ -40,12 +40,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' defineProps({
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
required: true required: true
@@ -60,17 +55,14 @@ const props = defineProps({
}, },
confirmText: { confirmText: {
type: String, type: String,
default: '' default: '继续'
}, },
cancelText: { cancelText: {
type: String, 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']) defineEmits(['confirm', 'cancel'])
</script> </script>

View File

@@ -11,7 +11,7 @@
<span <span
class="select-none whitespace-nowrap text-sm font-medium text-gray-700 dark:text-gray-200" class="select-none whitespace-nowrap text-sm font-medium text-gray-700 dark:text-gray-200"
> >
{{ selectedLabel || placeholderText }} {{ selectedLabel || placeholder }}
</span> </span>
<i <i
:class="[ :class="[
@@ -65,9 +65,6 @@
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -80,7 +77,7 @@ const props = defineProps({
}, },
placeholder: { placeholder: {
type: String, type: String,
default: '' default: '请选择'
}, },
icon: { icon: {
type: String, type: String,
@@ -99,8 +96,6 @@ const triggerRef = ref(null)
const dropdownRef = ref(null) const dropdownRef = ref(null)
const dropdownStyle = ref({}) const dropdownStyle = ref({})
const placeholderText = computed(() => props.placeholder || t('common.customDropdown.placeholder'))
const selectedLabel = computed(() => { const selectedLabel = computed(() => {
const selected = props.options.find((opt) => opt.value === props.modelValue) const selected = props.options.find((opt) => opt.value === props.modelValue)
return selected ? selected.label : '' 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"> <template v-if="!loading">
<img <img
v-if="logoSrc" v-if="logoSrc"
:alt="$t('common.logoTitle.logoAlt')" alt="Logo"
class="h-8 w-8 object-contain" class="h-8 w-8 object-contain"
:src="logoSrc" :src="logoSrc"
@error="handleLogoError" @error="handleLogoError"

View File

@@ -69,7 +69,6 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useI18n } from 'vue-i18n'
// Props // Props
defineProps({ defineProps({
@@ -89,37 +88,32 @@ defineProps({
// Store // Store
const themeStore = useThemeStore() const themeStore = useThemeStore()
// i18n
const { t } = useI18n()
// 主题选项配置 // 主题选项配置
const themeOptions = computed(() => [ const themeOptions = [
{ {
value: 'light', value: 'light',
label: t('common.themeToggle.light.label'), label: '浅色模式',
shortLabel: t('common.themeToggle.light.shortLabel'), shortLabel: '浅色',
icon: 'fas fa-sun' icon: 'fas fa-sun'
}, },
{ {
value: 'dark', value: 'dark',
label: t('common.themeToggle.dark.label'), label: '深色模式',
shortLabel: t('common.themeToggle.dark.shortLabel'), shortLabel: '深色',
icon: 'fas fa-moon' icon: 'fas fa-moon'
}, },
{ {
value: 'auto', value: 'auto',
label: t('common.themeToggle.auto.label'), label: '跟随系统',
shortLabel: t('common.themeToggle.auto.shortLabel'), shortLabel: '自动',
icon: 'fas fa-circle-half-stroke' icon: 'fas fa-circle-half-stroke'
} }
]) ]
// 计算属性 // 计算属性
const themeTooltip = computed(() => { const themeTooltip = computed(() => {
const current = themeOptions.value.find((opt) => opt.value === themeStore.themeMode) const current = themeOptions.find((opt) => opt.value === themeStore.themeMode)
return current return current ? `点击切换主题 - ${current.label}` : '切换主题'
? `${t('common.themeToggle.clickToSwitch')} - ${current.label}`
: t('common.themeToggle.toggleTheme')
}) })
// 方法 // 方法

View File

@@ -12,8 +12,8 @@
<i :class="getIconClass(toast.type)" /> <i :class="getIconClass(toast.type)" />
</div> </div>
<div class="toast-body"> <div class="toast-body">
<div v-if="toast.title || getDefaultTitle(toast.type)" class="toast-title"> <div v-if="toast.title" class="toast-title">
{{ toast.title || getDefaultTitle(toast.type) }} {{ toast.title }}
</div> </div>
<div class="toast-message"> <div class="toast-message">
{{ toast.message }} {{ toast.message }}
@@ -35,9 +35,6 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 状态 // 状态
const toasts = ref([]) const toasts = ref([])
@@ -54,11 +51,6 @@ const getIconClass = (type) => {
return iconMap[type] || iconMap.info return iconMap[type] || iconMap.info
} }
// 获取默认标题
const getDefaultTitle = (type) => {
return t(`common.toastNotification.defaultTitles.${type}`)
}
// 添加Toast // 添加Toast
const addToast = (message, type = 'info', title = null, duration = 5000) => { const addToast = (message, type = 'info', title = null, duration = 5000) => {
const id = ++toastIdCounter 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"> <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"> <h2 class="flex items-center text-xl font-bold text-gray-800">
<i class="fas fa-robot mr-2 text-purple-500" /> <i class="fas fa-robot mr-2 text-purple-500" />
{{ $t('dashboard.modelDistribution.title') }} 模型使用分布
</h2> </h2>
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange"> <el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
<el-radio-button label="daily"> <el-radio-button label="daily"> 今日 </el-radio-button>
{{ $t('dashboard.modelDistribution.periods.daily') }} <el-radio-button label="total"> 累计 </el-radio-button>
</el-radio-button>
<el-radio-button label="total">
{{ $t('dashboard.modelDistribution.periods.total') }}
</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
@@ -21,16 +17,16 @@
class="py-12 text-center text-gray-500" class="py-12 text-center text-gray-500"
> >
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" /> <i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
<p>{{ $t('dashboard.modelDistribution.noData') }}</p> <p>暂无模型使用数据</p>
</div> </div>
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Pie Chart --> <!-- 饼图 -->
<div class="relative" style="height: 300px"> <div class="relative" style="height: 300px">
<canvas ref="chartCanvas" /> <canvas ref="chartCanvas" />
</div> </div>
<!-- Data List --> <!-- 数据列表 -->
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="(stat, index) in sortedStats" v-for="(stat, index) in sortedStats"
@@ -42,14 +38,8 @@
<span class="font-medium text-gray-700">{{ stat.model }}</span> <span class="font-medium text-gray-700">{{ stat.model }}</span>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-semibold text-gray-800"> <p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
{{ formatNumber(stat.requests) }} <p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
{{ $t('dashboard.modelDistribution.units.requests') }}
</p>
<p class="text-sm text-gray-500">
{{ formatNumber(stat.totalTokens) }}
{{ $t('dashboard.modelDistribution.units.tokens') }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -60,13 +50,10 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Chart } from 'chart.js/auto' import { Chart } from 'chart.js/auto'
import { useI18n } from 'vue-i18n'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useChartConfig } from '@/composables/useChartConfig' import { useChartConfig } from '@/composables/useChartConfig'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
const { t } = useI18n()
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
const chartCanvas = ref(null) const chartCanvas = ref(null)
let chart = null let chart = null
@@ -123,8 +110,8 @@ const createChart = () => {
).toFixed(1) ).toFixed(1)
return [ return [
`${stat.model}: ${percentage}%`, `${stat.model}: ${percentage}%`,
`${t('dashboard.modelDistribution.chart.tooltip.requests')}: ${formatNumber(stat.requests)}`, `请求: ${formatNumber(stat.requests)}`,
`${t('dashboard.modelDistribution.chart.tooltip.tokens')}: ${formatNumber(stat.totalTokens)}` `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"> <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"> <h2 class="flex items-center text-xl font-bold text-gray-800">
<i class="fas fa-chart-area mr-2 text-blue-500" /> <i class="fas fa-chart-area mr-2 text-blue-500" />
{{ $t('dashboard.usageTrend.title') }} 使用趋势
</h2> </h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange"> <el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
<el-radio-button label="day"> <el-radio-button label="day"> 按天 </el-radio-button>
{{ $t('dashboard.usageTrend.granularity.byDay') }} <el-radio-button label="hour"> 按小时 </el-radio-button>
</el-radio-button>
<el-radio-button label="hour">
{{ $t('dashboard.usageTrend.granularity.byHour') }}
</el-radio-button>
</el-radio-group> </el-radio-group>
<el-select <el-select
@@ -25,7 +21,7 @@
<el-option <el-option
v-for="period in periodOptions" v-for="period in periodOptions"
:key="period.days" :key="period.days"
:label="$t('dashboard.usageTrend.periodOptions.recentDays', { days: period.days })" :label="`最近${period.days}天`"
:value="period.days" :value="period.days"
/> />
</el-select> </el-select>
@@ -39,25 +35,23 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Chart } from 'chart.js/auto' import { Chart } from 'chart.js/auto'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useChartConfig } from '@/composables/useChartConfig' import { useChartConfig } from '@/composables/useChartConfig'
import { useI18n } from 'vue-i18n'
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
const chartCanvas = ref(null) const chartCanvas = ref(null)
let chart = null let chart = null
const { t } = useI18n()
const trendPeriod = ref(7) const trendPeriod = ref(7)
const granularity = ref('day') const granularity = ref('day')
const periodOptions = computed(() => [ const periodOptions = [
{ days: 1, label: t('dashboard.usageTrend.periodOptions.last24Hours') }, { days: 1, label: '24小时' },
{ days: 7, label: t('dashboard.usageTrend.periodOptions.last7Days') }, { days: 7, label: '7天' },
{ days: 30, label: t('dashboard.usageTrend.periodOptions.last30Days') } { days: 30, label: '30天' }
]) ]
const createChart = () => { const createChart = () => {
if (!chartCanvas.value || !dashboardStore.trendData.length) return if (!chartCanvas.value || !dashboardStore.trendData.length) return
@@ -87,7 +81,7 @@ const createChart = () => {
labels, labels,
datasets: [ datasets: [
{ {
label: t('dashboard.usageTrend.chartLabels.requests'), label: '请求次数',
data: dashboardStore.trendData.map((item) => item.requests), data: dashboardStore.trendData.map((item) => item.requests),
borderColor: '#667eea', borderColor: '#667eea',
backgroundColor: getGradient(ctx, '#667eea', 0.1), backgroundColor: getGradient(ctx, '#667eea', 0.1),
@@ -95,7 +89,7 @@ const createChart = () => {
tension: 0.4 tension: 0.4
}, },
{ {
label: t('dashboard.usageTrend.chartLabels.tokens'), label: 'Token使用量',
data: dashboardStore.trendData.map((item) => item.tokens), data: dashboardStore.trendData.map((item) => item.tokens),
borderColor: '#f093fb', borderColor: '#f093fb',
backgroundColor: getGradient(ctx, '#f093fb', 0.1), backgroundColor: getGradient(ctx, '#f093fb', 0.1),
@@ -133,7 +127,7 @@ const createChart = () => {
position: 'left', position: 'left',
title: { title: {
display: true, display: true,
text: t('dashboard.usageTrend.chartLabels.requestsAxis') text: '请求次数'
} }
}, },
y1: { y1: {
@@ -142,7 +136,7 @@ const createChart = () => {
position: 'right', position: 'right',
title: { title: {
display: true, display: true,
text: t('dashboard.usageTrend.chartLabels.tokensAxis') text: 'Token使用量'
}, },
grid: { grid: {
drawOnChartArea: false drawOnChartArea: false

View File

@@ -11,7 +11,7 @@
<LogoTitle <LogoTitle
:loading="oemLoading" :loading="oemLoading"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon" :logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
:subtitle="t('header.adminPanel')" subtitle="管理后台"
:title="oemSettings.siteName" :title="oemSettings.siteName"
title-class="text-white dark:text-gray-100" 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" 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 || '#'" :href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank" target="_blank"
:title="t('header.newVersionAvailable')" title="有新版本可用"
> >
<i class="fas fa-arrow-up text-[10px]" /> <i class="fas fa-arrow-up text-[10px]" />
<span>{{ t('header.newVersion') }}</span> <span>新版本</span>
</a> </a>
</div> </div>
</template> </template>
@@ -38,11 +38,6 @@
</div> </div>
<!-- 主题切换和用户菜单 --> <!-- 主题切换和用户菜单 -->
<div class="flex items-center gap-2 sm:gap-4"> <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"> <div class="flex items-center">
<ThemeToggle mode="dropdown" /> <ThemeToggle mode="dropdown" />
@@ -60,7 +55,7 @@
@click="userMenuOpen = !userMenuOpen" @click="userMenuOpen = !userMenuOpen"
> >
<i class="fas fa-user-circle text-sm sm:text-base" /> <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 <i
class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200" class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
:class="{ 'rotate-180': userMenuOpen }" :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="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ <span class="text-gray-500 dark:text-gray-400">当前版本</span>
t('header.currentVersion')
}}</span>
<span class="font-mono text-gray-700 dark:text-gray-300" <span class="font-mono text-gray-700 dark:text-gray-300"
>v{{ versionInfo.current || '...' }}</span >v{{ versionInfo.current || '...' }}</span
> >
@@ -87,7 +80,7 @@
<div v-if="versionInfo.hasUpdate" class="mt-2"> <div v-if="versionInfo.hasUpdate" class="mt-2">
<div class="mb-2 flex items-center justify-between text-sm"> <div class="mb-2 flex items-center justify-between text-sm">
<span class="font-medium text-green-600 dark:text-green-400"> <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>
<span class="font-mono text-green-600 dark:text-green-400" <span class="font-mono text-green-600 dark:text-green-400"
>v{{ versionInfo.latest }}</span >v{{ versionInfo.latest }}</span
@@ -98,14 +91,14 @@
:href="versionInfo.releaseInfo?.htmlUrl || '#'" :href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank" target="_blank"
> >
<i class="fas fa-external-link-alt mr-1" />{{ t('header.viewUpdate') }} <i class="fas fa-external-link-alt mr-1" />查看更新
</a> </a>
</div> </div>
<div <div
v-else-if="versionInfo.checkingUpdate" v-else-if="versionInfo.checkingUpdate"
class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400" 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>
<div v-else class="mt-2 text-center"> <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" 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"> <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> </p>
</div> </div>
<button <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" class="text-xs text-blue-500 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
@click="checkForUpdates()" @click="checkForUpdates()"
> >
<i class="fas fa-sync-alt mr-1" />{{ t('header.checkUpdate') }} <i class="fas fa-sync-alt mr-1" />检查更新
</button> </button>
</transition> </transition>
</div> </div>
@@ -136,7 +129,7 @@
@click="openChangePasswordModal" @click="openChangePasswordModal"
> >
<i class="fas fa-key text-blue-500" /> <i class="fas fa-key text-blue-500" />
<span>{{ t('header.changeAccountInfo') }}</span> <span>修改账户信息</span>
</button> </button>
<hr class="my-2 border-gray-200 dark:border-gray-700" /> <hr class="my-2 border-gray-200 dark:border-gray-700" />
@@ -146,7 +139,7 @@
@click="logout" @click="logout"
> >
<i class="fas fa-sign-out-alt text-red-500" /> <i class="fas fa-sign-out-alt text-red-500" />
<span>{{ t('header.logout') }}</span> <span>退出登录</span>
</button> </button>
</div> </div>
</div> </div>
@@ -167,9 +160,7 @@
> >
<i class="fas fa-key text-white" /> <i class="fas fa-key text-white" />
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100"> <h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改账户信息</h3>
{{ t('header.changePasswordModal.title') }}
</h3>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300" class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@@ -184,72 +175,68 @@
@submit.prevent="changePassword" @submit.prevent="changePassword"
> >
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('header.changePasswordModal.currentUsername') >当前用户名</label
}}</label> >
<input <input
class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300" class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
disabled disabled
type="text" 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"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('header.changePasswordModal.currentUsernameHint') }} 当前用户名输入新用户名以修改
</p> </p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('header.changePasswordModal.newUsername') >新用户名</label
}}</label> >
<input <input
v-model="changePasswordForm.newUsername" v-model="changePasswordForm.newUsername"
class="form-input w-full" class="form-input w-full"
:placeholder="t('header.changePasswordModal.newUsernamePlaceholder')" placeholder="输入新用户名(留空保持不变)"
type="text" type="text"
/> />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">留空表示不修改用户名</p>
{{ t('header.changePasswordModal.newUsernameHint') }}
</p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('header.changePasswordModal.currentPassword') >当前密码</label
}}</label> >
<input <input
v-model="changePasswordForm.currentPassword" v-model="changePasswordForm.currentPassword"
class="form-input w-full" class="form-input w-full"
:placeholder="t('header.changePasswordModal.currentPasswordPlaceholder')" placeholder="请输入当前密码"
required required
type="password" type="password"
/> />
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('header.changePasswordModal.newPassword') >新密码</label
}}</label> >
<input <input
v-model="changePasswordForm.newPassword" v-model="changePasswordForm.newPassword"
class="form-input w-full" class="form-input w-full"
:placeholder="t('header.changePasswordModal.newPasswordPlaceholder')" placeholder="请输入新密码"
required required
type="password" type="password"
/> />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">密码长度至少8位</p>
{{ t('header.changePasswordModal.newPasswordHint') }}
</p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{ <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
t('header.changePasswordModal.confirmPassword') >确认新密码</label
}}</label> >
<input <input
v-model="changePasswordForm.confirmPassword" v-model="changePasswordForm.confirmPassword"
class="form-input w-full" class="form-input w-full"
:placeholder="t('header.changePasswordModal.confirmPasswordPlaceholder')" placeholder="请再次输入新密码"
required required
type="password" type="password"
/> />
@@ -261,7 +248,7 @@
type="button" type="button"
@click="closeChangePasswordModal" @click="closeChangePasswordModal"
> >
{{ t('common.cancel') }} 取消
</button> </button>
<button <button
class="btn btn-primary flex-1 px-6 py-3 font-semibold" 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" /> <div v-if="changePasswordLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" /> <i v-else class="fas fa-save mr-2" />
{{ {{ changePasswordLoading ? '保存中...' : '保存修改' }}
changePasswordLoading
? t('header.changePasswordModal.saving')
: t('header.changePasswordModal.save')
}}
</button> </button>
</div> </div>
</form> </form>
@@ -285,20 +268,17 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import LogoTitle from '@/components/common/LogoTitle.vue' import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue' import ThemeToggle from '@/components/common/ThemeToggle.vue'
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { t } = useI18n()
// 当前用户信息 // 当前用户信息
const currentUser = computed(() => authStore.user || {}) const currentUser = computed(() => authStore.user || { username: 'Admin' })
// OEM设置 // OEM设置
const oemSettings = computed(() => authStore.oemSettings || {}) const oemSettings = computed(() => authStore.oemSettings || {})
@@ -405,12 +385,12 @@ const closeChangePasswordModal = () => {
// 修改密码 // 修改密码
const changePassword = async () => { const changePassword = async () => {
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) { if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
showToast(t('header.changePasswordModal.passwordMismatch'), 'error') showToast('两次输入的密码不一致', 'error')
return return
} }
if (changePasswordForm.newPassword.length < 8) { if (changePasswordForm.newPassword.length < 8) {
showToast(t('header.changePasswordModal.passwordTooShort'), 'error') showToast('新密码长度至少8位', 'error')
return return
} }
@@ -425,8 +405,8 @@ const changePassword = async () => {
if (data.success) { if (data.success) {
const message = changePasswordForm.newUsername const message = changePasswordForm.newUsername
? t('header.changePasswordModal.accountInfoChangeSuccess') ? '账户信息修改成功,请重新登录'
: t('header.changePasswordModal.passwordChangeSuccess') : '密码修改成功,请重新登录'
showToast(message, 'success') showToast(message, 'success')
closeChangePasswordModal() closeChangePasswordModal()
@@ -436,10 +416,10 @@ const changePassword = async () => {
router.push('/login') router.push('/login')
}, 1500) }, 1500)
} else { } else {
showToast(data.message || t('header.changePasswordModal.changeFailed'), 'error') showToast(data.message || '修改失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('header.changePasswordModal.changePasswordFailed'), 'error') showToast('修改密码失败', 'error')
} finally { } finally {
changePasswordLoading.value = false changePasswordLoading.value = false
} }
@@ -447,10 +427,10 @@ const changePassword = async () => {
// 退出登录 // 退出登录
const logout = () => { const logout = () => {
if (confirm(t('header.logoutConfirm'))) { if (confirm('确定要退出登录吗?')) {
authStore.logout() authStore.logout()
router.push('/login') router.push('/login')
showToast(t('header.logoutSuccess'), 'success') showToast('已安全退出', 'success')
} }
userMenuOpen.value = false userMenuOpen.value = false
} }

View File

@@ -22,13 +22,10 @@
<script setup> <script setup>
import { ref, watch, nextTick, computed } from 'vue' import { ref, watch, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue' import TabBar from './TabBar.vue'
const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -127,7 +124,7 @@ const handleTabChange = async (tabKey) => {
} catch (err) { } catch (err) {
// 如果路由切换失败恢复activeTab状态 // 如果路由切换失败恢复activeTab状态
if (err.name !== 'NavigationDuplicated') { if (err.name !== 'NavigationDuplicated') {
console.error(t('layout.mainLayout.routing.routeChangeError'), err) console.error('路由切换失败:', err)
// 恢复到当前路由对应的tab // 恢复到当前路由对应的tab
initActiveTab() initActiveTab()
} }

View File

@@ -38,11 +38,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
defineProps({ defineProps({
activeTab: { activeTab: {
type: String, type: String,
@@ -57,49 +54,24 @@ const authStore = useAuthStore()
// 根据 LDAP 配置动态生成 tabs // 根据 LDAP 配置动态生成 tabs
const tabs = computed(() => { const tabs = computed(() => {
const baseTabs = [ const baseTabs = [
{ { key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
key: 'dashboard', { key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
name: t('layout.tabBar.tabs.dashboard.name'), { key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
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'
}
] ]
// 只有在 LDAP 启用时才显示用户管理 // 只有在 LDAP 启用时才显示用户管理
if (authStore.oemSettings?.ldapEnabled) { if (authStore.oemSettings?.ldapEnabled) {
baseTabs.push({ baseTabs.push({
key: 'userManagement', key: 'userManagement',
name: t('layout.tabBar.tabs.userManagement.name'), name: '用户管理',
shortName: t('layout.tabBar.tabs.userManagement.shortName'), shortName: '用户',
icon: 'fas fa-users' icon: 'fas fa-users'
}) })
} }
baseTabs.push( baseTabs.push(
{ { key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
key: 'tutorial', { key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
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'
}
) )
return baseTabs return baseTabs

View File

@@ -8,7 +8,7 @@
> >
<div class="mt-3"> <div class="mt-3">
<div class="mb-4 flex items-center justify-between"> <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')"> <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"> <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -23,16 +23,13 @@
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <div>
<label class="block text-sm font-medium text-gray-700" for="name"> <label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
{{ t('user.createApiKeyModal.form.nameLabel') }}
{{ t('user.createApiKeyModal.form.nameRequired') }}
</label>
<input <input
id="name" id="name"
v-model="form.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" 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" :disabled="loading"
:placeholder="t('user.createApiKeyModal.form.namePlaceholder')" placeholder="Enter API key name"
required required
type="text" type="text"
/> />
@@ -40,14 +37,14 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700" for="description"> <label class="block text-sm font-medium text-gray-700" for="description">
{{ t('user.createApiKeyModal.form.descriptionLabel') }} Description
</label> </label>
<textarea <textarea
id="description" id="description"
v-model="form.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" 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" :disabled="loading"
:placeholder="t('user.createApiKeyModal.form.descriptionPlaceholder')" placeholder="Optional description"
rows="3" rows="3"
></textarea> ></textarea>
</div> </div>
@@ -76,7 +73,7 @@
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >
{{ t('user.createApiKeyModal.buttons.cancel') }} Cancel
</button> </button>
<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" 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" fill="currentColor"
></path> ></path>
</svg> </svg>
{{ t('user.createApiKeyModal.buttons.creating') }} Creating...
</span> </span>
<span v-else>{{ t('user.createApiKeyModal.buttons.createApiKey') }}</span> <span v-else>Create API Key</span>
</button> </button>
</div> </div>
</form> </form>
@@ -124,13 +121,11 @@
</svg> </svg>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<h4 class="text-sm font-medium text-green-800"> <h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
{{ t('user.createApiKeyModal.success.title') }}
</h4>
<div class="mt-3"> <div class="mt-3">
<p class="mb-2 text-sm text-green-700"> <p class="mb-2 text-sm text-green-700">
<strong>{{ t('user.createApiKeyModal.success.warning.important') }}</strong> <strong>Important:</strong> Copy your API key now. You won't be able to see it
{{ t('user.createApiKeyModal.success.warning.message') }} again!
</p> </p>
<div class="rounded-md border border-green-300 bg-white p-3"> <div class="rounded-md border border-green-300 bg-white p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -154,7 +149,7 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
{{ t('user.createApiKeyModal.buttons.copy') }} Copy
</button> </button>
</div> </div>
</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" 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" @click="handleClose"
> >
{{ t('user.createApiKeyModal.buttons.done') }} Done
</button> </button>
</div> </div>
</div> </div>
@@ -177,7 +172,6 @@
<script setup> <script setup>
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
@@ -190,7 +184,6 @@ const props = defineProps({
const emit = defineEmits(['close', 'created']) const emit = defineEmits(['close', 'created'])
const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
@@ -211,7 +204,7 @@ const resetForm = () => {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.name.trim()) { if (!form.name.trim()) {
error.value = t('user.createApiKeyModal.validation.nameRequired') error.value = 'API key name is required'
return return
} }
@@ -228,14 +221,13 @@ const handleSubmit = async () => {
if (result.success) { if (result.success) {
newApiKey.value = result.apiKey newApiKey.value = result.apiKey
showToast(t('user.createApiKeyModal.messages.createSuccess'), 'success') showToast('API key created successfully!', 'success')
} else { } else {
error.value = result.message || t('user.createApiKeyModal.errors.createFailed') error.value = result.message || 'Failed to create API key'
} }
} catch (err) { } catch (err) {
console.error('Create API key error:', err) console.error('Create API key error:', err)
error.value = error.value = err.response?.data?.message || err.message || 'Failed to create API key'
err.response?.data?.message || err.message || t('user.createApiKeyModal.errors.createFailed')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -244,10 +236,10 @@ const handleSubmit = async () => {
const copyToClipboard = async (text) => { const copyToClipboard = async (text) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
showToast(t('user.createApiKeyModal.messages.copySuccess'), 'success') showToast('API key copied to clipboard!', 'success')
} catch (err) { } catch (err) {
console.error('Failed to copy:', 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="space-y-6">
<div class="sm:flex sm:items-center"> <div class="sm:flex sm:items-center">
<div class="sm:flex-auto"> <div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900"> <h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
{{ t('user.userApiKeysManager.title') }}
</h1>
<p class="mt-2 text-sm text-gray-700"> <p class="mt-2 text-sm text-gray-700">
{{ t('user.userApiKeysManager.description') }} Manage your API keys to access Claude Relay services
</p> </p>
</div> </div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
@@ -23,7 +21,7 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
{{ t('user.userApiKeysManager.buttons.createApiKey') }} Create API Key
</button> </button>
</div> </div>
</div> </div>
@@ -45,7 +43,8 @@
</div> </div>
<div class="ml-3"> <div class="ml-3">
<p class="text-sm text-yellow-700"> <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> </p>
</div> </div>
</div> </div>
@@ -73,7 +72,7 @@
fill="currentColor" fill="currentColor"
></path> ></path>
</svg> </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> </div>
<!-- API Keys List --> <!-- API Keys List -->
@@ -101,37 +100,29 @@
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt" 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" 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>
<span <span
v-else-if="!apiKey.isActive" 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" 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> </span>
</div> </div>
<div class="mt-1"> <div class="mt-1">
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
{{ apiKey.description || t('user.userApiKeysManager.status.noDescription') }}
</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400"> <div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
<span <span>Created: {{ formatDate(apiKey.createdAt) }}</span>
>{{ t('user.userApiKeysManager.dateLabels.created') }}:
{{ formatDate(apiKey.createdAt) }}</span
>
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt" <span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
>{{ t('user.userApiKeysManager.dateLabels.deleted') }}: >Deleted: {{ formatDate(apiKey.deletedAt) }}</span
{{ formatDate(apiKey.deletedAt) }}</span
> >
<span v-else-if="apiKey.lastUsedAt" <span v-else-if="apiKey.lastUsedAt"
>{{ t('user.userApiKeysManager.dateLabels.lastUsed') }}: >Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
{{ formatDate(apiKey.lastUsedAt) }}</span
> >
<span v-else>{{ t('user.userApiKeysManager.status.neverUsed') }}</span> <span v-else>Never used</span>
<span <span
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)" v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
>{{ t('user.userApiKeysManager.dateLabels.expires') }}: >Expires: {{ formatDate(apiKey.expiresAt) }}</span
{{ formatDate(apiKey.expiresAt) }}</span
> >
</div> </div>
</div> </div>
@@ -140,10 +131,7 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<!-- Usage Stats --> <!-- Usage Stats -->
<div class="text-right text-xs text-gray-500"> <div class="text-right text-xs text-gray-500">
<div> <div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
{{ formatNumber(apiKey.usage?.requests || 0) }}
{{ t('user.userApiKeysManager.usage.requests') }}
</div>
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div> <div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
</div> </div>
@@ -151,7 +139,7 @@
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<button <button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600" 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)" @click="showApiKey(apiKey)"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -177,7 +165,7 @@
allowUserDeleteApiKeys allowUserDeleteApiKeys
" "
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600" 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)" @click="deleteApiKey(apiKey)"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -211,12 +199,8 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900"> <h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
{{ t('user.userApiKeysManager.emptyState.title') }} <p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ t('user.userApiKeysManager.emptyState.description') }}
</p>
<div class="mt-6"> <div class="mt-6">
<button <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" 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" stroke-width="2"
/> />
</svg> </svg>
{{ t('user.userApiKeysManager.buttons.createApiKey') }} Create API Key
</button> </button>
</div> </div>
</div> </div>
@@ -252,10 +236,10 @@
<!-- Confirm Delete Modal --> <!-- Confirm Delete Modal -->
<ConfirmModal <ConfirmModal
confirm-class="bg-red-600 hover:bg-red-700" confirm-class="bg-red-600 hover:bg-red-700"
:confirm-text="t('user.userApiKeysManager.buttons.delete')" confirm-text="Delete"
:message="t('user.userApiKeysManager.confirmDelete.message', { name: selectedApiKey?.name })" :message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
:show="showDeleteModal" :show="showDeleteModal"
:title="t('user.userApiKeysManager.confirmDelete.title')" title="Delete API Key"
@cancel="showDeleteModal = false" @cancel="showDeleteModal = false"
@confirm="handleDeleteConfirm" @confirm="handleDeleteConfirm"
/> />
@@ -264,15 +248,12 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import CreateApiKeyModal from './CreateApiKeyModal.vue' import CreateApiKeyModal from './CreateApiKeyModal.vue'
import ViewApiKeyModal from './ViewApiKeyModal.vue' import ViewApiKeyModal from './ViewApiKeyModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
const { t, locale } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const loading = ref(true) const loading = ref(true)
@@ -310,12 +291,7 @@ const formatNumber = (num) => {
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return null if (!dateString) return null
const localeMap = { return new Date(dateString).toLocaleDateString('en-US', {
'zh-cn': 'zh-CN',
'zh-tw': 'zh-TW',
en: 'en-US'
}
return new Date(dateString).toLocaleDateString(localeMap[locale.value] || 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -330,7 +306,7 @@ const loadApiKeys = async () => {
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
} catch (error) { } catch (error) {
console.error('Failed to load API keys:', error) console.error('Failed to load API keys:', error)
showToast(t('user.userApiKeysManager.messages.loadFailed'), 'error') showToast('Failed to load API keys', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -351,12 +327,12 @@ const handleDeleteConfirm = async () => {
const result = await userStore.deleteApiKey(selectedApiKey.value.id) const result = await userStore.deleteApiKey(selectedApiKey.value.id)
if (result.success) { if (result.success) {
showToast(t('user.userApiKeysManager.messages.deleteSuccess'), 'success') showToast('API key deleted successfully', 'success')
await loadApiKeys() await loadApiKeys()
} }
} catch (error) { } catch (error) {
console.error('Failed to delete API key:', error) console.error('Failed to delete API key:', error)
showToast(t('user.userApiKeysManager.messages.deleteFailed'), 'error') showToast('Failed to delete API key', 'error')
} finally { } finally {
showDeleteModal.value = false showDeleteModal.value = false
selectedApiKey.value = null selectedApiKey.value = null

View File

@@ -2,8 +2,8 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="sm:flex sm:items-center"> <div class="sm:flex sm:items-center">
<div class="sm:flex-auto"> <div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">{{ t('user.userUsageStats.title') }}</h1> <h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
<p class="mt-2 text-sm text-gray-700">{{ t('user.userUsageStats.subtitle') }}</p> <p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
</div> </div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<select <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" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats" @change="loadUsageStats"
> >
<option value="day">{{ t('user.userUsageStats.periodSelection.day') }}</option> <option value="day">Last 24 Hours</option>
<option value="week">{{ t('user.userUsageStats.periodSelection.week') }}</option> <option value="week">Last 7 Days</option>
<option value="month">{{ t('user.userUsageStats.periodSelection.month') }}</option> <option value="month">Last 30 Days</option>
<option value="quarter">{{ t('user.userUsageStats.periodSelection.quarter') }}</option> <option value="quarter">Last 90 Days</option>
</select> </select>
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@
fill="currentColor" fill="currentColor"
></path> ></path>
</svg> </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> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
@@ -66,9 +66,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500"> <dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
{{ t('user.userUsageStats.statsCards.totalRequests') }}
</dt>
<dd class="text-lg font-medium text-gray-900"> <dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalRequests || 0) }} {{ formatNumber(usageStats?.totalRequests || 0) }}
</dd> </dd>
@@ -98,9 +96,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500"> <dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
{{ t('user.userUsageStats.statsCards.inputTokens') }}
</dt>
<dd class="text-lg font-medium text-gray-900"> <dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }} {{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd> </dd>
@@ -130,9 +126,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500"> <dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
{{ t('user.userUsageStats.statsCards.outputTokens') }}
</dt>
<dd class="text-lg font-medium text-gray-900"> <dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }} {{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd> </dd>
@@ -162,9 +156,7 @@
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500"> <dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
{{ t('user.userUsageStats.statsCards.totalCost') }}
</dt>
<dd class="text-lg font-medium text-gray-900"> <dd class="text-lg font-medium text-gray-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }} ${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd> </dd>
@@ -178,9 +170,7 @@
<!-- Daily Usage Chart --> <!-- Daily Usage Chart -->
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow"> <div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900"> <h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
{{ t('user.userUsageStats.usageTrend.title') }}
</h3>
<!-- Placeholder for chart - you can integrate Chart.js or similar --> <!-- Placeholder for chart - you can integrate Chart.js or similar -->
<div <div
@@ -200,14 +190,10 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900"> <h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
{{ t('user.userUsageStats.usageTrend.chartTitle') }} <p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ t('user.userUsageStats.usageTrend.dailyTrendsDescription') }}
</p>
<p class="mt-2 text-xs text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -220,9 +206,7 @@
class="rounded-lg bg-white shadow" class="rounded-lg bg-white shadow"
> >
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900"> <h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
{{ t('user.userUsageStats.modelUsage.title') }}
</h3>
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="model in usageStats.modelStats" v-for="model in usageStats.modelStats"
@@ -238,13 +222,7 @@
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-sm text-gray-900"> <p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
{{
t('user.userUsageStats.modelUsage.requestsCount', {
count: formatNumber(model.requests)
})
}}
</p>
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p> <p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
</div> </div>
</div> </div>
@@ -255,9 +233,7 @@
<!-- Detailed Usage Table --> <!-- Detailed Usage Table -->
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow"> <div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900"> <h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
{{ t('user.userUsageStats.apiKeyUsage.title') }}
</h3>
<div class="overflow-hidden"> <div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <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" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ t('user.userUsageStats.apiKeyUsage.headers.apiKey') }} API Key
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ t('user.userUsageStats.apiKeyUsage.headers.requests') }} Requests
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ t('user.userUsageStats.apiKeyUsage.headers.inputTokens') }} Input Tokens
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ t('user.userUsageStats.apiKeyUsage.headers.outputTokens') }} Output Tokens
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ t('user.userUsageStats.apiKeyUsage.headers.cost') }} Cost
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col" scope="col"
> >
{{ t('user.userUsageStats.apiKeyUsage.headers.status') }} Status
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -331,10 +307,10 @@
> >
{{ {{
apiKey.isDeleted === 'true' || apiKey.deletedAt apiKey.isDeleted === 'true' || apiKey.deletedAt
? t('user.userUsageStats.apiKeyUsage.status.deleted') ? 'Deleted'
: apiKey.isActive : apiKey.isActive
? t('user.userUsageStats.apiKeyUsage.status.active') ? 'Active'
: t('user.userUsageStats.apiKeyUsage.status.disabled') : 'Disabled'
}} }}
</span> </span>
</td> </td>
@@ -363,11 +339,10 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900"> <h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
{{ t('user.userUsageStats.noData.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500"> <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> </p>
</div> </div>
</div> </div>
@@ -375,12 +350,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const loading = ref(true) const loading = ref(true)
@@ -409,7 +381,7 @@ const loadUsageStats = async () => {
userApiKeys.value = apiKeys userApiKeys.value = apiKeys
} catch (error) { } catch (error) {
console.error('Failed to load usage stats:', error) console.error('Failed to load usage stats:', error)
showToast(t('user.userUsageStats.loadFailed'), 'error') showToast('Failed to load usage statistics', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -8,7 +8,7 @@
> >
<div class="mt-3"> <div class="mt-3">
<div class="mb-4 flex items-center justify-between"> <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')"> <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"> <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -24,35 +24,29 @@
<div v-if="apiKey" class="space-y-4"> <div v-if="apiKey" class="space-y-4">
<!-- API Key Name --> <!-- API Key Name -->
<div> <div>
<label class="block text-sm font-medium text-gray-700">{{ <label class="block text-sm font-medium text-gray-700">Name</label>
t('user.viewApiKeyModal.fields.name')
}}</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p> <p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
</div> </div>
<!-- Description --> <!-- Description -->
<div v-if="apiKey.description"> <div v-if="apiKey.description">
<label class="block text-sm font-medium text-gray-700">{{ <label class="block text-sm font-medium text-gray-700">Description</label>
t('user.viewApiKeyModal.fields.description')
}}</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p> <p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
</div> </div>
<!-- API Key --> <!-- API Key -->
<div> <div>
<label class="block text-sm font-medium text-gray-700">{{ <label class="block text-sm font-medium text-gray-700">API Key</label>
t('user.viewApiKeyModal.fields.apiKey')
}}</label>
<div class="mt-1 flex items-center space-x-2"> <div class="mt-1 flex items-center space-x-2">
<div class="flex-1"> <div class="flex-1">
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3"> <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">{{ <code class="break-all font-mono text-sm text-gray-900">{{
apiKey.key || t('user.viewApiKeyModal.apiKeyDisplay.notAvailable') apiKey.key || 'Not available'
}}</code> }}</code>
</div> </div>
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3"> <div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="font-mono text-sm text-gray-900">{{ <code class="font-mono text-sm text-gray-900">{{
apiKey.keyPreview || t('user.viewApiKeyModal.apiKeyDisplay.keyPreview') apiKey.keyPreview || 'cr_****'
}}</code> }}</code>
</div> </div>
</div> </div>
@@ -96,11 +90,7 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
{{ {{ showFullKey ? 'Hide' : 'Show' }}
showFullKey
? t('user.viewApiKeyModal.buttons.hide')
: t('user.viewApiKeyModal.buttons.show')
}}
</button> </button>
<button <button
v-if="showFullKey && apiKey.key" v-if="showFullKey && apiKey.key"
@@ -115,20 +105,18 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
{{ t('user.viewApiKeyModal.buttons.copy') }} Copy
</button> </button>
</div> </div>
</div> </div>
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500"> <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> </p>
</div> </div>
<!-- Status --> <!-- Status -->
<div> <div>
<label class="block text-sm font-medium text-gray-700">{{ <label class="block text-sm font-medium text-gray-700">Status</label>
t('user.viewApiKeyModal.fields.status')
}}</label>
<div class="mt-1"> <div class="mt-1">
<span <span
:class="[ :class="[
@@ -136,47 +124,33 @@
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]" ]"
> >
{{ {{ apiKey.isActive ? 'Active' : 'Disabled' }}
apiKey.isActive
? t('user.viewApiKeyModal.status.active')
: t('user.viewApiKeyModal.status.disabled')
}}
</span> </span>
</div> </div>
</div> </div>
<!-- Usage Stats --> <!-- Usage Stats -->
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4"> <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">{{ <label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
t('user.viewApiKeyModal.fields.usageStatistics')
}}</label>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span class="text-gray-500" <span class="text-gray-500">Requests:</span>
>{{ t('user.viewApiKeyModal.usageStats.requests') }}:</span
>
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span> <span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
</div> </div>
<div> <div>
<span class="text-gray-500" <span class="text-gray-500">Input Tokens:</span>
>{{ t('user.viewApiKeyModal.usageStats.inputTokens') }}:</span
>
<span class="ml-2 font-medium">{{ <span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.inputTokens || 0) formatNumber(apiKey.usage.inputTokens || 0)
}}</span> }}</span>
</div> </div>
<div> <div>
<span class="text-gray-500" <span class="text-gray-500">Output Tokens:</span>
>{{ t('user.viewApiKeyModal.usageStats.outputTokens') }}:</span
>
<span class="ml-2 font-medium">{{ <span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.outputTokens || 0) formatNumber(apiKey.usage.outputTokens || 0)
}}</span> }}</span>
</div> </div>
<div> <div>
<span class="text-gray-500" <span class="text-gray-500">Total Cost:</span>
>{{ t('user.viewApiKeyModal.usageStats.totalCost') }}:</span
>
<span class="ml-2 font-medium" <span class="ml-2 font-medium"
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span >${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
> >
@@ -187,17 +161,15 @@
<!-- Timestamps --> <!-- Timestamps -->
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm"> <div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
<div class="flex justify-between"> <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> <span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
</div> </div>
<div v-if="apiKey.lastUsedAt" class="flex justify-between"> <div v-if="apiKey.lastUsedAt" class="flex justify-between">
<span class="text-gray-500" <span class="text-gray-500">Last Used:</span>
>{{ t('user.viewApiKeyModal.timestamps.lastUsed') }}:</span
>
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span> <span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
</div> </div>
<div v-if="apiKey.expiresAt" class="flex justify-between"> <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 <span
:class="[ :class="[
'font-medium', '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" 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')" @click="emit('close')"
> >
{{ t('user.viewApiKeyModal.buttons.close') }} Close
</button> </button>
</div> </div>
</div> </div>
@@ -225,11 +197,8 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const { t } = useI18n()
defineProps({ defineProps({
show: { show: {
type: Boolean, type: Boolean,
@@ -256,26 +225,22 @@ const formatNumber = (num) => {
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return null if (!dateString) return null
const { locale } = useI18n() return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
{
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
} })
)
} }
const copyToClipboard = async (text) => { const copyToClipboard = async (text) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
showToast(t('user.viewApiKeyModal.messages.copySuccess'), 'success') showToast('Copied to clipboard!', 'success')
} catch (err) { } catch (err) {
console.error('Failed to copy:', err) console.error('Failed to copy:', err)
showToast(t('user.viewApiKeyModal.messages.copyFailed'), 'error') showToast('Failed to copy to clipboard', 'error')
} }
} }
</script> </script>

View File

@@ -1,5 +1,4 @@
import { Chart } from 'chart.js/auto' import { Chart } from 'chart.js/auto'
import i18n from '@/i18n'
export function useChartConfig() { export function useChartConfig() {
// 设置Chart.js默认配置 // 设置Chart.js默认配置
@@ -52,9 +51,7 @@ export function useChartConfig() {
label += ': ' label += ': '
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
const localeMap = { 'zh-cn': 'zh-CN', 'zh-tw': 'zh-TW', en: 'en-US' } label += new Intl.NumberFormat('zh-CN').format(context.parsed.y)
const currentLocale = localeMap[i18n.global.locale.value] || 'en-US'
label += new Intl.NumberFormat(currentLocale).format(context.parsed.y)
} }
return label return label
} }

View File

@@ -1,22 +1,16 @@
import { ref } from 'vue' import { ref } from 'vue'
import i18n from '@/i18n'
const showConfirmModal = ref(false) const showConfirmModal = ref(false)
const confirmOptions = ref({ const confirmOptions = ref({
title: '', title: '',
message: '', message: '',
confirmText: i18n.global.t('common.confirmModal.continue'), confirmText: '继续',
cancelText: i18n.global.t('common.confirmModal.cancel') cancelText: '取消'
}) })
const confirmResolve = ref(null) const confirmResolve = ref(null)
export function useConfirm() { export function useConfirm() {
const showConfirm = ( const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
title,
message,
confirmText = i18n.global.t('common.confirmModal.continue'),
cancelText = i18n.global.t('common.confirmModal.cancel')
) => {
return new Promise((resolve) => { return new Promise((resolve) => {
confirmOptions.value = { confirmOptions.value = {
title, title,

View File

@@ -1,8 +1,6 @@
// API Stats 专用 API 客户端 // API Stats 专用 API 客户端
// 与管理员 API 隔离,不需要认证 // 与管理员 API 隔离,不需要认证
import i18n from '@/i18n'
class ApiStatsClient { class ApiStatsClient {
constructor() { constructor() {
this.baseURL = window.location.origin this.baseURL = window.location.origin
@@ -28,9 +26,7 @@ class ApiStatsClient {
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(data.message || `请求失败: ${response.status}`)
data.message || i18n.global.t('common.errors.requestFailed', { status: response.status })
)
} }
return data return data

View File

@@ -4,7 +4,7 @@ export const APP_CONFIG = {
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'), basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
// 应用标题 // 应用标题
title: import.meta.env.VITE_APP_TITLE || 'Claude Relay Service - Admin Panel', title: import.meta.env.VITE_APP_TITLE || 'Claude Relay Service - 管理后台',
// 是否为开发环境 // 是否为开发环境
isDev: import.meta.env.DEV, isDev: import.meta.env.DEV,

View File

@@ -1,90 +0,0 @@
import { createI18n } from 'vue-i18n'
import zhCn from './locales/zh-cn.js'
import zhTw from './locales/zh-tw.js'
import en from './locales/en.js'
// 获取浏览器语言设置
function getBrowserLocale() {
const navigatorLocale = navigator.languages ? navigator.languages[0] : navigator.language
if (!navigatorLocale) {
return 'zh-cn'
}
const trimmedLocale = navigatorLocale.trim().split(';')[0].toLowerCase()
if (trimmedLocale.includes('zh')) {
if (
trimmedLocale.includes('tw') ||
trimmedLocale.includes('hk') ||
trimmedLocale.includes('mo')
) {
return 'zh-tw'
}
return 'zh-cn'
}
if (trimmedLocale.includes('en')) {
return 'en'
}
return 'zh-cn' // 默认简体中文
}
// 获取保存的语言设置或浏览器语言
const savedLocale = localStorage.getItem('app-locale')
const defaultLocale = savedLocale || getBrowserLocale()
// 创建一个函数来获取本地化的语言信息
export function getSupportedLocalesWithI18n(t) {
return {
'zh-cn': {
name: t('common.languageSwitch.zhCnName'),
flag: t('common.languageSwitch.zhCnFlag'),
shortName: t('common.languageSwitch.zhCnFlag')
},
'zh-tw': {
name: t('common.languageSwitch.zhTwName'),
flag: t('common.languageSwitch.zhTwFlag'),
shortName: t('common.languageSwitch.zhTwFlag')
},
en: {
name: t('common.languageSwitch.enName'),
flag: t('common.languageSwitch.enFlag'),
shortName: t('common.languageSwitch.enFlag')
}
}
}
// 保持原有的SUPPORTED_LOCALES作为默认值用于不依赖i18n的场景
export const SUPPORTED_LOCALES = {
'zh-cn': {
name: '简体中文',
flag: '简',
shortName: '简'
},
'zh-tw': {
name: '繁體中文',
flag: '繁',
shortName: '繁'
},
en: {
name: 'English',
flag: 'EN',
shortName: 'EN'
}
}
export const i18n = createI18n({
legacy: false, // 使用 Composition API 模式
locale: defaultLocale,
fallbackLocale: 'zh-cn',
messages: {
'zh-cn': zhCn,
'zh-tw': zhTw,
en: en
},
globalInjection: true // 全局注入 $t 函数
})
export default i18n

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useUserStore } from './stores/user' import { useUserStore } from './stores/user'
import i18n from './i18n'
import './assets/styles/main.css' import './assets/styles/main.css'
import './assets/styles/global.css' import './assets/styles/global.css'
@@ -20,11 +20,10 @@ app.use(pinia)
// 使用路由 // 使用路由
app.use(router) app.use(router)
// 使用Vue I18n // 使用Element Plus
app.use(i18n) app.use(ElementPlus, {
locale: zhCn
// 使用Element Plus - 语言配置在 App.vue 中通过 ElConfigProvider 处理 })
app.use(ElementPlus)
// 设置axios拦截器 // 设置axios拦截器
const userStore = useUserStore() const userStore = useUserStore()

View File

@@ -2,7 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { APP_CONFIG } from '@/config/app' import { APP_CONFIG } from '@/config/app'
import { showToast } from '@/utils/toast'
// 路由懒加载 // 路由懒加载
const LoginView = () => import('@/views/LoginView.vue') const LoginView = () => import('@/views/LoginView.vue')
@@ -151,7 +150,7 @@ router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const userStore = useUserStore() const userStore = useUserStore()
console.log('Router navigation:', { console.log('路由导航:', {
to: to.path, to: to.path,
from: from.path, from: from.path,
fullPath: to.fullPath, fullPath: to.fullPath,
@@ -178,6 +177,8 @@ router.beforeEach(async (to, from, next) => {
} catch (error) { } catch (error) {
// If the error is about disabled account, redirect to login with error // If the error is about disabled account, redirect to login with error
if (error.message && error.message.includes('disabled')) { if (error.message && error.message.includes('disabled')) {
// Import showToast to display the error
const { showToast } = await import('@/utils/toast')
showToast(error.message, 'error') showToast(error.message, 'error')
} }
return next('/user-login') return next('/user-login')

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import i18n from '@/i18n'
export const useAccountsStore = defineStore('accounts', () => { export const useAccountsStore = defineStore('accounts', () => {
// 状态 // 状态
@@ -28,7 +27,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
claudeAccounts.value = response.data || [] claudeAccounts.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed')) throw new Error(response.message || '获取Claude账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -47,7 +46,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
claudeConsoleAccounts.value = response.data || [] claudeConsoleAccounts.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed')) throw new Error(response.message || '获取Claude Console账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -66,7 +65,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
bedrockAccounts.value = response.data || [] bedrockAccounts.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed')) throw new Error(response.message || '获取Bedrock账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -85,7 +84,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
geminiAccounts.value = response.data || [] geminiAccounts.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed')) throw new Error(response.message || '获取Gemini账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -104,7 +103,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
openaiAccounts.value = response.data || [] openaiAccounts.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed')) throw new Error(response.message || '获取OpenAI账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -123,7 +122,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
azureOpenaiAccounts.value = response.data || [] azureOpenaiAccounts.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed')) throw new Error(response.message || '获取Azure OpenAI账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -184,7 +183,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchClaudeAccounts() await fetchClaudeAccounts()
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '创建Claude账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -204,9 +203,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchClaudeConsoleAccounts() await fetchClaudeConsoleAccounts()
return response.data return response.data
} else { } else {
throw new Error( throw new Error(response.message || '创建Claude Console账户失败')
response.message || i18n.global.t('common.errors.createClaudeConsoleAccountFailed')
)
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -226,7 +223,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchBedrockAccounts() await fetchBedrockAccounts()
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '创建Bedrock账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -246,7 +243,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchGeminiAccounts() await fetchGeminiAccounts()
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '创建Gemini账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -266,7 +263,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchOpenAIAccounts() await fetchOpenAIAccounts()
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '创建OpenAI账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -286,9 +283,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchAzureOpenAIAccounts() await fetchAzureOpenAIAccounts()
return response.data return response.data
} else { } else {
throw new Error( throw new Error(response.message || '创建Azure OpenAI账户失败')
response.message || i18n.global.t('common.errors.createAzureOpenAIAccountFailed')
)
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -328,7 +323,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchClaudeAccounts() await fetchClaudeAccounts()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '更新Claude账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -348,9 +343,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchClaudeConsoleAccounts() await fetchClaudeConsoleAccounts()
return response return response
} else { } else {
throw new Error( throw new Error(response.message || '更新Claude Console账户失败')
response.message || i18n.global.t('common.errors.updateClaudeConsoleAccountFailed')
)
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -370,7 +363,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchBedrockAccounts() await fetchBedrockAccounts()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '更新Bedrock账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -390,7 +383,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchGeminiAccounts() await fetchGeminiAccounts()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '更新Gemini账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -410,7 +403,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchOpenAIAccounts() await fetchOpenAIAccounts()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '更新OpenAI账户失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -430,9 +423,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchAzureOpenAIAccounts() await fetchAzureOpenAIAccounts()
return response return response
} else { } else {
throw new Error( throw new Error(response.message || '更新Azure OpenAI账户失败')
response.message || i18n.global.t('common.errors.updateAzureOpenAIAccountFailed')
)
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -507,7 +498,7 @@ export const useAccountsStore = defineStore('accounts', () => {
} }
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.schedulingToggleFailed')) throw new Error(response.message || '切换状态失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -562,7 +553,7 @@ export const useAccountsStore = defineStore('accounts', () => {
} }
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.deleteFailed')) throw new Error(response.message || '删除失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -582,7 +573,7 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchClaudeAccounts() await fetchClaudeAccounts()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || 'Token刷新失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -599,7 +590,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data // 返回整个对象包含authUrl和sessionId return response.data // 返回整个对象包含authUrl和sessionId
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '生成授权URL失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -614,7 +605,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '交换授权码失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -632,9 +623,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data // 返回整个对象包含authUrl和sessionId return response.data // 返回整个对象包含authUrl和sessionId
} else { } else {
throw new Error( throw new Error(response.message || '生成Setup Token URL失败')
response.message || i18n.global.t('common.errors.generateSetupTokenUrlFailed')
)
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -652,7 +641,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('common.errors.exchangeSetupTokenFailed')) throw new Error(response.message || '交换Setup Token授权码失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -667,7 +656,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data // 返回整个对象包含authUrl和sessionId return response.data // 返回整个对象包含authUrl和sessionId
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '生成授权URL失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -682,7 +671,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '交换授权码失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -697,7 +686,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data // 返回整个对象包含authUrl和sessionId return response.data // 返回整个对象包含authUrl和sessionId
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '生成授权URL失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -712,7 +701,7 @@ export const useAccountsStore = defineStore('accounts', () => {
if (response.success) { if (response.success) {
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('accounts.operationFailed')) throw new Error(response.message || '交换授权码失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message

View File

@@ -1,5 +1,4 @@
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import i18n from '@/i18n'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
@@ -23,7 +22,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
if (response.success) { if (response.success) {
apiKeys.value = response.data || [] apiKeys.value = response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.loadFailed')) throw new Error(response.message || '获取API Keys失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -43,7 +42,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
await fetchApiKeys() await fetchApiKeys()
return response.data return response.data
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed')) throw new Error(response.message || '创建API Key失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -63,7 +62,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
await fetchApiKeys() await fetchApiKeys()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.updateFailed')) throw new Error(response.message || '更新API Key失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -83,7 +82,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
await fetchApiKeys() await fetchApiKeys()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed')) throw new Error(response.message || '切换状态失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -103,7 +102,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
await fetchApiKeys() await fetchApiKeys()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed')) throw new Error(response.message || '续期失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -123,7 +122,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
await fetchApiKeys() await fetchApiKeys()
return response return response
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.deleteFailed')) throw new Error(response.message || '删除失败')
} }
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -142,10 +141,10 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
if (response.success) { if (response.success) {
return response.stats return response.stats
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed')) throw new Error(response.message || '获取统计失败')
} }
} catch (err) { } catch (err) {
console.error(i18n.global.t('common.errors.getApiKeyStatsFailed'), err) console.error('获取API Key统计失败:', err)
return null return null
} }
} }
@@ -167,10 +166,10 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
if (response.success) { if (response.success) {
return response.data || [] return response.data || []
} else { } else {
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed')) throw new Error(response.message || '获取标签失败')
} }
} catch (err) { } catch (err) {
console.error(i18n.global.t('common.errors.getTagsFailed'), err) console.error('获取标签失败:', err)
return [] return []
} }
} }

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiStatsClient } from '@/config/apiStats' import { apiStatsClient } from '@/config/apiStats'
import i18n from '@/i18n'
export const useApiStatsStore = defineStore('apistats', () => { export const useApiStatsStore = defineStore('apistats', () => {
// 状态 // 状态
@@ -94,7 +93,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
} }
if (!apiKey.value.trim()) { if (!apiKey.value.trim()) {
error.value = i18n.global.t('apiStats.enterApiKey') error.value = '请输入 API Key'
return return
} }
@@ -126,18 +125,14 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 更新 URL // 更新 URL
updateURL() updateURL()
} else { } else {
throw new Error( throw new Error(statsResult.message || '查询失败')
statsResult.message || i18n.global.t('common.errors.requestFailed', { status: 500 })
)
} }
} else { } else {
throw new Error( throw new Error(idResult.message || '获取 API Key ID 失败')
idResult.message || i18n.global.t('common.errors.requestFailed', { status: 500 })
)
} }
} catch (err) { } catch (err) {
console.error('Query stats error:', err) console.error('Query stats error:', err)
error.value = err.message || i18n.global.t('apiStats.errors.queryStatsFailed') error.value = err.message || '查询统计数据失败,请检查您的 API Key 是否正确'
statsData.value = null statsData.value = null
modelStats.value = [] modelStats.value = []
apiId.value = null apiId.value = null
@@ -214,7 +209,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
if (result.success) { if (result.success) {
modelStats.value = result.data || [] modelStats.value = result.data || []
} else { } else {
throw new Error(result.message || i18n.global.t('apiStats.errors.loadModelStatsFailed')) throw new Error(result.message || '加载模型统计失败')
} }
} catch (err) { } catch (err) {
console.error('Load model stats error:', err) console.error('Load model stats error:', err)
@@ -271,13 +266,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 清除错误信息 // 清除错误信息
error.value = '' error.value = ''
} else { } else {
throw new Error( throw new Error(result.message || '查询失败')
result.message || i18n.global.t('common.errors.requestFailed', { status: 500 })
)
} }
} catch (err) { } catch (err) {
console.error('Load stats with apiId error:', err) console.error('Load stats with apiId error:', err)
error.value = err.message || i18n.global.t('apiStats.errors.queryStatsFailed') error.value = err.message || '查询统计数据失败'
statsData.value = null statsData.value = null
modelStats.value = [] modelStats.value = []
} finally { } finally {
@@ -337,7 +330,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
async function queryBatchStats() { async function queryBatchStats() {
const keys = parseApiKeys() const keys = parseApiKeys()
if (keys.length === 0) { if (keys.length === 0) {
error.value = i18n.global.t('apiStats.errors.enterAtLeastOneKey') error.value = '请输入至少一个有效的 API Key'
return return
} }
@@ -367,7 +360,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
}) })
if (validIds.length === 0) { if (validIds.length === 0) {
throw new Error(i18n.global.t('common.errors.allApiKeysInvalid')) throw new Error('所有 API Key 都无效')
} }
apiIds.value = validIds apiIds.value = validIds
@@ -391,11 +384,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 更新 URL // 更新 URL
updateBatchURL() updateBatchURL()
} else { } else {
throw new Error(batchResult.message || i18n.global.t('apiStats.errors.batchQueryFailed')) throw new Error(batchResult.message || '批量查询失败')
} }
} catch (err) { } catch (err) {
console.error('Batch query error:', err) console.error('Batch query error:', err)
error.value = err.message || i18n.global.t('apiStats.errors.batchQueryFailed') error.value = err.message || '批量查询统计数据失败'
aggregatedStats.value = null aggregatedStats.value = null
individualStats.value = [] individualStats.value = []
} finally { } finally {
@@ -415,7 +408,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
if (result.success) { if (result.success) {
modelStats.value = result.data || [] modelStats.value = result.data || []
} else { } else {
throw new Error(result.message || i18n.global.t('apiStats.errors.batchModelStatsFailed')) throw new Error(result.message || '加载批量模型统计失败')
} }
} catch (err) { } catch (err) {
console.error('Load batch model stats error:', err) console.error('Load batch model stats error:', err)

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import router from '@/router' import router from '@/router'
import i18n from '@/i18n'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@@ -40,10 +39,10 @@ export const useAuthStore = defineStore('auth', () => {
await router.push('/dashboard') await router.push('/dashboard')
} else { } else {
loginError.value = result.message || i18n.global.t('login.loginFailed') loginError.value = result.message || '登录失败'
} }
} catch (error) { } catch (error) {
loginError.value = error.message || i18n.global.t('login.loginFailedCheck') loginError.value = error.message || '登录失败,请检查用户名和密码'
} finally { } finally {
loginLoading.value = false loginLoading.value = false
} }
@@ -103,11 +102,11 @@ export const useAuthStore = defineStore('auth', () => {
// 设置页面标题 // 设置页面标题
if (result.data.siteName) { if (result.data.siteName) {
document.title = `${result.data.siteName} - ${i18n.global.t('header.adminPanel')}` document.title = `${result.data.siteName} - 管理后台`
} }
} }
} catch (error) { } catch (error) {
console.error(i18n.global.t('common.errors.loadOemSettingsFailed'), error) console.error('加载OEM设置失败:', error)
} finally { } finally {
oemLoading.value = false oemLoading.value = false
} }

View File

@@ -1,6 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import i18n from '@/i18n'
export const useClientsStore = defineStore('clients', { export const useClientsStore = defineStore('clients', {
state: () => ({ state: () => ({
@@ -25,13 +24,13 @@ export const useClientsStore = defineStore('clients', {
if (response.success) { if (response.success) {
this.supportedClients = response.data || [] this.supportedClients = response.data || []
} else { } else {
this.error = response.message || i18n.global.t('common.errors.loadSupportedClientsFailed') this.error = response.message || '加载支持的客户端失败'
console.error('Failed to load supported clients:', this.error) console.error('Failed to load supported clients:', this.error)
} }
return this.supportedClients return this.supportedClients
} catch (error) { } catch (error) {
this.error = error.message || i18n.global.t('common.errors.loadSupportedClientsFailed') this.error = error.message || '加载支持的客户端失败'
console.error('Error loading supported clients:', error) console.error('Error loading supported clients:', error)
return [] return []
} finally { } finally {

View File

@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import i18n from '@/i18n'
export const useDashboardStore = defineStore('dashboard', () => { export const useDashboardStore = defineStore('dashboard', () => {
// 状态 // 状态
@@ -42,7 +41,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
realtimeTPM: 0, realtimeTPM: 0,
metricsWindow: 5, metricsWindow: 5,
isHistoricalMetrics: false, isHistoricalMetrics: false,
systemStatus: i18n.global.t('system.status.normal'), systemStatus: '正常',
uptime: 0, uptime: 0,
systemTimezone: 8 // 默认 UTC+8 systemTimezone: 8 // 默认 UTC+8
}) })
@@ -69,9 +68,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
customEnd: '', customEnd: '',
customRange: null, customRange: null,
presetOptions: [ presetOptions: [
{ value: 'today', label: i18n.global.t('dashboard.today'), days: 1 }, { value: 'today', label: '今日', days: 1 },
{ value: '7days', label: i18n.global.t('dashboard.last7Days'), days: 7 }, { value: '7days', label: '7天', days: 7 },
{ value: '30days', label: i18n.global.t('dashboard.last30Days'), days: 30 } { value: '30days', label: '30天', days: 30 }
] ]
}) })
@@ -90,11 +89,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
const minutes = Math.floor((seconds % 3600) / 60) const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) { if (days > 0) {
return i18n.global.t('dashboard.uptimeFormat.daysHours', { days, hours }) return `${days}${hours}小时`
} else if (hours > 0) { } else if (hours > 0) {
return i18n.global.t('dashboard.uptimeFormat.hoursMinutes', { hours, minutes }) return `${hours}小时 ${minutes}分钟`
} else { } else {
return i18n.global.t('dashboard.uptimeFormat.minutes', { minutes }) return `${minutes}分钟`
} }
}) })
@@ -199,9 +198,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
realtimeTPM: realtimeMetrics.tpm || 0, realtimeTPM: realtimeMetrics.tpm || 0,
metricsWindow: realtimeMetrics.windowMinutes || 5, metricsWindow: realtimeMetrics.windowMinutes || 5,
isHistoricalMetrics: realtimeMetrics.isHistorical || false, isHistoricalMetrics: realtimeMetrics.isHistorical || false,
systemStatus: systemHealth.redisConnected systemStatus: systemHealth.redisConnected ? '正常' : '异常',
? i18n.global.t('system.status.normal')
: i18n.global.t('system.status.abnormal'),
uptime: systemHealth.uptime || 0, uptime: systemHealth.uptime || 0,
systemTimezone: dashboardResponse.data.systemTimezone || 8 systemTimezone: dashboardResponse.data.systemTimezone || 8
} }
@@ -221,7 +218,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
} }
} }
} catch (error) { } catch (error) {
console.error(i18n.global.t('common.errors.loadDashboardFailed'), error) console.error('加载仪表板数据失败:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -309,7 +306,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
trendData.value = response.data trendData.value = response.data
} }
} catch (error) { } catch (error) {
console.error(i18n.global.t('common.errors.loadUsageTrendFailed'), error) console.error('加载使用趋势失败:', error)
} }
} }
@@ -400,7 +397,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
dashboardModelStats.value = response.data dashboardModelStats.value = response.data
} }
} catch (error) { } catch (error) {
console.error(i18n.global.t('common.errors.loadModelStatsFailed'), error) console.error('加载模型统计失败:', error)
} }
} }
@@ -502,7 +499,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
} }
} }
} catch (error) { } catch (error) {
console.error(i18n.global.t('common.errors.loadApiKeysTrendFailed'), error) console.error('加载API Keys趋势失败:', error)
} }
} }
@@ -639,14 +636,14 @@ export const useDashboardStore = defineStore('dashboard', () => {
// 小时粒度:限制 24 小时 // 小时粒度:限制 24 小时
const hoursDiff = (end - start) / (1000 * 60 * 60) const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) { if (hoursDiff > 24) {
showToast(i18n.global.t('dashboard.errors.rangeTooLongHour'), 'warning') showToast('小时粒度下日期范围不能超过24小时', 'warning')
return return
} }
} else { } else {
// 天粒度:限制 31 天 // 天粒度:限制 31 天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 31) { if (daysDiff > 31) {
showToast(i18n.global.t('dashboard.errors.rangeTooLongDay'), 'warning') showToast('日期范围不能超过 31 天', 'warning')
return return
} }
} }
@@ -665,13 +662,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
// 根据粒度更新预设选项 // 根据粒度更新预设选项
if (granularity === 'hour') { if (granularity === 'hour') {
dateFilter.value.presetOptions = [ dateFilter.value.presetOptions = [
{ { value: 'last24h', label: '近24小时', hours: 24 },
value: 'last24h', { value: 'yesterday', label: '昨天', hours: 24 },
label: i18n.global.t('dashboard.usageTrend.periodOptions.last24Hours'), { value: 'dayBefore', label: '前天', hours: 24 }
hours: 24
},
{ value: 'yesterday', label: i18n.global.t('dashboard.yesterday'), hours: 24 },
{ value: 'dayBefore', label: i18n.global.t('dashboard.dayBefore'), hours: 24 }
] ]
// 检查当前自定义日期范围是否超过24小时 // 检查当前自定义日期范围是否超过24小时
@@ -684,7 +677,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
const end = new Date(dateFilter.value.customRange[1]) const end = new Date(dateFilter.value.customRange[1])
const hoursDiff = (end - start) / (1000 * 60 * 60) const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) { if (hoursDiff > 24) {
showToast(i18n.global.t('dashboard.errors.rangeTooLongHourSwitched'), 'warning') showToast('小时粒度下日期范围不能超过24小时已切换到近24小时', 'warning')
setDateFilterPreset('last24h') setDateFilterPreset('last24h')
return return
} }
@@ -698,9 +691,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
} else { } else {
// 天粒度 // 天粒度
dateFilter.value.presetOptions = [ dateFilter.value.presetOptions = [
{ value: 'today', label: i18n.global.t('dashboard.today'), days: 1 }, { value: 'today', label: '今日', days: 1 },
{ value: '7days', label: i18n.global.t('dashboard.last7Days'), days: 7 }, { value: '7days', label: '7天', days: 7 },
{ value: '30days', label: i18n.global.t('dashboard.last30Days'), days: 30 } { value: '30days', label: '30天', days: 30 }
] ]
// 如果当前是小时粒度的预设,切换到天粒度的默认预设 // 如果当前是小时粒度的预设,切换到天粒度的默认预设

View File

@@ -1,47 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { i18n, SUPPORTED_LOCALES, getSupportedLocalesWithI18n } from '@/i18n'
export const useLocaleStore = defineStore('locale', () => {
const currentLocale = ref(i18n.global.locale.value)
// 切换语言
const setLocale = (locale) => {
if (!SUPPORTED_LOCALES[locale]) {
console.warn(`Unsupported locale: ${locale}`)
return
}
currentLocale.value = locale
i18n.global.locale.value = locale
localStorage.setItem('app-locale', locale)
// 更新HTML lang属性
document.documentElement.setAttribute('lang', locale)
}
// 获取当前语言信息兼容i18n
const getCurrentLocaleInfo = (t = null) => {
if (t) {
const supportedLocales = getSupportedLocalesWithI18n(t)
return supportedLocales[currentLocale.value] || supportedLocales['zh-cn']
}
return SUPPORTED_LOCALES[currentLocale.value] || SUPPORTED_LOCALES['zh-cn']
}
// 获取所有支持的语言兼容i18n
const getSupportedLocales = (t = null) => {
const supportedLocales = t ? getSupportedLocalesWithI18n(t) : SUPPORTED_LOCALES
return Object.entries(supportedLocales).map(([key, value]) => ({
code: key,
...value
}))
}
return {
currentLocale,
setLocale,
getCurrentLocaleInfo,
getSupportedLocales
}
})

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import i18n from '@/i18n'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
// 状态 // 状态
@@ -78,7 +77,7 @@ export const useSettingsStore = defineStore('settings', () => {
const applyOemSettings = () => { const applyOemSettings = () => {
// 更新页面标题 // 更新页面标题
if (oemSettings.value.siteName) { if (oemSettings.value.siteName) {
document.title = `${oemSettings.value.siteName} - ${i18n.global.t('header.adminPanel')}` document.title = `${oemSettings.value.siteName} - 管理后台`
} }
// 更新favicon // 更新favicon
@@ -95,9 +94,7 @@ export const useSettingsStore = defineStore('settings', () => {
// 格式化日期时间 // 格式化日期时间
const formatDateTime = (dateString) => { const formatDateTime = (dateString) => {
if (!dateString) return '' if (!dateString) return ''
const localeMap = { 'zh-cn': 'zh-CN', 'zh-tw': 'zh-TW', en: 'en-US' } return new Date(dateString).toLocaleString('zh-CN', {
const currentLocale = localeMap[i18n.global.locale.value] || 'en-US'
return new Date(dateString).toLocaleString(currentLocale, {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -113,13 +110,13 @@ export const useSettingsStore = defineStore('settings', () => {
// 检查文件大小 (350KB) // 检查文件大小 (350KB)
if (file.size > 350 * 1024) { if (file.size > 350 * 1024) {
errors.push(i18n.global.t('settings.validation.iconTooLarge')) errors.push('图标文件大小不能超过 350KB')
} }
// 检查文件类型 // 检查文件类型
const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'] const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
errors.push(i18n.global.t('settings.validation.iconTypeNotSupported')) errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件')
} }
return { return {

View File

@@ -1,6 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import axios from 'axios' import axios from 'axios'
import i18n from '@/i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
const API_BASE = '/users' const API_BASE = '/users'
@@ -41,7 +40,7 @@ export const useUserStore = defineStore('user', {
return response.data return response.data
} else { } else {
throw new Error(response.data.message || i18n.global.t('user.login.loginFailed')) throw new Error(response.data.message || 'Login failed')
} }
} catch (error) { } catch (error) {
this.clearAuth() this.clearAuth()
@@ -116,9 +115,7 @@ export const useUserStore = defineStore('user', {
this.clearAuth() this.clearAuth()
// If it's a disabled account error, throw a specific error // If it's a disabled account error, throw a specific error
if (error.response?.status === 403) { if (error.response?.status === 403) {
throw new Error( throw new Error(error.response.data?.message || 'Your account has been disabled')
error.response.data?.message || i18n.global.t('user.login.accountDisabled')
)
} }
} }
throw error throw error

View File

@@ -37,9 +37,7 @@ export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
.replace('ss', seconds) .replace('ss', seconds)
} }
// 相对时间格式化(使用 i18n // 相对时间格式化
import i18n from '@/i18n'
export function formatRelativeTime(date) { export function formatRelativeTime(date) {
if (!date) return '' if (!date) return ''
@@ -52,13 +50,13 @@ export function formatRelativeTime(date) {
const diffDays = Math.floor(diffHours / 24) const diffDays = Math.floor(diffHours / 24)
if (diffDays > 0) { if (diffDays > 0) {
return i18n.global.t('common.time.daysAgo', { days: diffDays }) return `${diffDays}天前`
} else if (diffHours > 0) { } else if (diffHours > 0) {
return i18n.global.t('common.time.hoursAgo', { hours: diffHours }) return `${diffHours}小时前`
} else if (diffMins > 0) { } else if (diffMins > 0) {
return i18n.global.t('common.time.minutesAgo', { minutes: diffMins }) return `${diffMins}分钟前`
} else { } else {
return i18n.global.t('common.time.justNow') return '刚刚'
} }
} }

View File

@@ -4,10 +4,10 @@
<div class="mb-4 flex flex-col gap-4 sm:mb-6"> <div class="mb-4 flex flex-col gap-4 sm:mb-6">
<div> <div>
<h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl"> <h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl">
{{ t('accounts.title') }} 账户管理
</h3> </h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base"> <p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
{{ t('accounts.description') }} 管理您的 ClaudeGeminiOpenAIAzure OpenAIOpenAI-Responses CCR 账户及代理配置
</p> </p>
</div> </div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -23,7 +23,7 @@
icon="fa-sort-amount-down" icon="fa-sort-amount-down"
icon-color="text-indigo-500" icon-color="text-indigo-500"
:options="sortOptions" :options="sortOptions"
:placeholder="t('accounts.sortBy')" placeholder="选择排序"
@change="sortAccounts()" @change="sortAccounts()"
/> />
</div> </div>
@@ -38,7 +38,7 @@
icon="fa-server" icon="fa-server"
icon-color="text-blue-500" icon-color="text-blue-500"
:options="platformOptions" :options="platformOptions"
:placeholder="t('accounts.selectPlatform')" placeholder="选择平台"
@change="filterByPlatform" @change="filterByPlatform"
/> />
</div> </div>
@@ -53,14 +53,18 @@
icon="fa-layer-group" icon="fa-layer-group"
icon-color="text-purple-500" icon-color="text-purple-500"
:options="groupOptions" :options="groupOptions"
:placeholder="t('accounts.selectGroup')" placeholder="选择分组"
@change="filterByGroup" @change="filterByGroup"
/> />
</div> </div>
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
<div class="relative"> <div class="relative">
<el-tooltip :content="t('accounts.refreshTooltip')" effect="dark" placement="bottom"> <el-tooltip
content="刷新数据 (Ctrl/⌘+点击强制刷新所有缓存)"
effect="dark"
placement="bottom"
>
<button <button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto" class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading" :disabled="accountsLoading"
@@ -77,7 +81,7 @@
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt' accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'
]" ]"
/> />
<span class="relative">{{ t('accounts.refresh') }}</span> <span class="relative">刷新</span>
</button> </button>
</el-tooltip> </el-tooltip>
</div> </div>
@@ -89,14 +93,14 @@
@click.stop="openCreateAccountModal" @click.stop="openCreateAccountModal"
> >
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span>{{ t('accounts.addAccount') }}</span> <span>添加账户</span>
</button> </button>
</div> </div>
</div> </div>
<div v-if="accountsLoading" class="py-12 text-center"> <div v-if="accountsLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" /> <div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">{{ t('accounts.loadingAccounts') }}</p> <p class="text-gray-500 dark:text-gray-400">正在加载账户...</p>
</div> </div>
<div v-else-if="sortedAccounts.length === 0" class="py-12 text-center"> <div v-else-if="sortedAccounts.length === 0" class="py-12 text-center">
@@ -105,10 +109,8 @@
> >
<i class="fas fa-user-circle text-xl text-gray-400" /> <i class="fas fa-user-circle text-xl text-gray-400" />
</div> </div>
<p class="text-lg text-gray-500 dark:text-gray-400">{{ t('accounts.noAccounts') }}</p> <p class="text-lg text-gray-500 dark:text-gray-400">暂无账户</p>
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500"> <p class="mt-2 text-sm text-gray-400 dark:text-gray-500">点击上方按钮添加您的第一个账户</p>
{{ t('accounts.noAccountsHint') }}
</p>
</div> </div>
<!-- 桌面端表格视图 --> <!-- 桌面端表格视图 -->
@@ -120,7 +122,7 @@
class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600" class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('name')" @click="sortAccounts('name')"
> >
{{ t('accounts.name') }} 名称
<i <i
v-if="accountsSortBy === 'name'" v-if="accountsSortBy === 'name'"
:class="[ :class="[
@@ -135,7 +137,7 @@
class="w-[15%] min-w-[120px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600" class="w-[15%] min-w-[120px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('platform')" @click="sortAccounts('platform')"
> >
{{ t('accounts.platformType') }} 平台/类型
<i <i
v-if="accountsSortBy === 'platform'" v-if="accountsSortBy === 'platform'"
:class="[ :class="[
@@ -150,7 +152,7 @@
class="w-[12%] min-w-[100px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600" class="w-[12%] min-w-[100px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('status')" @click="sortAccounts('status')"
> >
{{ t('accounts.status') }} 状态
<i <i
v-if="accountsSortBy === 'status'" v-if="accountsSortBy === 'status'"
:class="[ :class="[
@@ -165,7 +167,7 @@
class="w-[8%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600" class="w-[8%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('priority')" @click="sortAccounts('priority')"
> >
{{ t('accounts.priority') }} 优先级
<i <i
v-if="accountsSortBy === 'priority'" v-if="accountsSortBy === 'priority'"
:class="[ :class="[
@@ -179,40 +181,40 @@
<th <th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
{{ t('accounts.proxy') }} 代理
</th> </th>
<th <th
class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
{{ t('accounts.dailyUsage') }} 今日使用
</th> </th>
<th <th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{{ t('accounts.sessionWindow') }}</span> <span>会话窗口</span>
<el-tooltip placement="top"> <el-tooltip placement="top">
<template #content> <template #content>
<div class="space-y-2"> <div class="space-y-2">
<div>{{ t('accounts.sessionWindowTooltip.title') }}</div> <div>会话窗口进度表示5小时窗口的时间进度</div>
<div class="space-y-1 text-xs"> <div class="space-y-1 text-xs">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600" class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
></div> ></div>
<span>{{ t('accounts.sessionWindowTooltip.normal') }}</span> <span>正常请求正常处理</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500" class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
></div> ></div>
<span>{{ t('accounts.sessionWindowTooltip.warning') }}</span> <span>警告接近限制</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600" class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
></div> ></div>
<span>{{ t('accounts.sessionWindowTooltip.rejected') }}</span> <span>拒绝达到速率限制</span>
</div> </div>
</div> </div>
</div> </div>
@@ -226,12 +228,12 @@
<th <th
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
{{ t('accounts.lastUsed') }} 最后使用
</th> </th>
<th <th
class="w-[15%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[15%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
{{ t('accounts.actions') }} 操作
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -256,19 +258,19 @@
v-if="account.accountType === 'dedicated'" v-if="account.accountType === 'dedicated'"
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800" class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800"
> >
<i class="fas fa-lock mr-1" />{{ t('accounts.dedicated') }} <i class="fas fa-lock mr-1" />专属
</span> </span>
<span <span
v-else-if="account.accountType === 'group'" v-else-if="account.accountType === 'group'"
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
> >
<i class="fas fa-layer-group mr-1" />{{ t('accounts.groupScheduling') }} <i class="fas fa-layer-group mr-1" />分组调度
</span> </span>
<span <span
v-else v-else
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800" class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
> >
<i class="fas fa-share-alt mr-1" />{{ t('accounts.shared') }} <i class="fas fa-share-alt mr-1" />共享
</span> </span>
</div> </div>
<!-- 显示所有分组 - 换行显示 --> <!-- 显示所有分组 - 换行显示 -->
@@ -280,7 +282,7 @@
v-for="group in account.groupInfos" v-for="group in account.groupInfos"
:key="group.id" :key="group.id"
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400" class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
:title="t('accounts.belongsToGroup', { name: group.name })" :title="`所属分组: ${group.name}`"
> >
<i class="fas fa-folder mr-1" />{{ group.name }} <i class="fas fa-folder mr-1" />{{ group.name }}
</span> </span>
@@ -388,9 +390,7 @@
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1" class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
> >
<i class="fas fa-question text-xs text-gray-700" /> <i class="fas fa-question text-xs text-gray-700" />
<span class="text-xs font-semibold text-gray-800">{{ <span class="text-xs font-semibold text-gray-800">未知</span>
t('accounts.unknown')
}}</span>
</div> </div>
</div> </div>
</td> </td>
@@ -426,14 +426,14 @@
/> />
{{ {{
account.status === 'blocked' account.status === 'blocked'
? t('accounts.blocked') ? '已封锁'
: account.status === 'unauthorized' : account.status === 'unauthorized'
? t('accounts.abnormal') ? '异常'
: account.status === 'temp_error' : account.status === 'temp_error'
? t('accounts.tempError') ? '临时异常'
: account.isActive : account.isActive
? t('accounts.normal') ? '正常'
: t('accounts.abnormal') : '异常'
}} }}
</span> </span>
<span <span
@@ -444,18 +444,14 @@
class="inline-flex items-center rounded-full bg-yellow-100 px-3 py-1 text-xs font-semibold text-yellow-800" class="inline-flex items-center rounded-full bg-yellow-100 px-3 py-1 text-xs font-semibold text-yellow-800"
> >
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
{{ t('accounts.rateLimited') }} 限流中
<span <span
v-if=" v-if="
account.rateLimitStatus && account.rateLimitStatus &&
typeof account.rateLimitStatus === 'object' && typeof account.rateLimitStatus === 'object' &&
account.rateLimitStatus.minutesRemaining > 0 account.rateLimitStatus.minutesRemaining > 0
" "
>({{ >({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span
t('accounts.rateLimitTime', {
time: formatRateLimitTime(account.rateLimitStatus.minutesRemaining)
})
}})</span
> >
</span> </span>
<span <span
@@ -463,7 +459,7 @@
class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700" class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700"
> >
<i class="fas fa-pause-circle mr-1" /> <i class="fas fa-pause-circle mr-1" />
{{ t('accounts.notSchedulable') }} 不可调度
<el-tooltip <el-tooltip
v-if="getSchedulableReason(account)" v-if="getSchedulableReason(account)"
:content="getSchedulableReason(account)" :content="getSchedulableReason(account)"
@@ -484,7 +480,7 @@
v-if="account.accountType === 'dedicated'" v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500 dark:text-gray-400" class="text-xs text-gray-500 dark:text-gray-400"
> >
{{ t('accounts.bound', { count: account.boundApiKeysCount || 0 }) }} 绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span> </span>
</div> </div>
</td> </td>
@@ -524,14 +520,14 @@
> >
{{ formatProxyDisplay(account.proxy) }} {{ formatProxyDisplay(account.proxy) }}
</div> </div>
<div v-else class="text-gray-400">{{ t('accounts.noProxy') }}</div> <div v-else class="text-gray-400">无代理</div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm"> <td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1"> <div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500" /> <div class="h-2 w-2 rounded-full bg-blue-500" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100" <span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{{ account.usage.daily.requests || 0 }} {{ t('accounts.requests') }}</span >{{ account.usage.daily.requests || 0 }} </span
> >
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -550,10 +546,10 @@
v-if="account.usage.averages && account.usage.averages.rpm > 0" v-if="account.usage.averages && account.usage.averages.rpm > 0"
class="text-xs text-gray-500 dark:text-gray-400" class="text-xs text-gray-500 dark:text-gray-400"
> >
{{ t('accounts.averageRpm', { rpm: account.usage.averages.rpm.toFixed(2) }) }} 平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div> </div>
</div> </div>
<div v-else class="text-xs text-gray-400">{{ t('accounts.noData') }}</div> <div v-else class="text-xs text-gray-400">暂无数据</div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4"> <td class="whitespace-nowrap px-3 py-4">
<div <div
@@ -613,11 +609,7 @@
v-if="account.sessionWindow.remainingTime > 0" v-if="account.sessionWindow.remainingTime > 0"
class="font-medium text-indigo-600 dark:text-indigo-400" class="font-medium text-indigo-600 dark:text-indigo-400"
> >
{{ 剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
t('accounts.remaining', {
time: formatRemainingTime(account.sessionWindow.remainingTime)
})
}}
</div> </div>
</div> </div>
</div> </div>
@@ -625,9 +617,7 @@
<div v-else-if="account.platform === 'claude-console'" class="space-y-2"> <div v-else-if="account.platform === 'claude-console'" class="space-y-2">
<div v-if="Number(account.dailyQuota) > 0"> <div v-if="Number(account.dailyQuota) > 0">
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-300">{{ <span class="text-gray-600 dark:text-gray-300">额度进度</span>
t('accounts.quotaProgress')
}}</span>
<span class="font-medium text-gray-700 dark:text-gray-200"> <span class="font-medium text-gray-700 dark:text-gray-200">
{{ getQuotaUsagePercent(account).toFixed(1) }}% {{ getQuotaUsagePercent(account).toFixed(1) }}%
</span> </span>
@@ -651,10 +641,10 @@
</span> </span>
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400"> <div class="text-xs text-gray-600 dark:text-gray-400">
{{ t('accounts.remainingQuota', { amount: formatRemainingQuota(account) }) }} 剩余 ${{ formatRemainingQuota(account) }}
<span class="ml-2 text-gray-400">{{ <span class="ml-2 text-gray-400"
t('accounts.reset', { time: account.quotaResetTime || '00:00' }) >重置 {{ account.quotaResetTime || '00:00' }}</span
}}</span> >
</div> </div>
</div> </div>
<div v-else class="text-sm text-gray-400"> <div v-else class="text-sm text-gray-400">
@@ -692,15 +682,11 @@
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' : 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
]" ]"
:disabled="account.isResetting" :disabled="account.isResetting"
:title=" :title="account.isResetting ? '重置中...' : '重置所有异常状态'"
account.isResetting
? t('accounts.resetting')
: t('accounts.resetStatusTooltip')
"
@click="resetAccountStatus(account)" @click="resetAccountStatus(account)"
> >
<i :class="['fas fa-redo', account.isResetting ? 'animate-spin' : '']" /> <i :class="['fas fa-redo', account.isResetting ? 'animate-spin' : '']" />
<span class="ml-1">{{ t('accounts.resetStatus') }}</span> <span class="ml-1">重置状态</span>
</button> </button>
<button <button
:class="[ :class="[
@@ -712,33 +698,27 @@
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]" ]"
:disabled="account.isTogglingSchedulable" :disabled="account.isTogglingSchedulable"
:title=" :title="account.schedulable ? '点击禁用调度' : '点击启用调度'"
account.schedulable
? t('accounts.disableTooltip')
: t('accounts.enableTooltip')
"
@click="toggleSchedulable(account)" @click="toggleSchedulable(account)"
> >
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" /> <i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" />
<span class="ml-1">{{ <span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
account.schedulable ? t('accounts.scheduling') : t('accounts.disabled')
}}</span>
</button> </button>
<button <button
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200" class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
:title="t('accounts.editTooltip')" :title="'编辑账户'"
@click="editAccount(account)" @click="editAccount(account)"
> >
<i class="fas fa-edit" /> <i class="fas fa-edit" />
<span class="ml-1">{{ t('accounts.edit') }}</span> <span class="ml-1">编辑</span>
</button> </button>
<button <button
class="rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-200" class="rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-200"
:title="t('accounts.deleteTooltip')" :title="'删除账户'"
@click="deleteAccount(account)" @click="deleteAccount(account)"
> >
<i class="fas fa-trash" /> <i class="fas fa-trash" />
<span class="ml-1">{{ t('accounts.delete') }}</span> <span class="ml-1">删除</span>
</button> </button>
</div> </div>
</td> </td>
@@ -819,14 +799,12 @@
<!-- 使用统计 --> <!-- 使用统计 -->
<div class="mb-3 grid grid-cols-2 gap-3"> <div class="mb-3 grid grid-cols-2 gap-3">
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
{{ t('accounts.dailyUsageLabel') }}
</p>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" /> <div class="h-1.5 w-1.5 rounded-full bg-blue-500" />
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ account.usage?.daily?.requests || 0 }} {{ t('accounts.requests') }} {{ account.usage?.daily?.requests || 0 }}
</p> </p>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -844,9 +822,7 @@
</div> </div>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p>
{{ t('accounts.sessionWindowLabel') }}
</p>
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1"> <div v-if="account.usage && account.usage.sessionWindow" class="space-y-1">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" /> <div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
@@ -878,10 +854,11 @@
> >
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="font-medium text-gray-600 dark:text-gray-300">{{ <span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
t('accounts.sessionWindowLabel') <el-tooltip
}}</span> content="会话窗口进度不代表使用量仅表示距离下一个5小时窗口的剩余时间"
<el-tooltip :content="t('accounts.sessionWindowTooltipMobile')" placement="top"> placement="top"
>
<i <i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600" class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
/> />
@@ -913,27 +890,17 @@
v-if="account.sessionWindow.remainingTime > 0" v-if="account.sessionWindow.remainingTime > 0"
class="font-medium text-indigo-600" class="font-medium text-indigo-600"
> >
{{ 剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
t('accounts.remaining', {
time: formatRemainingTime(account.sessionWindow.remainingTime)
})
}}
</span> </span>
<span v-else class="text-gray-500"> {{ t('accounts.ended') }} </span> <span v-else class="text-gray-500"> 已结束 </span>
</div> </div>
</div> </div>
<!-- 最后使用时间 --> <!-- 最后使用时间 -->
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ <span class="text-gray-500 dark:text-gray-400">最后使用</span>
t('accounts.lastUsedLabel')
}}</span>
<span class="text-gray-700 dark:text-gray-200"> <span class="text-gray-700 dark:text-gray-200">
{{ {{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }}
account.lastUsedAt
? formatRelativeTime(account.lastUsedAt)
: t('accounts.neverUsed')
}}
</span> </span>
</div> </div>
@@ -942,7 +909,7 @@
v-if="account.proxyConfig && account.proxyConfig.type !== 'none'" v-if="account.proxyConfig && account.proxyConfig.type !== 'none'"
class="flex items-center justify-between text-xs" class="flex items-center justify-between text-xs"
> >
<span class="text-gray-500 dark:text-gray-400">{{ t('accounts.proxyLabel') }}</span> <span class="text-gray-500 dark:text-gray-400">代理</span>
<span class="text-gray-700 dark:text-gray-200"> <span class="text-gray-700 dark:text-gray-200">
{{ account.proxyConfig.type.toUpperCase() }} {{ account.proxyConfig.type.toUpperCase() }}
</span> </span>
@@ -950,9 +917,7 @@
<!-- 调度优先级 --> <!-- 调度优先级 -->
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ <span class="text-gray-500 dark:text-gray-400">优先级</span>
t('accounts.priorityLabel')
}}</span>
<span class="font-medium text-gray-700 dark:text-gray-200"> <span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }} {{ account.priority || 50 }}
</span> </span>
@@ -972,7 +937,7 @@
@click="toggleSchedulable(account)" @click="toggleSchedulable(account)"
> >
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" /> <i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
{{ account.schedulable ? t('accounts.pause') : t('accounts.enable') }} {{ account.schedulable ? '暂停' : '启用' }}
</button> </button>
<button <button
@@ -980,7 +945,7 @@
@click="editAccount(account)" @click="editAccount(account)"
> >
<i class="fas fa-edit mr-1" /> <i class="fas fa-edit mr-1" />
{{ t('accounts.edit') }} 编辑
</button> </button>
<button <button
@@ -1036,7 +1001,6 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
@@ -1045,9 +1009,6 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue'
// 国际化
const { t } = useI18n()
// 使用确认弹窗 // 使用确认弹窗
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm() const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
@@ -1069,30 +1030,30 @@ const groupMembersLoaded = ref(false)
const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>> const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>>
// 下拉选项数据 // 下拉选项数据
const sortOptions = computed(() => [ const sortOptions = ref([
{ value: 'name', label: t('accounts.sortByName'), icon: 'fa-font' }, { value: 'name', label: '按名称排序', icon: 'fa-font' },
{ value: 'dailyTokens', label: t('accounts.sortByDailyTokens'), icon: 'fa-coins' }, { value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
{ value: 'dailyRequests', label: t('accounts.sortByDailyRequests'), icon: 'fa-chart-line' }, { value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
{ value: 'totalTokens', label: t('accounts.sortByTotalTokens'), icon: 'fa-database' }, { value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
{ value: 'lastUsed', label: t('accounts.sortByLastUsed'), icon: 'fa-clock' } { value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }
]) ])
const platformOptions = computed(() => [ const platformOptions = ref([
{ value: 'all', label: t('accounts.allPlatforms'), icon: 'fa-globe' }, { value: 'all', label: '所有平台', icon: 'fa-globe' },
{ value: 'claude', label: t('accounts.claudePlatform'), icon: 'fa-brain' }, { value: 'claude', label: 'Claude', icon: 'fa-brain' },
{ value: 'claude-console', label: t('accounts.claudeConsolePlatform'), icon: 'fa-terminal' }, { value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
{ value: 'gemini', label: t('accounts.geminiPlatform'), icon: 'fa-google' }, { value: 'gemini', label: 'Gemini', icon: 'fa-google' },
{ value: 'openai', label: t('accounts.openaiPlatform'), icon: 'fa-openai' }, { value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
{ value: 'azure_openai', label: t('accounts.azureOpenaiPlatform'), icon: 'fab fa-microsoft' }, { value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
{ value: 'bedrock', label: t('accounts.bedrockPlatform'), icon: 'fab fa-aws' }, { value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
{ value: 'openai-responses', label: t('accounts.openaiResponsesPlatform'), icon: 'fa-server' }, { value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
{ value: 'ccr', label: t('accounts.ccrPlatform'), icon: 'fa-code-branch' } { value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
]) ])
const groupOptions = computed(() => { const groupOptions = computed(() => {
const options = [ const options = [
{ value: 'all', label: t('accounts.allAccounts'), icon: 'fa-globe' }, { value: 'all', label: '所有账户', icon: 'fa-globe' },
{ value: 'ungrouped', label: t('accounts.ungroupedAccounts'), icon: 'fa-user' } { value: 'ungrouped', label: '未分组账户', icon: 'fa-user' }
] ]
accountGroups.value.forEach((group) => { accountGroups.value.forEach((group) => {
options.push({ options.push({
@@ -1432,7 +1393,7 @@ const loadAccounts = async (forceReload = false) => {
accounts.value = filteredAccounts accounts.value = filteredAccounts
} catch (error) { } catch (error) {
showToast(t('accounts.loadAccountsFailed'), 'error') showToast('加载账户失败', 'error')
} finally { } finally {
accountsLoading.value = false accountsLoading.value = false
} }
@@ -1464,16 +1425,16 @@ const formatNumber = (num) => {
// 格式化最后使用时间 // 格式化最后使用时间
const formatLastUsed = (dateString) => { const formatLastUsed = (dateString) => {
if (!dateString) return t('accounts.neverUsed') if (!dateString) return '从未使用'
const date = new Date(dateString) const date = new Date(dateString)
const now = new Date() const now = new Date()
const diff = now - date const diff = now - date
if (diff < 60000) return t('accounts.justNow') if (diff < 60000) return '刚刚'
if (diff < 3600000) return t('accounts.minutesAgo', { minutes: Math.floor(diff / 60000) }) if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return t('accounts.hoursAgo', { hours: Math.floor(diff / 3600000) }) if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
if (diff < 604800000) return t('accounts.daysAgo', { days: Math.floor(diff / 86400000) }) if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
return date.toLocaleDateString('zh-CN') return date.toLocaleDateString('zh-CN')
} }
@@ -1570,15 +1531,15 @@ const formatSessionWindow = (windowStart, windowEnd) => {
// 格式化剩余时间 // 格式化剩余时间
const formatRemainingTime = (minutes) => { const formatRemainingTime = (minutes) => {
if (!minutes || minutes <= 0) return t('accounts.ended') if (!minutes || minutes <= 0) return '已结束'
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const mins = minutes % 60 const mins = minutes % 60
if (hours > 0) { if (hours > 0) {
return t('accounts.hoursAndMinutes', { hours, minutes: mins }) return `${hours}小时${mins}分钟`
} }
return t('accounts.minutesOnly', { minutes: mins }) return `${mins}分钟`
} }
// 格式化限流时间(支持显示天数) // 格式化限流时间(支持显示天数)
@@ -1598,18 +1559,18 @@ const formatRateLimitTime = (minutes) => {
if (days > 0) { if (days > 0) {
// 超过1天显示天数和小时 // 超过1天显示天数和小时
if (hours > 0) { if (hours > 0) {
return t('accounts.daysAndHours', { days, hours }) return `${days}天${hours}小时`
} }
return t('accounts.daysOnly', { days }) return `${days}天`
} else if (hours > 0) { } else if (hours > 0) {
// 超过1小时但不到1天显示小时和分钟 // 超过1小时但不到1天显示小时和分钟
if (mins > 0) { if (mins > 0) {
return t('accounts.hoursAndMinutes', { hours, minutes: mins }) return `${hours}小时${mins}分钟`
} }
return t('accounts.hoursOnly', { hours }) return `${hours}小时`
} else { } else {
// 不到1小时只显示分钟 // 不到1小时只显示分钟
return t('accounts.minutesOnly', { minutes: mins }) return `${mins}分钟`
} }
} }
@@ -1645,15 +1606,18 @@ const deleteAccount = async (account) => {
).length ).length
if (boundKeysCount > 0) { if (boundKeysCount > 0) {
showToast(t('accounts.cannotDeleteBoundAccount', { count: boundKeysCount }), 'error') showToast(
`无法删除此账号,有 ${boundKeysCount} 个API Key绑定到此账号请先解绑所有API Key`,
'error'
)
return return
} }
const confirmed = await showConfirm( const confirmed = await showConfirm(
t('accounts.deleteAccountTitle'), '删除账户',
t('accounts.deleteAccountMessage', { name: account.name }), `确定要删除账户 "${account.name}" \n\n此操作不可恢复`,
t('accounts.deleteAccountButton'), '删除',
t('accounts.deleteAccountCancel') '取消'
) )
if (!confirmed) return if (!confirmed) return
@@ -1681,15 +1645,15 @@ const deleteAccount = async (account) => {
const data = await apiClient.delete(endpoint) const data = await apiClient.delete(endpoint)
if (data.success) { if (data.success) {
showToast(t('accounts.accountDeleted'), 'success') showToast('账户已删除', 'success')
// 清空分组成员缓存因为账户可能从分组中移除 // 清空分组成员缓存因为账户可能从分组中移除
groupMembersLoaded.value = false groupMembersLoaded.value = false
loadAccounts() loadAccounts()
} else { } else {
showToast(data.message || t('accounts.deleteFailed'), 'error') showToast(data.message || '删除失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('accounts.deleteFailed'), 'error') showToast('删除失败', 'error')
} }
} }
@@ -1700,13 +1664,13 @@ const resetAccountStatus = async (account) => {
let confirmed = false let confirmed = false
if (window.showConfirm) { if (window.showConfirm) {
confirmed = await window.showConfirm( confirmed = await window.showConfirm(
t('accounts.resetStatusConfirmTitle'), '重置账户状态',
t('accounts.resetStatusConfirmMessage'), '确定要重置此账户的所有异常状态吗这将清除限流状态、401错误计数等所有异常标记。',
t('accounts.resetStatusConfirmButton'), '确定重置',
t('accounts.resetStatusCancelButton') '取消'
) )
} else { } else {
confirmed = confirm(t('accounts.resetStatusConfirmMessage')) confirmed = confirm('确定要重置此账户的所有异常状态吗?')
} }
if (!confirmed) return if (!confirmed) return
@@ -1727,7 +1691,7 @@ const resetAccountStatus = async (account) => {
} else if (account.platform === 'ccr') { } else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}/reset-status` endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
} else { } else {
showToast(t('accounts.unsupportedAccountTypeReset'), 'error') showToast('不支持的账户类型', 'error')
account.isResetting = false account.isResetting = false
return return
} }
@@ -1735,14 +1699,14 @@ const resetAccountStatus = async (account) => {
const data = await apiClient.post(endpoint) const data = await apiClient.post(endpoint)
if (data.success) { if (data.success) {
showToast(t('accounts.statusResetSuccess'), 'success') showToast('账户状态已重置', 'success')
// 强制刷新,绕过前端缓存,确保最终一致性 // 强制刷新,绕过前端缓存,确保最终一致性
loadAccounts(true) loadAccounts(true)
} else { } else {
showToast(data.message || t('accounts.statusResetFailed'), 'error') showToast(data.message || '状态重置失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('accounts.statusResetFailed'), 'error') showToast('状态重置失败', 'error')
} finally { } finally {
account.isResetting = false account.isResetting = false
} }
@@ -1773,7 +1737,7 @@ const toggleSchedulable = async (account) => {
} else if (account.platform === 'ccr') { } else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable` endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
} else { } else {
showToast(t('accounts.unsupportedAccountType'), 'warning') showToast('该账户类型暂不支持调度控制', 'warning')
return return
} }
@@ -1781,15 +1745,12 @@ const toggleSchedulable = async (account) => {
if (data.success) { if (data.success) {
account.schedulable = data.schedulable account.schedulable = data.schedulable
showToast( showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
data.schedulable ? t('accounts.enabledScheduling') : t('accounts.disabledScheduling'),
'success'
)
} else { } else {
showToast(data.message || t('accounts.operationFailed'), 'error') showToast(data.message || '操作失败', 'error')
} }
} catch (error) { } catch (error) {
showToast(t('accounts.schedulingToggleFailed'), 'error') showToast('切换调度状态失败', 'error')
} finally { } finally {
account.isTogglingSchedulable = false account.isTogglingSchedulable = false
} }
@@ -1798,7 +1759,7 @@ const toggleSchedulable = async (account) => {
// 处理创建成功 // 处理创建成功
const handleCreateSuccess = () => { const handleCreateSuccess = () => {
showCreateAccountModal.value = false showCreateAccountModal.value = false
showToast(t('accounts.accountCreateSuccess'), 'success') showToast('账户创建成功', 'success')
// 清空缓存,因为可能涉及分组关系变化 // 清空缓存,因为可能涉及分组关系变化
clearCache() clearCache()
loadAccounts() loadAccounts()
@@ -1807,7 +1768,7 @@ const handleCreateSuccess = () => {
// 处理编辑成功 // 处理编辑成功
const handleEditSuccess = () => { const handleEditSuccess = () => {
showEditAccountModal.value = false showEditAccountModal.value = false
showToast(t('accounts.accountUpdateSuccess'), 'success') showToast('账户更新成功', 'success')
// 清空分组成员缓存,因为账户类型和分组可能发生变化 // 清空分组成员缓存,因为账户类型和分组可能发生变化
groupMembersLoaded.value = false groupMembersLoaded.value = false
loadAccounts() loadAccounts()
@@ -1849,11 +1810,11 @@ const getClaudeAccountType = (account) => {
// 根据 has_claude_max 和 has_claude_pro 判断 // 根据 has_claude_max 和 has_claude_pro 判断
if (info.hasClaudeMax === true) { if (info.hasClaudeMax === true) {
return t('accounts.claudeMax') return 'Claude Max'
} else if (info.hasClaudePro === true) { } else if (info.hasClaudePro === true) {
return t('accounts.claudePro') return 'Claude Pro'
} else { } else {
return t('accounts.claudeFree') return 'Claude Free'
} }
} catch (e) { } catch (e) {
// 解析失败,返回默认值 // 解析失败,返回默认值
@@ -1872,13 +1833,13 @@ const getSchedulableReason = (account) => {
// Claude Console 账户的错误状态 // Claude Console 账户的错误状态
if (account.platform === 'claude-console') { if (account.platform === 'claude-console') {
if (account.status === 'unauthorized') { if (account.status === 'unauthorized') {
return t('accounts.invalidApiKey') return 'API Key无效或已过期401错误'
} }
if (account.overloadStatus === 'overloaded') { if (account.overloadStatus === 'overloaded') {
return t('accounts.serviceOverload') return '服务过载529错误'
} }
if (account.rateLimitStatus === 'limited') { if (account.rateLimitStatus === 'limited') {
return t('accounts.rateLimitTriggered') return '触发限流429错误'
} }
if (account.status === 'blocked' && account.errorMessage) { if (account.status === 'blocked' && account.errorMessage) {
return account.errorMessage return account.errorMessage
@@ -1888,7 +1849,7 @@ const getSchedulableReason = (account) => {
// Claude 官方账户的错误状态 // Claude 官方账户的错误状态
if (account.platform === 'claude') { if (account.platform === 'claude') {
if (account.status === 'unauthorized') { if (account.status === 'unauthorized') {
return t('accounts.authFailed') return '认证失败401错误'
} }
if (account.status === 'temp_error' && account.errorMessage) { if (account.status === 'temp_error' && account.errorMessage) {
return account.errorMessage return account.errorMessage
@@ -1897,7 +1858,7 @@ const getSchedulableReason = (account) => {
return account.errorMessage return account.errorMessage
} }
if (account.isRateLimited) { if (account.isRateLimited) {
return t('accounts.rateLimitTriggered') return '触发限流429错误'
} }
// 自动停止调度的原因 // 自动停止调度的原因
if (account.stoppedReason) { if (account.stoppedReason) {
@@ -1951,15 +1912,15 @@ const getSchedulableReason = (account) => {
} }
// 默认为手动停止 // 默认为手动停止
return t('accounts.manualStop') return '手动停止调度'
} }
// 获取账户状态文本 // 获取账户状态文本
const getAccountStatusText = (account) => { const getAccountStatusText = (account) => {
// 检查是否被封锁 // 检查是否被封锁
if (account.status === 'blocked') return t('accounts.blocked') if (account.status === 'blocked') return '已封锁'
// 检查是否未授权401错误 // 检查是否未授权401错误
if (account.status === 'unauthorized') return t('accounts.abnormal') if (account.status === 'unauthorized') return '异常'
// 检查是否限流 // 检查是否限流
if ( if (
account.isRateLimited || account.isRateLimited ||
@@ -1967,15 +1928,15 @@ const getAccountStatusText = (account) => {
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || (account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
account.rateLimitStatus === 'limited' account.rateLimitStatus === 'limited'
) )
return t('accounts.rateLimited') return '限流中'
// 检查是否临时错误 // 检查是否临时错误
if (account.status === 'temp_error') return t('accounts.tempError') if (account.status === 'temp_error') return '临时异常'
// 检查是否错误 // 检查是否错误
if (account.status === 'error' || !account.isActive) return t('accounts.abnormal') if (account.status === 'error' || !account.isActive) return '错误'
// 检查是否可调度 // 检查是否可调度
if (account.schedulable === false) return t('accounts.disabled') if (account.schedulable === false) return '已暂停'
// 否则正常 // 否则正常
return t('accounts.normal') return '正常'
} }
// 获取账户状态样式类 // 获取账户状态样式类

File diff suppressed because it is too large Load Diff

View File

@@ -6,15 +6,10 @@
<LogoTitle <LogoTitle
:loading="oemLoading" :loading="oemLoading"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon" :logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
:subtitle="currentTab === 'stats' ? t('apiStats.title') : t('apiStats.tutorialTitle')" :subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
:title="oemSettings.siteName" :title="oemSettings.siteName"
/> />
<div class="flex items-center gap-2 md:gap-4"> <div class="flex items-center gap-2 md:gap-4">
<!-- 语言切换按钮 -->
<div class="flex items-center">
<LanguageSwitch mode="dropdown" size="medium" />
</div>
<!-- 主题切换按钮 --> <!-- 主题切换按钮 -->
<div class="flex items-center"> <div class="flex items-center">
<ThemeToggle mode="dropdown" /> <ThemeToggle mode="dropdown" />
@@ -33,9 +28,7 @@
to="/user-login" to="/user-login"
> >
<i class="fas fa-user text-sm md:text-base" /> <i class="fas fa-user text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">{{ <span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
t('apiStats.userLogin')
}}</span>
</router-link> </router-link>
<!-- 管理后台按钮 --> <!-- 管理后台按钮 -->
<router-link <router-link
@@ -44,9 +37,7 @@
to="/dashboard" to="/dashboard"
> >
<i class="fas fa-shield-alt text-sm md:text-base" /> <i class="fas fa-shield-alt text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">{{ <span class="text-xs font-semibold tracking-wide md:text-sm">管理后台</span>
t('apiStats.adminPanel')
}}</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -63,14 +54,14 @@
@click="currentTab = 'stats'" @click="currentTab = 'stats'"
> >
<i class="fas fa-chart-line mr-1 md:mr-2" /> <i class="fas fa-chart-line mr-1 md:mr-2" />
<span class="text-sm md:text-base">{{ t('apiStats.statsQuery') }}</span> <span class="text-sm md:text-base">统计查询</span>
</button> </button>
<button <button
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']" :class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
@click="currentTab = 'tutorial'" @click="currentTab = 'tutorial'"
> >
<i class="fas fa-graduation-cap mr-1 md:mr-2" /> <i class="fas fa-graduation-cap mr-1 md:mr-2" />
<span class="text-sm md:text-base">{{ t('apiStats.tutorial') }}</span> <span class="text-sm md:text-base">使用教程</span>
</button> </button>
</div> </div>
</div> </div>
@@ -101,9 +92,9 @@
> >
<div class="flex items-center gap-2 md:gap-3"> <div class="flex items-center gap-2 md:gap-3">
<i class="fas fa-clock text-base text-blue-500 md:text-lg" /> <i class="fas fa-clock text-base text-blue-500 md:text-lg" />
<span class="text-base font-medium text-gray-700 dark:text-gray-200 md:text-lg">{{ <span class="text-base font-medium text-gray-700 dark:text-gray-200 md:text-lg"
t('apiStats.timeRange') >统计时间范围</span
}}</span> >
</div> </div>
<div class="flex w-full gap-2 md:w-auto"> <div class="flex w-full gap-2 md:w-auto">
<button <button
@@ -113,7 +104,7 @@
@click="switchPeriod('daily')" @click="switchPeriod('daily')"
> >
<i class="fas fa-calendar-day text-xs md:text-sm" /> <i class="fas fa-calendar-day text-xs md:text-sm" />
{{ t('apiStats.today') }} 今日
</button> </button>
<button <button
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm" class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
@@ -122,7 +113,7 @@
@click="switchPeriod('monthly')" @click="switchPeriod('monthly')"
> >
<i class="fas fa-calendar-alt text-xs md:text-sm" /> <i class="fas fa-calendar-alt text-xs md:text-sm" />
{{ t('apiStats.thisMonth') }} 本月
</button> </button>
</div> </div>
</div> </div>
@@ -149,7 +140,7 @@
<!-- 教程内容 --> <!-- 教程内容 -->
<div v-if="currentTab === 'tutorial'" class="tab-content"> <div v-if="currentTab === 'tutorial'" class="tab-content">
<div class="glass-strong rounded-3xl shadow-xl"> <div class="glass-strong rounded-3xl shadow-xl">
<component :is="currentTutorialComponent" /> <TutorialView />
</div> </div>
</div> </div>
</div> </div>
@@ -159,28 +150,21 @@
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useLocaleStore } from '@/stores/locale'
import LogoTitle from '@/components/common/LogoTitle.vue' import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue' import ThemeToggle from '@/components/common/ThemeToggle.vue'
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue' import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
import StatsOverview from '@/components/apistats/StatsOverview.vue' import StatsOverview from '@/components/apistats/StatsOverview.vue'
import TokenDistribution from '@/components/apistats/TokenDistribution.vue' import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
import LimitConfig from '@/components/apistats/LimitConfig.vue' import LimitConfig from '@/components/apistats/LimitConfig.vue'
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue' import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue' import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import TutorialViewZhCn from './tutorials/TutorialView-zh-cn.vue' import TutorialView from './TutorialView.vue'
import TutorialViewZhTw from './tutorials/TutorialView-zh-tw.vue'
import TutorialViewEn from './tutorials/TutorialView-en.vue'
const route = useRoute() const route = useRoute()
const { t } = useI18n()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const localeStore = useLocaleStore()
// 当前标签页 // 当前标签页
const currentTab = ref('stats') const currentTab = ref('stats')
@@ -188,17 +172,6 @@ const currentTab = ref('stats')
// 主题相关 // 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode) const isDarkMode = computed(() => themeStore.isDarkMode)
// 根据当前语言选择教程组件
const currentTutorialComponent = computed(() => {
const locale = localeStore.currentLocale
const components = {
'zh-cn': TutorialViewZhCn,
'zh-tw': TutorialViewZhTw,
en: TutorialViewEn
}
return components[locale] || TutorialViewZhCn
})
const { const {
apiKey, apiKey,
apiId, apiId,

View File

@@ -8,13 +8,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.totalApiKeys') }} 总API Keys
</p> </p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl"> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.totalApiKeys }} {{ dashboardData.totalApiKeys }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.activeApiKeys') }}: {{ dashboardData.activeApiKeys || 0 }} 活跃: {{ dashboardData.activeApiKeys || 0 }}
</p> </p>
</div> </div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
@@ -27,7 +27,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1"> <div class="flex-1">
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.serviceAccounts') }} 服务账户
</p> </p>
<div class="flex flex-wrap items-baseline gap-x-2"> <div class="flex flex-wrap items-baseline gap-x-2">
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl"> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
@@ -42,12 +42,7 @@
dashboardData.accountsByPlatform.claude.total > 0 dashboardData.accountsByPlatform.claude.total > 0
" "
class="inline-flex items-center gap-0.5" class="inline-flex items-center gap-0.5"
:title=" :title="`Claude: ${dashboardData.accountsByPlatform.claude.total} 个 (正常: ${dashboardData.accountsByPlatform.claude.normal})`"
t('dashboard.claudeAccount', {
total: dashboardData.accountsByPlatform.claude.total,
normal: dashboardData.accountsByPlatform.claude.normal
})
"
> >
<i class="fas fa-brain text-xs text-indigo-600" /> <i class="fas fa-brain text-xs text-indigo-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
@@ -61,12 +56,7 @@
dashboardData.accountsByPlatform['claude-console'].total > 0 dashboardData.accountsByPlatform['claude-console'].total > 0
" "
class="inline-flex items-center gap-0.5" class="inline-flex items-center gap-0.5"
:title=" :title="`Console: ${dashboardData.accountsByPlatform['claude-console'].total} 个 (正常: ${dashboardData.accountsByPlatform['claude-console'].normal})`"
t('dashboard.consoleAccount', {
total: dashboardData.accountsByPlatform['claude-console'].total,
normal: dashboardData.accountsByPlatform['claude-console'].normal
})
"
> >
<i class="fas fa-terminal text-xs text-purple-600" /> <i class="fas fa-terminal text-xs text-purple-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
@@ -80,12 +70,7 @@
dashboardData.accountsByPlatform.gemini.total > 0 dashboardData.accountsByPlatform.gemini.total > 0
" "
class="inline-flex items-center gap-0.5" class="inline-flex items-center gap-0.5"
:title=" :title="`Gemini: ${dashboardData.accountsByPlatform.gemini.total} 个 (正常: ${dashboardData.accountsByPlatform.gemini.normal})`"
t('dashboard.geminiAccount', {
total: dashboardData.accountsByPlatform.gemini.total,
normal: dashboardData.accountsByPlatform.gemini.normal
})
"
> >
<i class="fas fa-robot text-xs text-yellow-600" /> <i class="fas fa-robot text-xs text-yellow-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
@@ -99,12 +84,7 @@
dashboardData.accountsByPlatform.bedrock.total > 0 dashboardData.accountsByPlatform.bedrock.total > 0
" "
class="inline-flex items-center gap-0.5" class="inline-flex items-center gap-0.5"
:title=" :title="`Bedrock: ${dashboardData.accountsByPlatform.bedrock.total} 个 (正常: ${dashboardData.accountsByPlatform.bedrock.normal})`"
t('dashboard.bedrockAccount', {
total: dashboardData.accountsByPlatform.bedrock.total,
normal: dashboardData.accountsByPlatform.bedrock.normal
})
"
> >
<i class="fab fa-aws text-xs text-orange-600" /> <i class="fab fa-aws text-xs text-orange-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
@@ -118,12 +98,7 @@
dashboardData.accountsByPlatform.openai.total > 0 dashboardData.accountsByPlatform.openai.total > 0
" "
class="inline-flex items-center gap-0.5" class="inline-flex items-center gap-0.5"
:title=" :title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`"
t('dashboard.openaiAccount', {
total: dashboardData.accountsByPlatform.openai.total,
normal: dashboardData.accountsByPlatform.openai.normal
})
"
> >
<i class="fas fa-openai text-xs text-gray-100" /> <i class="fas fa-openai text-xs text-gray-100" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
@@ -137,12 +112,7 @@
dashboardData.accountsByPlatform.azure_openai.total > 0 dashboardData.accountsByPlatform.azure_openai.total > 0
" "
class="inline-flex items-center gap-0.5" class="inline-flex items-center gap-0.5"
:title=" :title="`Azure OpenAI: ${dashboardData.accountsByPlatform.azure_openai.total} 个 (正常: ${dashboardData.accountsByPlatform.azure_openai.normal})`"
t('dashboard.azureOpenaiAccount', {
total: dashboardData.accountsByPlatform.azure_openai.total,
normal: dashboardData.accountsByPlatform.azure_openai.normal
})
"
> >
<i class="fab fa-microsoft text-xs text-blue-600" /> <i class="fab fa-microsoft text-xs text-blue-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
@@ -152,18 +122,18 @@
</div> </div>
</div> </div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.normalAccounts') }}: {{ dashboardData.normalAccounts || 0 }} 正常: {{ dashboardData.normalAccounts || 0 }}
<span v-if="dashboardData.abnormalAccounts > 0" class="text-red-600"> <span v-if="dashboardData.abnormalAccounts > 0" class="text-red-600">
| {{ t('dashboard.abnormalAccounts') }}: {{ dashboardData.abnormalAccounts }} | 异常: {{ dashboardData.abnormalAccounts }}
</span> </span>
<span <span
v-if="dashboardData.pausedAccounts > 0" v-if="dashboardData.pausedAccounts > 0"
class="text-gray-600 dark:text-gray-400" class="text-gray-600 dark:text-gray-400"
> >
| {{ t('dashboard.pausedAccounts') }}: {{ dashboardData.pausedAccounts }} | 停止调度: {{ dashboardData.pausedAccounts }}
</span> </span>
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600"> <span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
| {{ t('dashboard.rateLimitedAccounts') }}: {{ dashboardData.rateLimitedAccounts }} | 限流: {{ dashboardData.rateLimitedAccounts }}
</span> </span>
</p> </p>
</div> </div>
@@ -177,14 +147,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.todayRequests') }} 今日请求
</p> </p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl"> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.todayRequests }} {{ dashboardData.todayRequests }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.totalRequests') }}: 总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}
{{ formatNumber(dashboardData.totalRequests || 0) }}
</p> </p>
</div> </div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
@@ -197,13 +166,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.systemStatus') }} 系统状态
</p> </p>
<p class="text-2xl font-bold text-green-600 sm:text-3xl"> <p class="text-2xl font-bold text-green-600 sm:text-3xl">
{{ t(`common.system.status.${dashboardData.systemStatusCode || 'normal'}`) }} {{ dashboardData.systemStatus }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.uptime') }}: {{ formattedUptime }} 运行时间: {{ formattedUptime }}
</p> </p>
</div> </div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
@@ -221,7 +190,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="mr-8 flex-1"> <div class="mr-8 flex-1">
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.todayToken') }} 今日Token
</p> </p>
<div class="mb-2 flex flex-wrap items-baseline gap-2"> <div class="mb-2 flex flex-wrap items-baseline gap-2">
<p class="text-xl font-bold text-blue-600 sm:text-2xl md:text-3xl"> <p class="text-xl font-bold text-blue-600 sm:text-2xl md:text-3xl">
@@ -241,25 +210,25 @@
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap items-center justify-between gap-x-4"> <div class="flex flex-wrap items-center justify-between gap-x-4">
<span <span
>{{ t('dashboard.inputTokens') }}: >输入:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.todayInputTokens || 0) formatNumber(dashboardData.todayInputTokens || 0)
}}</span></span }}</span></span
> >
<span <span
>{{ t('dashboard.outputTokens') }}: >输出:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.todayOutputTokens || 0) formatNumber(dashboardData.todayOutputTokens || 0)
}}</span></span }}</span></span
> >
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600" <span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600"
>{{ t('dashboard.cacheCreateTokens') }}: >缓存创建:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.todayCacheCreateTokens || 0) formatNumber(dashboardData.todayCacheCreateTokens || 0)
}}</span></span }}</span></span
> >
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600" <span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600"
>{{ t('dashboard.cacheReadTokens') }}: >缓存读取:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.todayCacheReadTokens || 0) formatNumber(dashboardData.todayCacheReadTokens || 0)
}}</span></span }}</span></span
@@ -277,7 +246,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="mr-8 flex-1"> <div class="mr-8 flex-1">
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.totalTokenConsumption') }} 总Token消耗
</p> </p>
<div class="mb-2 flex flex-wrap items-baseline gap-2"> <div class="mb-2 flex flex-wrap items-baseline gap-2">
<p class="text-xl font-bold text-emerald-600 sm:text-2xl md:text-3xl"> <p class="text-xl font-bold text-emerald-600 sm:text-2xl md:text-3xl">
@@ -297,25 +266,25 @@
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap items-center justify-between gap-x-4"> <div class="flex flex-wrap items-center justify-between gap-x-4">
<span <span
>{{ t('dashboard.inputTokens') }}: >输入:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.totalInputTokens || 0) formatNumber(dashboardData.totalInputTokens || 0)
}}</span></span }}</span></span
> >
<span <span
>{{ t('dashboard.outputTokens') }}: >输出:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.totalOutputTokens || 0) formatNumber(dashboardData.totalOutputTokens || 0)
}}</span></span }}</span></span
> >
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600" <span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600"
>{{ t('dashboard.cacheCreateTokens') }}: >缓存创建:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.totalCacheCreateTokens || 0) formatNumber(dashboardData.totalCacheCreateTokens || 0)
}}</span></span }}</span></span
> >
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600" <span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600"
>{{ t('dashboard.cacheReadTokens') }}: >缓存读取:
<span class="font-medium">{{ <span class="font-medium">{{
formatNumber(dashboardData.totalCacheReadTokens || 0) formatNumber(dashboardData.totalCacheReadTokens || 0)
}}</span></span }}</span></span
@@ -333,18 +302,16 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.realtimeRPM') }} 实时RPM
<span class="text-xs text-gray-400" <span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
>({{ dashboardData.metricsWindow }}{{ t('dashboard.minutes') }})</span
>
</p> </p>
<p class="text-2xl font-bold text-orange-600 sm:text-3xl"> <p class="text-2xl font-bold text-orange-600 sm:text-3xl">
{{ dashboardData.realtimeRPM || 0 }} {{ dashboardData.realtimeRPM || 0 }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.requestsPerMinute') }} 每分钟请求数
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600"> <span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600">
<i class="fas fa-exclamation-circle" /> {{ t('dashboard.historicalData') }} <i class="fas fa-exclamation-circle" /> 历史数据
</span> </span>
</p> </p>
</div> </div>
@@ -358,18 +325,16 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
{{ t('dashboard.realtimeTPM') }} 实时TPM
<span class="text-xs text-gray-400" <span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
>({{ dashboardData.metricsWindow }}{{ t('dashboard.minutes') }})</span
>
</p> </p>
<p class="text-2xl font-bold text-rose-600 sm:text-3xl"> <p class="text-2xl font-bold text-rose-600 sm:text-3xl">
{{ formatNumber(dashboardData.realtimeTPM || 0) }} {{ formatNumber(dashboardData.realtimeTPM || 0) }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.tokensPerMinute') }} 每分钟Token数
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600"> <span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600">
<i class="fas fa-exclamation-circle" /> {{ t('dashboard.historicalData') }} <i class="fas fa-exclamation-circle" /> 历史数据
</span> </span>
</p> </p>
</div> </div>
@@ -384,7 +349,7 @@
<div class="mb-8"> <div class="mb-8">
<div class="mb-4 flex flex-col gap-4 sm:mb-6"> <div class="mb-4 flex flex-col gap-4 sm:mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
{{ t('dashboard.modelDistributionAndTrend') }} 模型使用分布与Token使用趋势
</h3> </h3>
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-end"> <div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-end">
<!-- 快捷日期选择 --> <!-- 快捷日期选择 -->
@@ -417,7 +382,7 @@
]" ]"
@click="setTrendGranularity('day')" @click="setTrendGranularity('day')"
> >
<i class="fas fa-calendar-day mr-1" />{{ t('dashboard.byDay') }} <i class="fas fa-calendar-day mr-1" />按天
</button> </button>
<button <button
:class="[ :class="[
@@ -428,7 +393,7 @@
]" ]"
@click="setTrendGranularity('hour')" @click="setTrendGranularity('hour')"
> >
<i class="fas fa-clock mr-1" />{{ t('dashboard.byHour') }} <i class="fas fa-clock mr-1" />按小时
</button> </button>
</div> </div>
@@ -439,18 +404,18 @@
class="custom-date-picker w-full lg:w-auto" class="custom-date-picker w-full lg:w-auto"
:default-time="defaultTime" :default-time="defaultTime"
:disabled-date="disabledDate" :disabled-date="disabledDate"
:end-placeholder="t('dashboard.endDatePlaceholder')" end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss"
:range-separator="t('dashboard.dateSeparator')" range-separator=""
size="default" size="default"
:start-placeholder="t('dashboard.startDatePlaceholder')" start-placeholder="开始日期"
style="max-width: 400px" style="max-width: 400px"
type="datetimerange" type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
@change="onCustomDateRangeChange" @change="onCustomDateRangeChange"
/> />
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600"> <span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
<i class="fas fa-info-circle" /> {{ t('dashboard.maxHours24') }} <i class="fas fa-info-circle" /> 最多24小时
</span> </span>
</div> </div>
@@ -468,7 +433,7 @@
class="ml-2.5 flex select-none items-center gap-1 text-sm font-medium text-gray-600 dark:text-gray-300" class="ml-2.5 flex select-none items-center gap-1 text-sm font-medium text-gray-600 dark:text-gray-300"
> >
<i class="fas fa-redo-alt text-xs text-gray-500 dark:text-gray-400" /> <i class="fas fa-redo-alt text-xs text-gray-500 dark:text-gray-400" />
<span>{{ t('dashboard.autoRefresh') }}</span> <span>自动刷新</span>
<span <span
v-if="autoRefreshEnabled" v-if="autoRefreshEnabled"
class="ml-1 font-mono text-xs text-blue-600 transition-opacity" class="ml-1 font-mono text-xs text-blue-600 transition-opacity"
@@ -484,13 +449,11 @@
<button <button
class="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-blue-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 sm:gap-2" class="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-blue-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 sm:gap-2"
:disabled="isRefreshing" :disabled="isRefreshing"
:title="t('dashboard.refreshDataNow')" title="立即刷新数据"
@click="refreshAllData()" @click="refreshAllData()"
> >
<i :class="['fas fa-sync-alt text-xs', { 'animate-spin': isRefreshing }]" /> <i :class="['fas fa-sync-alt text-xs', { 'animate-spin': isRefreshing }]" />
<span class="hidden sm:inline">{{ <span class="hidden sm:inline">{{ isRefreshing ? '刷新中' : '刷新' }}</span>
isRefreshing ? t('dashboard.refreshing') : t('dashboard.refresh')
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -500,7 +463,7 @@
<!-- 饼图 --> <!-- 饼图 -->
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg"> <h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg">
{{ t('dashboard.tokenUsageDistribution') }} Token使用分布
</h4> </h4>
<div class="relative" style="height: 250px"> <div class="relative" style="height: 250px">
<canvas ref="modelUsageChart" /> <canvas ref="modelUsageChart" />
@@ -510,10 +473,10 @@
<!-- 详细数据表格 --> <!-- 详细数据表格 -->
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg"> <h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg">
{{ t('dashboard.detailedStatistics') }} 详细统计数据
</h4> </h4>
<div v-if="dashboardModelStats.length === 0" class="py-8 text-center"> <div v-if="dashboardModelStats.length === 0" class="py-8 text-center">
<p class="text-sm text-gray-500 sm:text-base">{{ t('dashboard.noModelUsageData') }}</p> <p class="text-sm text-gray-500 sm:text-base">暂无模型使用数据</p>
</div> </div>
<div v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]"> <div v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]">
<table class="min-w-full"> <table class="min-w-full">
@@ -522,27 +485,27 @@
<th <th
class="px-2 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4" class="px-2 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4"
> >
{{ t('dashboard.model') }} 模型
</th> </th>
<th <th
class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4" class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4"
> >
{{ t('dashboard.requestCount') }} 请求数
</th> </th>
<th <th
class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4" class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4"
> >
{{ t('dashboard.totalTokens') }} 总Token
</th> </th>
<th <th
class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4" class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4"
> >
{{ t('dashboard.cost') }} 费用
</th> </th>
<th <th
class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4" class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4"
> >
{{ t('dashboard.percentage') }} 占比
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -603,7 +566,7 @@
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg"> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg">
{{ t('dashboard.apiKeysUsageTrend') }} API Keys 使用趋势
</h3> </h3>
<!-- 维度切换按钮 --> <!-- 维度切换按钮 -->
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700"> <div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
@@ -616,10 +579,8 @@
]" ]"
@click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())" @click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())"
> >
<i class="fas fa-exchange-alt mr-1" /><span class="hidden sm:inline">{{ <i class="fas fa-exchange-alt mr-1" /><span class="hidden sm:inline">请求次数</span
t('dashboard.requestsCount') ><span class="sm:hidden">请求</span>
}}</span
><span class="sm:hidden">{{ t('dashboard.requestsCount').split(' ')[0] }}</span>
</button> </button>
<button <button
:class="[ :class="[
@@ -630,20 +591,16 @@
]" ]"
@click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())" @click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())"
> >
<i class="fas fa-coins mr-1" /><span class="hidden sm:inline">{{ <i class="fas fa-coins mr-1" /><span class="hidden sm:inline">Token 数量</span
t('dashboard.tokenCount')
}}</span
><span class="sm:hidden">Token</span> ><span class="sm:hidden">Token</span>
</button> </button>
</div> </div>
</div> </div>
<div class="mb-4 text-xs text-gray-600 dark:text-gray-400 sm:text-sm"> <div class="mb-4 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
<span v-if="apiKeysTrendData.totalApiKeys > 10"> <span v-if="apiKeysTrendData.totalApiKeys > 10">
{{ t('dashboard.showingTop10', { count: apiKeysTrendData.totalApiKeys }) }} {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个
</span> </span>
<span v-else>{{ <span v-else> 共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key </span>
t('dashboard.totalApiKeysCount', { count: apiKeysTrendData.totalApiKeys })
}}</span>
</div> </div>
<div class="sm:h-[350px]" style="height: 300px"> <div class="sm:h-[350px]" style="height: 300px">
<canvas ref="apiKeysUsageTrendChart" /> <canvas ref="apiKeysUsageTrendChart" />
@@ -656,14 +613,12 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const { t, locale } = useI18n()
const { isDarkMode } = storeToRefs(themeStore) const { isDarkMode } = storeToRefs(themeStore)
const { const {
@@ -849,35 +804,35 @@ function createUsageTrendChart() {
labels: labels, labels: labels,
datasets: [ datasets: [
{ {
label: t('dashboard.inputTokensLabel'), label: '输入Token',
data: inputData, data: inputData,
borderColor: 'rgb(102, 126, 234)', borderColor: 'rgb(102, 126, 234)',
backgroundColor: 'rgba(102, 126, 234, 0.1)', backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.3 tension: 0.3
}, },
{ {
label: t('dashboard.outputTokensLabel'), label: '输出Token',
data: outputData, data: outputData,
borderColor: 'rgb(240, 147, 251)', borderColor: 'rgb(240, 147, 251)',
backgroundColor: 'rgba(240, 147, 251, 0.1)', backgroundColor: 'rgba(240, 147, 251, 0.1)',
tension: 0.3 tension: 0.3
}, },
{ {
label: t('dashboard.cacheCreateTokensLabel'), label: '缓存创建Token',
data: cacheCreateData, data: cacheCreateData,
borderColor: 'rgb(59, 130, 246)', borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3 tension: 0.3
}, },
{ {
label: t('dashboard.cacheReadTokensLabel'), label: '缓存读取Token',
data: cacheReadData, data: cacheReadData,
borderColor: 'rgb(147, 51, 234)', borderColor: 'rgb(147, 51, 234)',
backgroundColor: 'rgba(147, 51, 234, 0.1)', backgroundColor: 'rgba(147, 51, 234, 0.1)',
tension: 0.3 tension: 0.3
}, },
{ {
label: t('dashboard.costLabel'), label: '费用 (USD)',
data: costData, data: costData,
borderColor: 'rgb(34, 197, 94)', borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)', backgroundColor: 'rgba(34, 197, 94, 0.1)',
@@ -885,7 +840,7 @@ function createUsageTrendChart() {
yAxisID: 'y2' yAxisID: 'y2'
}, },
{ {
label: t('dashboard.requestsLabel'), label: '请求数',
data: requestsData, data: requestsData,
borderColor: 'rgb(16, 185, 129)', borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)', backgroundColor: 'rgba(16, 185, 129, 0.1)',
@@ -908,7 +863,7 @@ function createUsageTrendChart() {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: t('dashboard.tokenUsageTrend'), text: 'Token使用趋势',
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold'
@@ -930,14 +885,11 @@ function createUsageTrendChart() {
const bLabel = b.dataset.label || '' const bLabel = b.dataset.label || ''
// 费用和请求数使用不同的轴,单独处理 // 费用和请求数使用不同的轴,单独处理
if (aLabel === t('dashboard.costLabel') || bLabel === t('dashboard.costLabel')) { if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') {
return aLabel === t('dashboard.costLabel') ? -1 : 1 return aLabel === '费用 (USD)' ? -1 : 1
} }
if ( if (aLabel === '请求数' || bLabel === '请求数') {
aLabel === t('dashboard.requestsLabel') || return aLabel === '请求数' ? 1 : -1
bLabel === t('dashboard.requestsLabel')
) {
return aLabel === t('dashboard.requestsLabel') ? 1 : -1
} }
// 其他按token值倒序 // 其他按token值倒序
@@ -948,15 +900,15 @@ function createUsageTrendChart() {
const label = context.dataset.label || '' const label = context.dataset.label || ''
let value = context.parsed.y let value = context.parsed.y
if (label === t('dashboard.costLabel')) { if (label === '费用 (USD)') {
// 格式化费用显示 // 格式化费用显示
if (value < 0.01) { if (value < 0.01) {
return label + ': $' + value.toFixed(6) return label + ': $' + value.toFixed(6)
} else { } else {
return label + ': $' + value.toFixed(4) return label + ': $' + value.toFixed(4)
} }
} else if (label === t('dashboard.requestsLabel')) { } else if (label === '请求数') {
return label + ': ' + value.toLocaleString() return label + ': ' + value.toLocaleString() + ' 次'
} else { } else {
// 格式化token数显示 // 格式化token数显示
if (value >= 1000000) { if (value >= 1000000) {
@@ -977,7 +929,7 @@ function createUsageTrendChart() {
display: true, display: true,
title: { title: {
display: true, display: true,
text: trendGranularity === 'hour' ? t('dashboard.time') : t('dashboard.date'), text: trendGranularity === 'hour' ? '时间' : '日期',
color: chartColors.value.text color: chartColors.value.text
}, },
ticks: { ticks: {
@@ -993,7 +945,7 @@ function createUsageTrendChart() {
position: 'left', position: 'left',
title: { title: {
display: true, display: true,
text: t('dashboard.tokenQuantity'), text: 'Token数量',
color: chartColors.value.text color: chartColors.value.text
}, },
ticks: { ticks: {
@@ -1012,7 +964,7 @@ function createUsageTrendChart() {
position: 'right', position: 'right',
title: { title: {
display: true, display: true,
text: t('dashboard.requestsQuantity'), text: '请求数',
color: chartColors.value.text color: chartColors.value.text
}, },
grid: { grid: {
@@ -1196,7 +1148,7 @@ function createApiKeysUsageTrendChart() {
display: true, display: true,
title: { title: {
display: true, display: true,
text: trendGranularity === 'hour' ? t('dashboard.time') : t('dashboard.date'), text: trendGranularity === 'hour' ? '时间' : '日期',
color: chartColors.value.text color: chartColors.value.text
}, },
ticks: { ticks: {
@@ -1210,10 +1162,7 @@ function createApiKeysUsageTrendChart() {
beginAtZero: true, beginAtZero: true,
title: { title: {
display: true, display: true,
text: text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
apiKeysTrendMetric.value === 'tokens'
? t('dashboard.tokenQuantity')
: t('dashboard.requestsQuantity'),
color: chartColors.value.text color: chartColors.value.text
}, },
ticks: { ticks: {
@@ -1337,15 +1286,6 @@ watch(isDarkMode, () => {
}) })
}) })
// 监听语言变化,重新创建图表
watch(locale, () => {
nextTick(() => {
createModelUsageChart()
createUsageTrendChart()
createApiKeysUsageTrendChart()
})
})
// 初始化 // 初始化
onMounted(async () => { onMounted(async () => {
// 加载所有数据 // 加载所有数据

View File

@@ -1,10 +1,7 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center p-4 sm:p-6"> <div class="flex min-h-screen items-center justify-center p-4 sm:p-6">
<!-- 右上角工具栏 --> <!-- 主题切换按钮 - 固定在右上角 -->
<div class="fixed right-4 top-4 z-50 flex items-center gap-2 sm:gap-3"> <div class="fixed right-4 top-4 z-50">
<!-- 语言切换按钮 -->
<LanguageSwitch mode="dropdown" size="medium" />
<!-- 主题切换按钮 -->
<ThemeToggle mode="dropdown" /> <ThemeToggle mode="dropdown" />
</div> </div>
@@ -37,33 +34,31 @@
v-else-if="oemLoading" v-else-if="oemLoading"
class="mx-auto mb-2 h-8 w-48 animate-pulse rounded bg-gray-300/50 sm:h-9 sm:w-64" class="mx-auto mb-2 h-8 w-48 animate-pulse rounded bg-gray-300/50 sm:h-9 sm:w-64"
/> />
<p class="text-base text-gray-600 dark:text-gray-400 sm:text-lg">{{ t('login.title') }}</p> <p class="text-base text-gray-600 dark:text-gray-400 sm:text-lg">管理后台</p>
</div> </div>
<form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin"> <form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin">
<div> <div>
<label <label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3" >用户名</label
>{{ t('login.username') }}</label
> >
<input <input
v-model="loginForm.username" v-model="loginForm.username"
class="form-input w-full" class="form-input w-full"
:placeholder="t('login.usernamePlaceholder')" placeholder="请输入用户名"
required required
type="text" type="text"
/> />
</div> </div>
<div> <div>
<label <label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3" >密码</label
>{{ t('login.password') }}</label
> >
<input <input
v-model="loginForm.password" v-model="loginForm.password"
class="form-input w-full" class="form-input w-full"
:placeholder="t('login.passwordPlaceholder')" placeholder="请输入密码"
required required
type="password" type="password"
/> />
@@ -76,7 +71,7 @@
> >
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2" /> <i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2" />
<div v-if="authStore.loginLoading" class="loading-spinner mr-2" /> <div v-if="authStore.loginLoading" class="loading-spinner mr-2" />
{{ authStore.loginLoading ? t('login.loggingIn') : t('login.loginButton') }} {{ authStore.loginLoading ? '登录中...' : '登录' }}
</button> </button>
</form> </form>
@@ -92,15 +87,12 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import ThemeToggle from '@/components/common/ThemeToggle.vue' import ThemeToggle from '@/components/common/ThemeToggle.vue'
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const { t } = useI18n()
const oemLoading = computed(() => authStore.oemLoading) const oemLoading = computed(() => authStore.oemLoading)
const loginForm = ref({ const loginForm = ref({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
]" ]"
@click="handleTabChange('overview')" @click="handleTabChange('overview')"
> >
{{ t('user.dashboard.overview') }} Overview
</button> </button>
<button <button
:class="[ :class="[
@@ -43,7 +43,7 @@
]" ]"
@click="handleTabChange('api-keys')" @click="handleTabChange('api-keys')"
> >
{{ t('user.dashboard.apiKeys') }} API Keys
</button> </button>
<button <button
:class="[ :class="[
@@ -54,7 +54,7 @@
]" ]"
@click="handleTabChange('usage')" @click="handleTabChange('usage')"
> >
{{ t('user.dashboard.usageStats') }} Usage Stats
</button> </button>
<button <button
:class="[ :class="[
@@ -72,8 +72,7 @@
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm text-gray-700 dark:text-gray-300">
{{ t('user.dashboard.welcome') }}, Welcome, <span class="font-medium">{{ userStore.userName }}</span>
<span class="font-medium">{{ userStore.userName }}</span>
</div> </div>
<!-- 主题切换按钮 --> <!-- 主题切换按钮 -->
@@ -83,7 +82,7 @@
class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@click="handleLogout" @click="handleLogout"
> >
{{ t('user.dashboard.logout') }} Logout
</button> </button>
</div> </div>
</div> </div>
@@ -95,11 +94,9 @@
<!-- Overview Tab --> <!-- Overview Tab -->
<div v-if="activeTab === 'overview'" class="space-y-6"> <div v-if="activeTab === 'overview'" class="space-y-6">
<div> <div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white"> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard Overview</h1>
{{ t('user.dashboard.title') }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('user.dashboard.welcomeMessage') }} Welcome to your Claude Relay dashboard
</p> </p>
</div> </div>
@@ -126,7 +123,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.dashboard.activeApiKeys') }} Active API Keys
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ apiKeysStats.active }} {{ apiKeysStats.active }}
@@ -158,7 +155,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.dashboard.deletedApiKeys') }} Deleted API Keys
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ apiKeysStats.deleted }} {{ apiKeysStats.deleted }}
@@ -190,7 +187,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.dashboard.totalRequests') }} Total Requests
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ formatNumber(userProfile?.totalUsage?.requests || 0) }} {{ formatNumber(userProfile?.totalUsage?.requests || 0) }}
@@ -222,7 +219,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.dashboard.inputTokens') }} Input Tokens
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }} {{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }}
@@ -254,7 +251,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.dashboard.totalCost') }} Total Cost
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }} ${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }}
@@ -270,38 +267,30 @@
<div class="rounded-lg bg-white shadow dark:bg-gray-800"> <div class="rounded-lg bg-white shadow dark:bg-gray-800">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white"> <h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
{{ t('user.dashboard.accountInformation') }} Account Information
</h3> </h3>
<div class="mt-5 border-t border-gray-200 dark:border-gray-700"> <div class="mt-5 border-t border-gray-200 dark:border-gray-700">
<dl class="divide-y divide-gray-200 dark:divide-gray-700"> <dl class="divide-y divide-gray-200 dark:divide-gray-700">
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Username</dt>
{{ t('user.dashboard.username') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.username }} {{ userProfile?.username }}
</dd> </dd>
</div> </div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Display Name</dt>
{{ t('user.dashboard.displayName') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.displayName || t('user.dashboard.notAvailable') }} {{ userProfile?.displayName || 'N/A' }}
</dd> </dd>
</div> </div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
{{ t('user.dashboard.email') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.email || t('user.dashboard.notAvailable') }} {{ userProfile?.email || 'N/A' }}
</dd> </dd>
</div> </div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
{{ t('user.dashboard.role') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
<span <span
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200" class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
@@ -311,19 +300,15 @@
</dd> </dd>
</div> </div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Member Since</dt>
{{ t('user.dashboard.memberSince') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ formatDate(userProfile?.createdAt) }} {{ formatDate(userProfile?.createdAt) }}
</dd> </dd>
</div> </div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Login</dt>
{{ t('user.dashboard.lastLogin') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ formatDate(userProfile?.lastLoginAt) || t('user.dashboard.notAvailable') }} {{ formatDate(userProfile?.lastLoginAt) || 'N/A' }}
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -353,7 +338,6 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
@@ -363,7 +347,6 @@ import UserUsageStats from '@/components/user/UserUsageStats.vue'
import TutorialView from '@/views/TutorialView.vue' import TutorialView from '@/views/TutorialView.vue'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
@@ -402,11 +385,11 @@ const handleTabChange = (tab) => {
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await userStore.logout() await userStore.logout()
showToast(t('user.dashboard.logoutSuccess'), 'success') showToast('Logged out successfully', 'success')
router.push('/user-login') router.push('/user-login')
} catch (error) { } catch (error) {
console.error('Logout error:', error) console.error('Logout error:', error)
showToast(t('user.dashboard.logoutFailed'), 'error') showToast('Logout failed', 'error')
} }
} }
@@ -415,7 +398,7 @@ const loadUserProfile = async () => {
userProfile.value = await userStore.getUserProfile() userProfile.value = await userStore.getUserProfile()
} catch (error) { } catch (error) {
console.error('Failed to load user profile:', error) console.error('Failed to load user profile:', error)
showToast(t('user.dashboard.loadProfileFailed'), 'error') showToast('Failed to load user profile', 'error')
} }
} }

View File

@@ -26,10 +26,10 @@
<span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span> <span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span>
</div> </div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{{ t('user.login.title') }} User Sign In
</h2> </h2>
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> <p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{{ t('user.login.subtitle') }} Sign in to your account to manage your API keys
</p> </p>
</div> </div>
@@ -40,7 +40,7 @@
class="block text-sm font-medium text-gray-700 dark:text-gray-300" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
for="username" for="username"
> >
{{ t('user.login.username') }} Username
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input <input
@@ -49,7 +49,7 @@
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm" class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm"
:disabled="loading" :disabled="loading"
name="username" name="username"
:placeholder="t('user.login.usernamePlaceholder')" placeholder="Enter your username"
required required
type="text" type="text"
/> />
@@ -61,7 +61,7 @@
class="block text-sm font-medium text-gray-700 dark:text-gray-300" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
for="password" for="password"
> >
{{ t('user.login.password') }} Password
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input <input
@@ -70,7 +70,7 @@
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm" class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm"
:disabled="loading" :disabled="loading"
name="password" name="password"
:placeholder="t('user.login.passwordPlaceholder')" placeholder="Enter your password"
required required
type="password" type="password"
/> />
@@ -125,7 +125,7 @@
></path> ></path>
</svg> </svg>
</span> </span>
{{ loading ? t('user.login.signingIn') : t('user.login.signIn') }} {{ loading ? 'Signing In...' : 'Sign In' }}
</button> </button>
</div> </div>
@@ -134,7 +134,7 @@
class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
to="/admin-login" to="/admin-login"
> >
{{ t('user.login.adminLogin') }} Admin Login
</router-link> </router-link>
</div> </div>
</form> </form>
@@ -146,14 +146,12 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import ThemeToggle from '@/components/common/ThemeToggle.vue' import ThemeToggle from '@/components/common/ThemeToggle.vue'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
@@ -167,7 +165,7 @@ const form = reactive({
const handleLogin = async () => { const handleLogin = async () => {
if (!form.username || !form.password) { if (!form.username || !form.password) {
error.value = t('user.login.requiredFields') error.value = 'Please enter both username and password'
return return
} }
@@ -180,11 +178,11 @@ const handleLogin = async () => {
password: form.password password: form.password
}) })
showToast(t('user.login.loginSuccess'), 'success') showToast('Login successful!', 'success')
router.push('/user-dashboard') router.push('/user-dashboard')
} catch (err) { } catch (err) {
console.error('Login error:', err) console.error('Login error:', err)
error.value = err.response?.data?.message || err.message || t('user.login.loginFailed') error.value = err.response?.data?.message || err.message || 'Login failed'
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -3,11 +3,9 @@
<!-- Header --> <!-- Header -->
<div class="sm:flex sm:items-center"> <div class="sm:flex sm:items-center">
<div class="sm:flex-auto"> <div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white"> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
{{ t('user.management.title') }}
</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300"> <p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ t('user.management.description') }} Manage users, their API keys, and view usage statistics
</p> </p>
</div> </div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
@@ -24,7 +22,7 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
{{ t('user.management.refresh') }} Refresh
</button> </button>
</div> </div>
</div> </div>
@@ -52,7 +50,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.management.totalUsers') }} Total Users
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalUsers || 0 }} {{ userStats?.totalUsers || 0 }}
@@ -84,7 +82,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.management.activeUsers') }} Active Users
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.activeUsers || 0 }} {{ userStats?.activeUsers || 0 }}
@@ -116,7 +114,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.management.totalApiKeys') }} Total API Keys
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalApiKeys || 0 }} {{ userStats?.totalApiKeys || 0 }}
@@ -148,7 +146,7 @@
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ t('user.management.totalCost') }} Total Cost
</dt> </dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white"> <dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }} ${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
@@ -186,7 +184,7 @@
<input <input
v-model="searchQuery" v-model="searchQuery"
class="block w-full rounded-md border-gray-300 pl-10 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm" class="block w-full rounded-md border-gray-300 pl-10 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
:placeholder="t('user.management.searchPlaceholder')" placeholder="Search users..."
type="search" type="search"
@input="debouncedSearch" @input="debouncedSearch"
/> />
@@ -200,9 +198,9 @@
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers" @change="loadUsers"
> >
<option value="">{{ t('user.management.allRoles') }}</option> <option value="">All Roles</option>
<option value="user">{{ t('user.management.user') }}</option> <option value="user">User</option>
<option value="admin">{{ t('user.management.admin') }}</option> <option value="admin">Admin</option>
</select> </select>
</div> </div>
@@ -213,9 +211,9 @@
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers" @change="loadUsers"
> >
<option value="">{{ t('user.management.allStatus') }}</option> <option value="">All Status</option>
<option value="true">{{ t('user.management.active') }}</option> <option value="true">Active</option>
<option value="false">{{ t('user.management.disabled') }}</option> <option value="false">Disabled</option>
</select> </select>
</div> </div>
</div> </div>
@@ -227,7 +225,7 @@
<div class="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-md"> <div class="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-md">
<div class="border-b border-gray-200 px-4 py-5 dark:border-gray-700 sm:px-6"> <div class="border-b border-gray-200 px-4 py-5 dark:border-gray-700 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white"> <h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
{{ t('user.management.users') }} Users
<span v-if="!loading" class="text-sm text-gray-500 dark:text-gray-400" <span v-if="!loading" class="text-sm text-gray-500 dark:text-gray-400"
>({{ filteredUsers.length }} of {{ users.length }})</span >({{ filteredUsers.length }} of {{ users.length }})</span
> >
@@ -256,9 +254,7 @@
fill="currentColor" fill="currentColor"
></path> ></path>
</svg> </svg>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading users...</p>
{{ t('user.management.loadingUsers') }}
</p>
</div> </div>
<!-- Users List --> <!-- Users List -->
@@ -303,9 +299,7 @@
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
]" ]"
> >
{{ {{ user.isActive ? 'Active' : 'Disabled' }}
user.isActive ? t('user.management.active') : t('user.management.disabled')
}}
</span> </span>
<span <span
:class="[ :class="[
@@ -324,24 +318,18 @@
> >
<span>@{{ user.username }}</span> <span>@{{ user.username }}</span>
<span v-if="user.email">{{ user.email }}</span> <span v-if="user.email">{{ user.email }}</span>
<span>{{ user.apiKeyCount || 0 }} {{ t('user.management.apiKeysCount') }}</span> <span>{{ user.apiKeyCount || 0 }} API keys</span>
<span v-if="user.lastLoginAt" <span v-if="user.lastLoginAt"
>{{ t('user.management.lastLogin') }}: {{ formatDate(user.lastLoginAt) }}</span >Last login: {{ formatDate(user.lastLoginAt) }}</span
> >
<span v-else>{{ t('user.management.neverLoggedIn') }}</span> <span v-else>Never logged in</span>
</div> </div>
<div <div
v-if="user.totalUsage" v-if="user.totalUsage"
class="mt-1 flex items-center space-x-4 text-xs text-gray-400 dark:text-gray-500" class="mt-1 flex items-center space-x-4 text-xs text-gray-400 dark:text-gray-500"
> >
<span <span>{{ formatNumber(user.totalUsage.requests || 0) }} requests</span>
>{{ formatNumber(user.totalUsage.requests || 0) }} <span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
{{ t('user.management.requests') }}</span
>
<span
>${{ (user.totalUsage.totalCost || 0).toFixed(4) }}
{{ t('user.management.totalCostLabel') }}</span
>
</div> </div>
</div> </div>
</div> </div>
@@ -349,7 +337,7 @@
<!-- View Usage Stats --> <!-- View Usage Stats -->
<button <button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-blue-600" class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-blue-600"
:title="t('user.management.viewUsageStats')" title="View Usage Stats"
@click="viewUserStats(user)" @click="viewUserStats(user)"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -366,7 +354,7 @@
<button <button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="user.apiKeyCount === 0" :disabled="user.apiKeyCount === 0"
:title="t('user.management.disableAllApiKeys')" title="Disable All API Keys"
@click="disableUserApiKeys(user)" @click="disableUserApiKeys(user)"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -387,9 +375,7 @@
? 'text-gray-400 hover:text-red-600' ? 'text-gray-400 hover:text-red-600'
: 'text-gray-400 hover:text-green-600' : 'text-gray-400 hover:text-green-600'
]" ]"
:title=" :title="user.isActive ? 'Disable User' : 'Enable User'"
user.isActive ? t('user.management.disableUser') : t('user.management.enableUser')
"
@click="toggleUserStatus(user)" @click="toggleUserStatus(user)"
> >
<svg <svg
@@ -419,7 +405,7 @@
<!-- Change Role --> <!-- Change Role -->
<button <button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-purple-600" class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-purple-600"
:title="t('user.management.changeRole')" title="Change Role"
@click="changeUserRole(user)" @click="changeUserRole(user)"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -451,12 +437,10 @@
stroke-width="2" stroke-width="2"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white"> <h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No users found</h3>
{{ t('user.management.noUsersFound') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ {{
searchQuery ? t('user.management.noUsersMatch') : t('user.management.noUsersCreated') searchQuery ? 'No users match your search criteria.' : 'No users have been created yet.'
}} }}
</p> </p>
</div> </div>
@@ -492,7 +476,6 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { debounce } from 'lodash-es' import { debounce } from 'lodash-es'
@@ -500,7 +483,6 @@ import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue' import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
const { t } = useI18n()
const loading = ref(true) const loading = ref(true)
const users = ref([]) const users = ref([])
const userStats = ref(null) const userStats = ref(null)
@@ -595,7 +577,7 @@ const loadUsers = async () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to load users:', error) console.error('Failed to load users:', error)
showToast(t('user.management.loadUsersError'), 'error') showToast('Failed to load users', 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -613,13 +595,11 @@ const viewUserStats = (user) => {
const toggleUserStatus = (user) => { const toggleUserStatus = (user) => {
selectedUser.value = user selectedUser.value = user
confirmAction.value = { confirmAction.value = {
title: user.isActive title: user.isActive ? 'Disable User' : 'Enable User',
? t('user.management.disableUserTitle')
: t('user.management.enableUserTitle'),
message: user.isActive message: user.isActive
? t('user.management.disableUserMessage', { username: user.username }) ? `Are you sure you want to disable user "${user.username}"? This will prevent them from logging in.`
: t('user.management.enableUserMessage', { username: user.username }), : `Are you sure you want to enable user "${user.username}"?`,
confirmText: user.isActive ? t('user.management.disable') : t('user.management.enable'), confirmText: user.isActive ? 'Disable' : 'Enable',
confirmClass: user.isActive ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700', confirmClass: user.isActive ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700',
action: 'toggleStatus' action: 'toggleStatus'
} }
@@ -631,12 +611,9 @@ const disableUserApiKeys = (user) => {
selectedUser.value = user selectedUser.value = user
confirmAction.value = { confirmAction.value = {
title: t('user.management.disableAllKeysTitle'), title: 'Disable All API Keys',
message: t('user.management.disableAllKeysMessage', { message: `Are you sure you want to disable all ${user.apiKeyCount} API keys for user "${user.username}"? This will prevent them from using the service.`,
count: user.apiKeyCount, confirmText: 'Disable Keys',
username: user.username
}),
confirmText: t('user.management.disableKeys'),
confirmClass: 'bg-red-600 hover:bg-red-700', confirmClass: 'bg-red-600 hover:bg-red-700',
action: 'disableKeys' action: 'disableKeys'
} }
@@ -663,27 +640,19 @@ const handleConfirmAction = async () => {
if (userIndex !== -1) { if (userIndex !== -1) {
users.value[userIndex].isActive = !user.isActive users.value[userIndex].isActive = !user.isActive
} }
showToast( showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
user.isActive
? t('user.management.userDisabledSuccess')
: t('user.management.userEnabledSuccess'),
'success'
)
} }
} else if (action === 'disableKeys') { } else if (action === 'disableKeys') {
const response = await apiClient.post(`/users/${user.id}/disable-keys`) const response = await apiClient.post(`/users/${user.id}/disable-keys`)
if (response.success) { if (response.success) {
showToast( showToast(`Disabled ${response.disabledCount} API keys`, 'success')
t('user.management.keysDisabledSuccess', { count: response.disabledCount }),
'success'
)
await loadUsers() // Refresh to get updated counts await loadUsers() // Refresh to get updated counts
} }
} }
} catch (error) { } catch (error) {
console.error(`Failed to ${action}:`, error) console.error(`Failed to ${action}:`, error)
showToast(t(`user.management.${action}Error`), 'error') showToast(`Failed to ${action}`, 'error')
} finally { } finally {
showConfirmModal.value = false showConfirmModal.value = false
selectedUser.value = null selectedUser.value = null

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -51,9 +51,7 @@ export default defineConfig(({ mode }) => {
imports: ['vue', 'vue-router', 'pinia'] imports: ['vue', 'vue-router', 'pinia']
}), }),
Components({ Components({
// 仅自动注册 Element Plus 组件;本地组件显式导入 resolvers: [ElementPlusResolver()]
resolvers: [ElementPlusResolver()],
dirs: []
}) })
], ],
resolve: { resolve: {
@@ -105,8 +103,6 @@ export default defineConfig(({ mode }) => {
build: { build: {
outDir: 'dist', outDir: 'dist',
assetsDir: 'assets', assetsDir: 'assets',
// 提升 chunk 大小限制以消除 UI 库的大量警告
chunkSizeWarningLimit: 1024,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id) { manualChunks(id) {