mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
1 Commits
v1.1.253
...
revert-424
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f213e3e2 |
@@ -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
|
||||
@@ -18,8 +18,7 @@ module.exports = {
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
// 允许在所有环境中使用 console 语句以避免构建警告
|
||||
'no-console': 'off',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'prettier/prettier': 'error',
|
||||
'vue/attributes-order': [
|
||||
|
||||
102
web/admin-spa/auto-imports.d.ts
vendored
102
web/admin-spa/auto-imports.d.ts
vendored
@@ -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')
|
||||
}
|
||||
16
web/admin-spa/components.d.ts
vendored
16
web/admin-spa/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
70
web/admin-spa/package-lock.json
generated
70
web/admin-spa/package-lock.json
generated
@@ -15,7 +15,6 @@
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
@@ -658,50 +657,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -3834,10 +3789,9 @@
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
@@ -5227,26 +5181,6 @@
|
||||
"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": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz",
|
||||
@@ -5444,7 +5378,7 @@
|
||||
},
|
||||
"node_modules/xlsx-js-style": {
|
||||
"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==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<el-config-provider :locale="elLocale">
|
||||
<router-view />
|
||||
<router-view />
|
||||
|
||||
<!-- 全局组件 -->
|
||||
<ToastNotification ref="toastRef" />
|
||||
<ConfirmDialog ref="confirmRef" />
|
||||
</el-config-provider>
|
||||
<!-- 全局组件 -->
|
||||
<ToastNotification ref="toastRef" />
|
||||
<ConfirmDialog ref="confirmRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import ToastNotification from '@/components/common/ToastNotification.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 themeStore = useThemeStore()
|
||||
const toastRef = 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(() => {
|
||||
// 初始化主题
|
||||
themeStore.initTheme()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,7 @@
|
||||
>
|
||||
<i class="fas fa-layer-group text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">
|
||||
{{ t('groupManagement.title') }}
|
||||
</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">账户分组管理</h3>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@@ -27,32 +25,26 @@
|
||||
<div class="mb-6">
|
||||
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
|
||||
<i class="fas fa-plus mr-2" />
|
||||
{{ t('groupManagement.createNewGroup') }}
|
||||
创建新分组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建分组表单 -->
|
||||
<div v-if="showCreateForm" class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ t('groupManagement.createGroup') }}
|
||||
</h4>
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-900">创建新分组</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.groupNameRequired')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('groupManagement.groupNamePlaceholder')"
|
||||
placeholder="输入分组名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.platformTypeRequired')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
|
||||
@@ -70,13 +62,11 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.descriptionOptional')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
class="form-input w-full resize-none"
|
||||
:placeholder="t('groupManagement.descriptionPlaceholder')"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
@@ -88,11 +78,9 @@
|
||||
@click="createGroup"
|
||||
>
|
||||
<div v-if="creating" class="loading-spinner mr-2" />
|
||||
{{ creating ? t('groupManagement.creating') : t('groupManagement.create') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">
|
||||
{{ t('groupManagement.cancel') }}
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,12 +89,12 @@
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="py-8 text-center">
|
||||
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||
<p class="text-gray-500">{{ t('groupManagement.loading') }}</p>
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="groups.length === 0" class="rounded-lg bg-gray-50 py-8 text-center">
|
||||
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300" />
|
||||
<p class="text-gray-500">{{ t('groupManagement.noGroups') }}</p>
|
||||
<p class="text-gray-500">暂无分组</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -121,7 +109,7 @@
|
||||
{{ group.name }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ group.description || t('groupManagement.noDescription') }}
|
||||
{{ group.description || '暂无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center gap-2">
|
||||
@@ -150,7 +138,7 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<span>
|
||||
<i class="fas fa-users mr-1" />
|
||||
{{ group.memberCount || 0 }}{{ t('groupManagement.membersCount') }}
|
||||
{{ group.memberCount || 0 }} 个成员
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" />
|
||||
@@ -160,7 +148,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-blue-600 transition-colors hover:text-blue-800"
|
||||
:title="t('groupManagement.edit')"
|
||||
title="编辑"
|
||||
@click="editGroup(group)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
@@ -168,7 +156,7 @@
|
||||
<button
|
||||
class="text-red-600 transition-colors hover:text-red-800"
|
||||
:disabled="group.memberCount > 0"
|
||||
:title="t('groupManagement.delete')"
|
||||
title="删除"
|
||||
@click="deleteGroup(group)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
@@ -188,7 +176,7 @@
|
||||
>
|
||||
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900">{{ t('groupManagement.editGroup') }}</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
|
||||
<button class="text-gray-400 transition-colors hover:text-gray-600" @click="cancelEdit">
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
@@ -196,21 +184,17 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.groupNameRequired')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('groupManagement.groupNamePlaceholder')"
|
||||
placeholder="输入分组名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.platformTypeLabel')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型</label>
|
||||
<div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
|
||||
{{
|
||||
editForm.platform === 'claude'
|
||||
@@ -219,20 +203,16 @@
|
||||
? 'Gemini'
|
||||
: 'OpenAI'
|
||||
}}
|
||||
<span class="ml-2 text-xs text-gray-500">{{
|
||||
t('groupManagement.cannotModify')
|
||||
}}</span>
|
||||
<span class="ml-2 text-xs text-gray-500">(不可修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">{{
|
||||
t('groupManagement.descriptionOptional')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
class="form-input w-full resize-none"
|
||||
:placeholder="t('groupManagement.descriptionPlaceholder')"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
@@ -244,11 +224,9 @@
|
||||
@click="updateGroup"
|
||||
>
|
||||
<div v-if="updating" class="loading-spinner mr-2" />
|
||||
{{ updating ? t('groupManagement.updating') : t('groupManagement.update') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">
|
||||
{{ t('groupManagement.cancel') }}
|
||||
{{ updating ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,12 +236,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits(['close', 'refresh'])
|
||||
|
||||
const show = ref(true)
|
||||
@@ -303,7 +278,7 @@ const loadGroups = async () => {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast(t('groupManagement.loadGroupsFailed'), 'error')
|
||||
showToast('加载分组列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -312,7 +287,7 @@ const loadGroups = async () => {
|
||||
// 创建分组
|
||||
const createGroup = async () => {
|
||||
if (!createForm.value.name || !createForm.value.platform) {
|
||||
showToast(t('groupManagement.fillRequiredFields'), 'error')
|
||||
showToast('请填写必填项', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -324,12 +299,12 @@ const createGroup = async () => {
|
||||
description: createForm.value.description
|
||||
})
|
||||
|
||||
showToast(t('groupManagement.groupCreated'), 'success')
|
||||
showToast('分组创建成功', 'success')
|
||||
cancelCreate()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || t('groupManagement.createGroupFailed'), 'error')
|
||||
showToast(error.response?.data?.error || '创建分组失败', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -359,7 +334,7 @@ const editGroup = (group) => {
|
||||
// 更新分组
|
||||
const updateGroup = async () => {
|
||||
if (!editForm.value.name) {
|
||||
showToast(t('groupManagement.fillGroupName'), 'error')
|
||||
showToast('请填写分组名称', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -370,12 +345,12 @@ const updateGroup = async () => {
|
||||
description: editForm.value.description
|
||||
})
|
||||
|
||||
showToast(t('groupManagement.groupUpdated'), 'success')
|
||||
showToast('分组更新成功', 'success')
|
||||
cancelEdit()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || t('groupManagement.updateGroupFailed'), 'error')
|
||||
showToast(error.response?.data?.error || '更新分组失败', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
@@ -395,21 +370,21 @@ const cancelEdit = () => {
|
||||
// 删除分组
|
||||
const deleteGroup = async (group) => {
|
||||
if (group.memberCount > 0) {
|
||||
showToast(t('groupManagement.groupHasMembers'), 'error')
|
||||
showToast('分组内还有成员,无法删除', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(t('groupManagement.confirmDelete', { name: group.name }))) {
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
||||
showToast(t('groupManagement.groupDeleted'), 'success')
|
||||
showToast('分组删除成功', 'success')
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || t('groupManagement.deleteGroupFailed'), 'error')
|
||||
showToast(error.response?.data?.error || '删除分组失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
<i class="fas fa-link text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.claudeAccountAuth') }}
|
||||
</h4>
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ t('oauthFlow.claudeAuthDescription') }}
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -32,7 +30,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.step1Title') }}
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -42,7 +40,7 @@
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }}
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -54,7 +52,7 @@
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="t('oauthFlow.copyLinkTooltip')"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
@@ -64,7 +62,7 @@
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,18 +81,18 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.step2Title') }}
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('oauthFlow.step2Description') }}
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>{{ t('oauthFlow.proxyNotice') }}</strong
|
||||
>{{ t('oauthFlow.proxyNoticeText') }}
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,32 +111,29 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('oauthFlow.step3Title') }}
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('oauthFlow.step3Description') }}
|
||||
<strong>{{ t('oauthFlow.authorizationCode') }}</strong
|
||||
>{{ t('oauthFlow.step3DescriptionMiddle') }}
|
||||
授权完成后,页面会显示一个
|
||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-blue-500" />{{
|
||||
t('oauthFlow.authorizationCode')
|
||||
}}
|
||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
:placeholder="t('oauthFlow.authCodePlaceholder')"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('oauthFlow.authCodeHint') }}
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,11 +157,9 @@
|
||||
<i class="fas fa-robot text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.geminiAccountAuth') }}
|
||||
</h4>
|
||||
<h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">Gemini 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-green-800 dark:text-green-300">
|
||||
{{ t('oauthFlow.geminiAuthDescription') }}
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -182,7 +175,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.step1Title') }}
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -192,7 +185,7 @@
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }}
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -204,7 +197,7 @@
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="t('oauthFlow.copyLinkTooltip')"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
@@ -214,7 +207,7 @@
|
||||
class="text-xs text-green-600 hover:text-green-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,18 +226,18 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.step2Title') }}
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('oauthFlow.step2DescriptionGemini') }}
|
||||
请在新标签页中打开授权链接,登录您的 Gemini 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>{{ t('oauthFlow.proxyNotice') }}</strong
|
||||
>{{ t('oauthFlow.proxyNoticeText') }}
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,31 +256,29 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
|
||||
{{ t('oauthFlow.step3Title') }}
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('oauthFlow.step3DescriptionGemini') }}
|
||||
授权完成后,页面会显示一个 Authorization Code,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-green-500" />{{
|
||||
t('oauthFlow.authorizationCode')
|
||||
}}
|
||||
<i class="fas fa-key mr-2 text-green-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
:placeholder="t('oauthFlow.authCodePlaceholderGemini')"
|
||||
placeholder="粘贴从Gemini页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-check-circle mr-1 text-green-500" />
|
||||
{{ t('oauthFlow.authCodeHintGemini') }}
|
||||
请粘贴从Gemini页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,11 +303,9 @@
|
||||
<i class="fas fa-brain text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.openaiAccountAuth') }}
|
||||
</h4>
|
||||
<h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">OpenAI 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-orange-800 dark:text-orange-300">
|
||||
{{ t('oauthFlow.openaiAuthDescription') }}
|
||||
请按照以下步骤完成 OpenAI 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -332,7 +321,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.step1Title') }}
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -342,7 +331,7 @@
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? t('oauthFlow.generating') : t('oauthFlow.generateAuthLink') }}
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -354,7 +343,7 @@
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="t('oauthFlow.copyLinkTooltip')"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
@@ -364,7 +353,7 @@
|
||||
class="text-xs text-orange-600 hover:text-orange-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('oauthFlow.regenerate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,23 +372,22 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.step2Title') }}
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="mb-2 text-sm text-orange-700 dark:text-orange-300">
|
||||
{{ t('oauthFlow.step2DescriptionOpenAI') }}
|
||||
请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。
|
||||
</p>
|
||||
<div
|
||||
class="mb-3 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<p class="text-xs text-amber-800 dark:text-amber-300">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
<strong>{{ t('oauthFlow.openaiImportantNote') }}</strong
|
||||
>{{ t('oauthFlow.openaiLoadingNote') }}
|
||||
<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('oauthFlow.openaiAddressNote') }}
|
||||
当浏览器地址栏变为
|
||||
<strong class="font-mono">http://localhost:1455/...</strong>
|
||||
{{ t('oauthFlow.openaiAddressNoteMiddle') }}
|
||||
开头时,表示授权已完成。
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -407,8 +395,8 @@
|
||||
>
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>{{ t('oauthFlow.proxyNotice') }}</strong
|
||||
>{{ t('oauthFlow.proxyNoticeText') }}
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,26 +415,23 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
|
||||
{{ t('oauthFlow.step3TitleOpenAI') }}
|
||||
输入授权链接或 Code
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-orange-700 dark:text-orange-300">
|
||||
{{ t('oauthFlow.step3DescriptionOpenAI') }}
|
||||
<strong class="font-mono">http://localhost:1455/...</strong>
|
||||
{{ t('oauthFlow.step3DescriptionOpenAIMiddle') }}
|
||||
授权完成后,当页面地址变为
|
||||
<strong class="font-mono">http://localhost:1455/...</strong> 时:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-link mr-2 text-orange-500" />{{
|
||||
t('oauthFlow.authLinkOrCode')
|
||||
}}
|
||||
<i class="fas fa-link mr-2 text-orange-500" />授权链接或 Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
:placeholder="t('oauthFlow.authCodePlaceholderOpenAI')"
|
||||
placeholder="方式1:复制完整的链接(http://localhost:1455/auth/callback?code=...) 方式2:仅复制 code 参数的值 系统会自动识别并提取所需信息"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
@@ -455,18 +440,18 @@
|
||||
>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<i class="fas fa-lightbulb mr-1" />
|
||||
<strong>{{ t('oauthFlow.openaiTip') }}</strong
|
||||
>{{ t('oauthFlow.openaiTipText') }}
|
||||
<strong>提示:</strong>您可以直接复制整个链接或仅复制 code
|
||||
参数值,系统会自动识别。
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t('oauthFlow.openaiLinkExample')
|
||||
}}<span class="font-mono"
|
||||
• 完整链接示例:<span class="font-mono"
|
||||
>http://localhost:1455/auth/callback?code=ac_4hm8...</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-blue-600">
|
||||
{{ t('oauthFlow.openaiCodeExample')
|
||||
}}<span class="font-mono">ac_4hm8iqmx9A2fzMy_cwye7U3W7...</span>
|
||||
• 仅 Code 示例:<span class="font-mono"
|
||||
>ac_4hm8iqmx9A2fzMy_cwye7U3W7...</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +470,7 @@
|
||||
type="button"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ t('oauthFlow.previousStep') }}
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -494,7 +479,7 @@
|
||||
@click="exchangeCode"
|
||||
>
|
||||
<div v-if="exchanging" class="loading-spinner mr-2" />
|
||||
{{ exchanging ? t('oauthFlow.verifying') : t('oauthFlow.completeAuth') }}
|
||||
{{ exchanging ? '验证中...' : '完成授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -502,12 +487,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
@@ -562,16 +544,16 @@ watch(authCode, (newValue) => {
|
||||
if (code) {
|
||||
// 成功提取授权码
|
||||
authCode.value = code
|
||||
showToast(t('oauthFlow.successExtractCode'), 'success')
|
||||
showToast('成功提取授权码!', 'success')
|
||||
console.log('Successfully extracted authorization code from URL')
|
||||
} else {
|
||||
// URL 中没有 code 参数
|
||||
showToast(t('oauthFlow.errorCodeNotFound'), 'error')
|
||||
showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
// URL 解析失败
|
||||
console.error('Failed to parse URL:', error)
|
||||
showToast(t('oauthFlow.errorLinkFormat'), 'error')
|
||||
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
||||
}
|
||||
} else if (props.platform === 'gemini' || props.platform === 'openai') {
|
||||
// Gemini 和 OpenAI 平台可能使用不同的回调URL
|
||||
@@ -582,14 +564,14 @@ watch(authCode, (newValue) => {
|
||||
|
||||
if (code) {
|
||||
authCode.value = code
|
||||
showToast(t('oauthFlow.successExtractCode'), 'success')
|
||||
showToast('成功提取授权码!', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
// 不是有效的URL,保持原值
|
||||
}
|
||||
} else {
|
||||
// 错误的 URL(不是正确的 localhost 回调地址)
|
||||
showToast(t('oauthFlow.errorWrongUrlFormat'), 'error')
|
||||
showToast('请粘贴以 http://localhost:1455 或 http://localhost:45462 开头的链接', 'error')
|
||||
}
|
||||
}
|
||||
// 如果不是 URL,保持原值(兼容直接输入授权码)
|
||||
@@ -625,7 +607,7 @@ const generateAuthUrl = async () => {
|
||||
sessionId.value = result.sessionId
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || t('oauthFlow.generateAuthFailed'), 'error')
|
||||
showToast(error.message || '生成授权链接失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -643,7 +625,7 @@ const copyAuthUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl.value)
|
||||
copied.value = true
|
||||
showToast(t('oauthFlow.linkCopied'), 'success')
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
@@ -656,7 +638,7 @@ const copyAuthUrl = async () => {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
showToast(t('oauthFlow.linkCopied'), 'success')
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
@@ -713,7 +695,7 @@ const exchangeCode = async () => {
|
||||
|
||||
emit('success', tokenInfo)
|
||||
} catch (error) {
|
||||
showToast(error.message || t('oauthFlow.authFailed'), 'error')
|
||||
showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
|
||||
} finally {
|
||||
exchanging.value = false
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ t('proxyConfig.title') }}
|
||||
</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">代理设置 (可选)</h4>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="proxy.enabled"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.enableProxy')
|
||||
}}</span>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">启用代理</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +22,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('proxyConfig.configDescription') }}
|
||||
配置代理以访问受限的网络资源。支持 SOCKS5 和 HTTP 代理。
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('proxyConfig.stabilityNotice') }}
|
||||
请确保代理服务器稳定可用,否则会影响账户的正常使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,9 +70,9 @@
|
||||
<div class="my-3 border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.proxyType')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>代理类型</label
|
||||
>
|
||||
<select
|
||||
v-model="proxy.type"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@@ -89,24 +85,24 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.hostAddress')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>主机地址</label
|
||||
>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.hostPlaceholder')"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.port')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>端口</label
|
||||
>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.portPlaceholder')"
|
||||
placeholder="例如: 1080"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,31 +120,31 @@
|
||||
class="ml-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
|
||||
for="proxyAuth"
|
||||
>
|
||||
{{ t('proxyConfig.needsAuth') }}
|
||||
需要身份验证
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.username')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>用户名</label
|
||||
>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.usernamePlaceholder')"
|
||||
placeholder="代理用户名"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('proxyConfig.password')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>密码</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('proxyConfig.passwordPlaceholder')"
|
||||
placeholder="代理密码"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
/>
|
||||
<button
|
||||
@@ -168,8 +164,8 @@
|
||||
>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<strong>{{ t('proxyConfig.tip') }}</strong
|
||||
>{{ t('proxyConfig.apiRequestNotice') }}
|
||||
<strong>提示:</strong
|
||||
>代理设置将用于所有与此账户相关的API请求。请确保代理服务器支持HTTPS流量转发。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,9 +174,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ $t('user.changeRoleModal.title') }}</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">Change User Role</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -54,7 +54,7 @@
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
]"
|
||||
>
|
||||
{{ $t('user.changeRoleModal.currentRole', { role: user.role }) }}
|
||||
Current: {{ user.role }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,9 +64,7 @@
|
||||
<!-- Role Selection -->
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">
|
||||
{{ $t('user.changeRoleModal.newRole') }}
|
||||
</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
@@ -77,12 +75,8 @@
|
||||
value="user"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $t('user.changeRoleModal.roles.user') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ $t('user.changeRoleModal.roles.userDesc') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">User</div>
|
||||
<div class="text-xs text-gray-500">Regular user with basic permissions</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
@@ -94,12 +88,8 @@
|
||||
value="admin"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $t('user.changeRoleModal.roles.admin') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ $t('user.changeRoleModal.roles.adminDesc') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">Administrator</div>
|
||||
<div class="text-xs text-gray-500">Full access to manage users and system</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -121,15 +111,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
{{ $t('user.changeRoleModal.roleChangeWarning.title') }}
|
||||
</h3>
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p v-if="selectedRole === 'admin'">
|
||||
{{ $t('user.changeRoleModal.roleChangeWarning.grantAdmin') }}
|
||||
Granting admin privileges will give this user full access to the system,
|
||||
including the ability to manage other users and their API keys.
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ $t('user.changeRoleModal.roleChangeWarning.removeAdmin') }}
|
||||
Removing admin privileges will restrict this user to only managing their own
|
||||
API keys and viewing their own usage statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +150,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('user.changeRoleModal.cancel') }}
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -188,9 +178,9 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{{ $t('user.changeRoleModal.updating') }}
|
||||
Updating...
|
||||
</span>
|
||||
<span v-else>{{ $t('user.changeRoleModal.updateRole') }}</span>
|
||||
<span v-else>Update Role</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -204,9 +194,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -239,7 +226,7 @@ const handleSubmit = async () => {
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast(t('user.changeRoleModal.roleUpdated', { role: selectedRole.value }), 'success')
|
||||
showToast(`User role updated to ${selectedRole.value}`, 'success')
|
||||
emit('updated')
|
||||
} else {
|
||||
error.value = response.message || 'Failed to update user role'
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
{{
|
||||
$t('user.usageStatsModal.titleWithUser', {
|
||||
displayName: user?.displayName || user?.username
|
||||
})
|
||||
}}
|
||||
Usage Statistics - {{ user?.displayName || user?.username }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">@{{ user?.username }} • {{ user?.role }}</p>
|
||||
</div>
|
||||
@@ -35,12 +31,10 @@
|
||||
class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
@change="loadUsageStats"
|
||||
>
|
||||
<option value="day">{{ $t('user.usageStatsModal.periodSelection.day') }}</option>
|
||||
<option value="week">{{ $t('user.usageStatsModal.periodSelection.week') }}</option>
|
||||
<option value="month">{{ $t('user.usageStatsModal.periodSelection.month') }}</option>
|
||||
<option value="quarter">
|
||||
{{ $t('user.usageStatsModal.periodSelection.quarter') }}
|
||||
</option>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
<option value="month">Last 30 Days</option>
|
||||
<option value="quarter">Last 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +60,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">{{ $t('user.usageStatsModal.loadingStats') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Content -->
|
||||
@@ -93,9 +87,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-blue-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.requests') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
|
||||
<dd class="text-lg font-medium text-blue-900">
|
||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||
</dd>
|
||||
@@ -125,9 +117,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-green-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.inputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
|
||||
<dd class="text-lg font-medium text-green-900">
|
||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -157,9 +147,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-purple-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.outputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
|
||||
<dd class="text-lg font-medium text-purple-900">
|
||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -189,9 +177,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-yellow-600">
|
||||
{{ $t('user.usageStatsModal.summaryCards.totalCost') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
|
||||
<dd class="text-lg font-medium text-yellow-900">
|
||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||
</dd>
|
||||
@@ -208,9 +194,7 @@
|
||||
class="rounded-lg border border-gray-200 bg-white"
|
||||
>
|
||||
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.title') }}
|
||||
</h4>
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -220,37 +204,37 @@
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.apiKey') }}
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.status') }}
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.requests') }}
|
||||
Requests
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.tokens') }}
|
||||
Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.cost') }}
|
||||
Cost
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.headers.lastUsed') }}
|
||||
Last Used
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -269,35 +253,21 @@
|
||||
: 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
apiKey.isActive
|
||||
? $t('user.usageStatsModal.apiKeysTable.status.active')
|
||||
: $t('user.usageStatsModal.apiKeysTable.status.disabled')
|
||||
}}
|
||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
<div>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.tokensFormat.input') }}:
|
||||
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('user.usageStatsModal.apiKeysTable.tokensFormat.output') }}:
|
||||
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
|
||||
</div>
|
||||
<div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
|
||||
<div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{{
|
||||
apiKey.lastUsedAt
|
||||
? formatDate(apiKey.lastUsedAt)
|
||||
: $t('user.usageStatsModal.apiKeysTable.never')
|
||||
}}
|
||||
{{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -308,9 +278,7 @@
|
||||
<!-- Chart Placeholder -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">
|
||||
{{ $t('user.usageStatsModal.usageTrend.title') }}
|
||||
</h4>
|
||||
<h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div
|
||||
@@ -330,16 +298,12 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ $t('user.usageStatsModal.usageTrend.chartTitle') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{
|
||||
$t('user.usageStatsModal.usageTrend.dailyTrends', { period: selectedPeriod })
|
||||
}}
|
||||
Daily usage trends for {{ selectedPeriod }} period
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{{ $t('user.usageStatsModal.usageTrend.chartNote') }}
|
||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,11 +325,9 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ $t('user.usageStatsModal.noData.title') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ $t('user.usageStatsModal.noData.description') }}
|
||||
This user hasn't made any API requests in the selected period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,7 +337,7 @@
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('user.usageStatsModal.close') }}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +349,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
// import { useI18n } from 'vue-i18n' - using $t in template instead
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
||||
@@ -12,17 +12,13 @@
|
||||
<i class="fas fa-layer-group text-lg text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
{{ $t('apiKeys.batchApiKeyModal.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.successMessage', { count: apiKeys.length }) }}
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900">批量创建成功</h3>
|
||||
<p class="text-sm text-gray-600">成功创建 {{ apiKeys.length }} 个 API Key</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
:title="$t('apiKeys.batchApiKeyModal.directCloseTooltip')"
|
||||
title="直接关闭(不推荐)"
|
||||
@click="handleDirectClose"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
@@ -38,11 +34,10 @@
|
||||
<i class="fas fa-exclamation-triangle text-sm text-white" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h5 class="mb-1 font-semibold text-amber-900">
|
||||
{{ $t('apiKeys.batchApiKeyModal.importantReminder') }}
|
||||
</h5>
|
||||
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
|
||||
<p class="text-sm text-amber-800">
|
||||
{{ $t('apiKeys.batchApiKeyModal.warningMessage') }}
|
||||
这是您唯一能看到所有 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API
|
||||
Key。请立即下载并妥善保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,9 +50,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-blue-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.createdCount') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-blue-600">创建数量</p>
|
||||
<p class="mt-1 text-2xl font-bold text-blue-900">
|
||||
{{ apiKeys.length }}
|
||||
</p>
|
||||
@@ -75,9 +68,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-green-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.baseName') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-green-600">基础名称</p>
|
||||
<p class="mt-1 truncate text-lg font-bold text-green-900">
|
||||
{{ baseName }}
|
||||
</p>
|
||||
@@ -95,9 +86,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-purple-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.permissionScope') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-purple-600">权限范围</p>
|
||||
<p class="mt-1 text-lg font-bold text-purple-900">
|
||||
{{ getPermissionText() }}
|
||||
</p>
|
||||
@@ -115,9 +104,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-orange-600">
|
||||
{{ $t('apiKeys.batchApiKeyModal.expiryTime') }}
|
||||
</p>
|
||||
<p class="text-xs font-medium text-orange-600">过期时间</p>
|
||||
<p class="mt-1 text-lg font-bold text-orange-900">
|
||||
{{ getExpiryText() }}
|
||||
</p>
|
||||
@@ -134,9 +121,7 @@
|
||||
<!-- API Keys 预览 -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700">{{
|
||||
$t('apiKeys.batchApiKeyModal.previewTitle')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700">API Keys 预览</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
@@ -144,15 +129,9 @@
|
||||
@click="togglePreview"
|
||||
>
|
||||
<i :class="['fas', showPreview ? 'fa-eye-slash' : 'fa-eye']" />
|
||||
{{
|
||||
showPreview
|
||||
? $t('apiKeys.batchApiKeyModal.hide')
|
||||
: $t('apiKeys.batchApiKeyModal.show')
|
||||
}}{{ $t('apiKeys.batchApiKeyModal.preview') }}
|
||||
{{ showPreview ? '隐藏' : '显示' }}预览
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">{{
|
||||
$t('apiKeys.batchApiKeyModal.maxDisplayNote')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500">(最多显示前10个)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,13 +150,13 @@
|
||||
@click="downloadApiKeys"
|
||||
>
|
||||
<i class="fas fa-download" />
|
||||
{{ $t('apiKeys.batchApiKeyModal.downloadAll') }}
|
||||
下载所有 API Keys
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ $t('apiKeys.batchApiKeyModal.alreadySaved') }}
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +165,8 @@
|
||||
<p class="flex items-start text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{{ $t('apiKeys.batchApiKeyModal.fileFormatInfo') }}
|
||||
下载的文件格式为文本文件(.txt),每行包含一个 API Key。
|
||||
请将文件保存在安全的位置,避免泄露。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -197,11 +177,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
apiKeys: {
|
||||
type: Array,
|
||||
@@ -226,28 +203,30 @@ const baseName = computed(() => {
|
||||
|
||||
// 获取权限文本
|
||||
const getPermissionText = () => {
|
||||
if (props.apiKeys.length === 0) return t('apiKeys.batchApiKeyModal.permissions.unknown')
|
||||
if (props.apiKeys.length === 0) return '未知'
|
||||
const permissions = props.apiKeys[0].permissions
|
||||
const permissionKey = `apiKeys.batchApiKeyModal.permissions.${permissions}`
|
||||
return t(permissionKey, t('apiKeys.batchApiKeyModal.permissions.unknown'))
|
||||
const permissionMap = {
|
||||
all: '全部服务',
|
||||
claude: '仅 Claude',
|
||||
gemini: '仅 Gemini'
|
||||
}
|
||||
return permissionMap[permissions] || permissions
|
||||
}
|
||||
|
||||
// 获取过期时间文本
|
||||
const getExpiryText = () => {
|
||||
if (props.apiKeys.length === 0) return t('apiKeys.batchApiKeyModal.permissions.unknown')
|
||||
if (props.apiKeys.length === 0) return '未知'
|
||||
const expiresAt = props.apiKeys[0].expiresAt
|
||||
if (!expiresAt) return t('apiKeys.batchApiKeyModal.neverExpire')
|
||||
if (!expiresAt) return '永不过期'
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 7) return t('apiKeys.batchApiKeyModal.daysFormat', { days: diffDays })
|
||||
if (diffDays <= 30)
|
||||
return t('apiKeys.batchApiKeyModal.weeksFormat', { weeks: Math.ceil(diffDays / 7) })
|
||||
if (diffDays <= 365)
|
||||
return t('apiKeys.batchApiKeyModal.monthsFormat', { months: Math.ceil(diffDays / 30) })
|
||||
return t('apiKeys.batchApiKeyModal.yearsFormat', { years: Math.ceil(diffDays / 365) })
|
||||
if (diffDays <= 7) return `${diffDays}天`
|
||||
if (diffDays <= 30) return `${Math.ceil(diffDays / 7)}周`
|
||||
if (diffDays <= 365) return `${Math.ceil(diffDays / 30)}个月`
|
||||
return `${Math.ceil(diffDays / 365)}年`
|
||||
}
|
||||
|
||||
// 切换预览显示
|
||||
@@ -263,7 +242,7 @@ const getPreviewText = () => {
|
||||
})
|
||||
|
||||
if (props.apiKeys.length > 10) {
|
||||
lines.push(t('apiKeys.batchApiKeyModal.moreKeysNote', { count: props.apiKeys.length - 10 }))
|
||||
lines.push(`... 还有 ${props.apiKeys.length - 10} 个 API Key`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
@@ -298,24 +277,26 @@ const downloadApiKeys = () => {
|
||||
// 释放 URL 对象
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
showToast(t('apiKeys.batchApiKeyModal.downloadSuccess'), 'success')
|
||||
showToast('API Keys 文件已下载', 'success')
|
||||
}
|
||||
|
||||
// 关闭弹窗(带确认)
|
||||
const handleClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.batchApiKeyModal.closeReminderTitle'),
|
||||
t('apiKeys.batchApiKeyModal.closeReminderMessage'),
|
||||
t('apiKeys.batchApiKeyModal.confirmCloseButton'),
|
||||
t('apiKeys.batchApiKeyModal.goBackDownloadButton')
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'返回下载'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.batchApiKeyModal.closeReminderMessage'))
|
||||
const confirmed = confirm(
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
@@ -326,17 +307,17 @@ const handleClose = async () => {
|
||||
const handleDirectClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.batchApiKeyModal.directCloseTitle'),
|
||||
t('apiKeys.batchApiKeyModal.directCloseMessage'),
|
||||
t('apiKeys.batchApiKeyModal.stillCloseButton'),
|
||||
t('apiKeys.batchApiKeyModal.goBackDownloadButton')
|
||||
'确定要关闭吗?',
|
||||
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
|
||||
'仍然关闭',
|
||||
'返回下载'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.batchApiKeyModal.directCloseFallbackMessage'))
|
||||
const confirmed = confirm('您还没有下载 API Keys,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<i class="fas fa-edit text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.title', { count: selectedCount }) }}
|
||||
批量编辑 API Keys ({{ selectedCount }} 个)
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -32,11 +32,10 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-info-circle mt-1 text-blue-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.infoTitle') }}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">批量编辑说明</p>
|
||||
<p class="mt-1 text-sm text-blue-700 dark:text-blue-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.infoContent', { count: selectedCount }) }}
|
||||
以下设置将应用到所选的 {{ selectedCount }} 个 API
|
||||
Key。只有填写或修改的字段才会被更新,空白字段将保持原值不变。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,34 +46,26 @@
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.tagLabel') }}
|
||||
标签 (批量操作)
|
||||
</label>
|
||||
<div class="space-y-4">
|
||||
<!-- 标签操作模式选择 -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="replace" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.replace')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">替换标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="add" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.add')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">添加标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="remove" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.remove')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">移除标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="none" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.tagOperations.none')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">不修改标签</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -85,10 +76,10 @@
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{
|
||||
tagOperation === 'replace'
|
||||
? $t('apiKeys.batchEditApiKeyModal.newTagsList')
|
||||
? '新标签列表:'
|
||||
: tagOperation === 'add'
|
||||
? $t('apiKeys.batchEditApiKeyModal.tagsToAdd')
|
||||
: $t('apiKeys.batchEditApiKeyModal.tagsToRemove')
|
||||
? '要添加的标签:'
|
||||
: '要移除的标签:'
|
||||
}}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -112,7 +103,7 @@
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.clickToSelectTags') }}
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -131,13 +122,13 @@
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.createNewTag') }}
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.inputNewTagPlaceholder')"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
@@ -164,48 +155,46 @@
|
||||
>
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.rateLimitTitle') }}
|
||||
</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">速率限制设置</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.rateLimitWindow') }}
|
||||
时间窗口 (分钟)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.rateLimitRequests')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>请求次数限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.rateLimitCost')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.noModifyPlaceholder')"
|
||||
placeholder="不修改"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
@@ -217,13 +206,13 @@
|
||||
<!-- 每日费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.dailyCostLimit') }}
|
||||
每日费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.dailyCostLimitPlaceholder')"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
@@ -232,31 +221,31 @@
|
||||
<!-- Opus 模型周费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.weeklyOpusCostLimit') }}
|
||||
Opus 模型周费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.weeklyOpusCostLimitPlaceholder')"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.opusLimitDescription') }}
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 并发限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.concurrencyLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.batchEditApiKeyModal.concurrencyLimitPlaceholder')"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -264,27 +253,19 @@
|
||||
<!-- 激活状态 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.activeStatus')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">激活状态</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="true" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.statusOptions.active')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">激活</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="false" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.statusOptions.disabled')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">禁用</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="null" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.statusOptions.noChange')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">不修改</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,39 +273,29 @@
|
||||
|
||||
<!-- 服务权限 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.servicePermissions')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.noChange')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">不修改</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="all" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.all')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.claude')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.gemini')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.permissionOptions.openai')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,13 +303,13 @@
|
||||
<!-- 专属账号绑定 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.accountBinding')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>专属账号绑定</label
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:disabled="accountsLoading"
|
||||
:title="$t('apiKeys.batchEditApiKeyModal.refreshAccounts')"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
@@ -349,46 +320,31 @@
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{
|
||||
accountsLoading
|
||||
? $t('apiKeys.batchEditApiKeyModal.refreshing')
|
||||
: $t('apiKeys.batchEditApiKeyModal.refreshAccounts')
|
||||
}}</span>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.claudeAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Claude 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.claudeGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix')
|
||||
}}{{ group.name }}
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<optgroup v-if="localAccounts.claude.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.claude"
|
||||
:key="account.id"
|
||||
@@ -404,37 +360,26 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.geminiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Gemini 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.geminiGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix')
|
||||
}}{{ group.name }}
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<optgroup v-if="localAccounts.gemini.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.gemini"
|
||||
:key="account.id"
|
||||
@@ -446,37 +391,26 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.openaiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>OpenAI 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.openaiAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.openaiGroups.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.accountGroups')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.openaiGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.openaiGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.groupPrefix')
|
||||
}}{{ group.name }}
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.openai.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<optgroup v-if="localAccounts.openai.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.openai"
|
||||
:key="account.id"
|
||||
@@ -488,24 +422,17 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.batchEditApiKeyModal.bedrockAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Bedrock 专属账号</label
|
||||
>
|
||||
<select
|
||||
v-model="form.bedrockAccountId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.noChange') }}
|
||||
</option>
|
||||
<option value="SHARED_POOL">
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.accountOptions.sharedPool') }}
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.bedrock.length > 0"
|
||||
:label="$t('apiKeys.batchEditApiKeyModal.optgroupLabels.dedicatedAccounts')"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.bedrock.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.bedrock"
|
||||
:key="account.id"
|
||||
@@ -525,7 +452,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.batchEditApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -534,11 +461,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
loading
|
||||
? $t('apiKeys.batchEditApiKeyModal.saving')
|
||||
: $t('apiKeys.batchEditApiKeyModal.batchSave')
|
||||
}}
|
||||
{{ loading ? '保存中...' : '批量保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -549,13 +472,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
selectedKeys: {
|
||||
type: Array,
|
||||
@@ -700,9 +620,9 @@ const refreshAccounts = async () => {
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.refreshAccountsSuccess'), 'success')
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.refreshAccountsFailed'), 'error')
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -802,33 +722,24 @@ const batchUpdateApiKeys = async () => {
|
||||
const { successCount, failedCount, errors } = result.data
|
||||
|
||||
if (successCount > 0) {
|
||||
showToast(
|
||||
t('apiKeys.batchEditApiKeyModal.batchEditSuccess', { count: successCount }),
|
||||
'success'
|
||||
)
|
||||
showToast(`成功批量编辑 ${successCount} 个 API Keys`, 'success')
|
||||
|
||||
if (failedCount > 0) {
|
||||
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
|
||||
showToast(
|
||||
t('apiKeys.batchEditApiKeyModal.batchEditPartialFail', {
|
||||
failedCount,
|
||||
errors: errorMessages
|
||||
}),
|
||||
'warning'
|
||||
)
|
||||
showToast(`${failedCount} 个编辑失败:\n${errorMessages}`, 'warning')
|
||||
}
|
||||
} else {
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.batchEditAllFailed'), 'error')
|
||||
showToast('所有 API Keys 编辑失败', 'error')
|
||||
}
|
||||
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.batchEditApiKeyModal.batchEditFailed'), 'error')
|
||||
showToast(result.message || '批量编辑失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.batchEditApiKeyModal.batchEditFailed'), 'error')
|
||||
console.error(t('apiKeys.batchEditApiKeyModal.batchEditErrorLog'), error)
|
||||
showToast('批量编辑失败', 'error')
|
||||
console.error('批量编辑 API Keys 失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<i class="fas fa-key text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ $t('apiKeys.createApiKeyModal.title') }}
|
||||
创建新的 API Key
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -37,7 +37,7 @@
|
||||
>
|
||||
<label
|
||||
class="flex h-full items-center text-xs font-semibold text-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
>{{ $t('apiKeys.createApiKeyModal.createType') }}</label
|
||||
>创建类型</label
|
||||
>
|
||||
<div class="flex items-center gap-3 sm:gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
@@ -51,7 +51,7 @@
|
||||
class="flex items-center text-xs text-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
>
|
||||
<i class="fas fa-key mr-1 text-xs" />
|
||||
{{ $t('apiKeys.createApiKeyModal.singleCreate') }}
|
||||
单个创建
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
@@ -65,7 +65,7 @@
|
||||
class="flex items-center text-xs text-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
>
|
||||
<i class="fas fa-layer-group mr-1 text-xs" />
|
||||
{{ $t('apiKeys.createApiKeyModal.batchCreate') }}
|
||||
批量创建
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -75,30 +75,32 @@
|
||||
<div v-if="form.createType === 'batch'" class="mt-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.batchCount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>创建数量</label
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="form.batchCount"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
max="500"
|
||||
min="2"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.batchCountPlaceholder')"
|
||||
placeholder="输入数量 (2-500)"
|
||||
required
|
||||
type="number"
|
||||
/>
|
||||
<div class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.maxSupported') }}
|
||||
最大支持 500 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 flex items-start text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-info-circle mr-1 mt-0.5 flex-shrink-0" />
|
||||
<span>{{
|
||||
$t('apiKeys.createApiKeyModal.batchHint', { name: form.name || 'MyKey' })
|
||||
}}</span>
|
||||
<span
|
||||
>批量创建时,每个 Key 的名称会自动添加序号后缀,例如:{{
|
||||
form.name || 'MyKey'
|
||||
}}_1, {{ form.name || 'MyKey' }}_2 ...</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,24 +108,23 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
|
||||
>{{ $t('apiKeys.createApiKeyModal.name') }}
|
||||
<span class="text-red-500">{{
|
||||
$t('apiKeys.createApiKeyModal.nameRequired')
|
||||
}}</span></label
|
||||
>名称 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? $t('apiKeys.createApiKeyModal.batchNamePlaceholder')
|
||||
: $t('apiKeys.createApiKeyModal.singleNamePlaceholder')
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? '输入基础名称(将自动添加序号)'
|
||||
: '为您的 API Key 取一个名称'
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.name }}
|
||||
</p>
|
||||
@@ -131,14 +132,14 @@
|
||||
|
||||
<!-- 标签 -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.tags')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>标签</label
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 已选择的标签 -->
|
||||
<div v-if="form.tags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.selectedTags') }}
|
||||
已选择的标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -161,7 +162,7 @@
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.clickToSelectTags') }}
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -180,13 +181,13 @@
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.createNewTag') }}
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.newTagPlaceholder')"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
@@ -201,7 +202,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.tagHint') }}
|
||||
用于标记不同团队或用途,方便筛选管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,76 +218,68 @@
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitTitle') }}
|
||||
速率限制设置 (可选)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.rateLimitWindow')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>时间窗口 (分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitWindowPlaceholder')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitWindowHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.rateLimitRequests')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>请求次数限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitRequestsPlaceholder')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitRequestsHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.rateLimitCost')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.rateLimitCostPlaceholder')"
|
||||
placeholder="无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.rateLimitCostHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.exampleTitle') }}
|
||||
💡 使用示例
|
||||
</h5>
|
||||
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
|
||||
<div>
|
||||
<strong>{{ $t('apiKeys.createApiKeyModal.example1') }}</strong>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用</div>
|
||||
<div>
|
||||
<strong>{{ $t('apiKeys.createApiKeyModal.example2') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ $t('apiKeys.createApiKeyModal.example3') }}</strong>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,9 +287,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.dailyCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>每日费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -325,27 +318,27 @@
|
||||
type="button"
|
||||
@click="form.dailyCostLimit = ''"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.dailyCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.dailyCostHint') }}
|
||||
设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.weeklyOpusCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -374,55 +367,55 @@
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = ''"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.weeklyOpusCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.weeklyOpusHint') }}
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.concurrencyLimit')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.concurrencyLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.concurrencyHint') }}
|
||||
设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.description')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>备注 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input w-full resize-none border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.descriptionPlaceholder')"
|
||||
placeholder="描述此 API Key 的用途..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.expirationSettings')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>过期设置</label
|
||||
>
|
||||
<!-- 过期模式选择 -->
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
|
||||
@@ -435,9 +428,7 @@
|
||||
type="radio"
|
||||
value="fixed"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.fixedTimeExpiry')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">固定时间过期</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -446,19 +437,17 @@
|
||||
type="radio"
|
||||
value="activation"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.activationExpiry')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">首次使用后激活</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="form.expirationMode === 'fixed'">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ $t('apiKeys.createApiKeyModal.fixedModeHint') }}
|
||||
固定时间模式:Key 创建后立即生效,按设定时间过期
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ $t('apiKeys.createApiKeyModal.activationModeHint') }}
|
||||
激活模式:Key 首次使用时激活,激活后按设定天数过期(适合批量销售)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -470,14 +459,14 @@
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@change="updateExpireAt"
|
||||
>
|
||||
<option value="">{{ $t('apiKeys.createApiKeyModal.neverExpire') }}</option>
|
||||
<option value="1d">{{ $t('apiKeys.createApiKeyModal.1d') }}</option>
|
||||
<option value="7d">{{ $t('apiKeys.createApiKeyModal.7d') }}</option>
|
||||
<option value="30d">{{ $t('apiKeys.createApiKeyModal.30d') }}</option>
|
||||
<option value="90d">{{ $t('apiKeys.createApiKeyModal.90d') }}</option>
|
||||
<option value="180d">{{ $t('apiKeys.createApiKeyModal.180d') }}</option>
|
||||
<option value="365d">{{ $t('apiKeys.createApiKeyModal.365d') }}</option>
|
||||
<option value="custom">{{ $t('apiKeys.createApiKeyModal.customDate') }}</option>
|
||||
<option value="">永不过期</option>
|
||||
<option value="1d">1 天</option>
|
||||
<option value="7d">7 天</option>
|
||||
<option value="30d">30 天</option>
|
||||
<option value="90d">90 天</option>
|
||||
<option value="180d">180 天</option>
|
||||
<option value="365d">365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
</select>
|
||||
<div v-if="form.expireDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
@@ -489,11 +478,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
$t('apiKeys.createApiKeyModal.willExpireOn', {
|
||||
date: formatExpireDate(form.expiresAt)
|
||||
})
|
||||
}}
|
||||
将于 {{ formatExpireDate(form.expiresAt) }} 过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -505,12 +490,10 @@
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
max="3650"
|
||||
min="1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.activationDays')"
|
||||
placeholder="输入天数"
|
||||
type="number"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.daysUnit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">天</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -520,24 +503,20 @@
|
||||
type="button"
|
||||
@click="form.activationDays = days"
|
||||
>
|
||||
{{ days }}{{ $t('apiKeys.createApiKeyModal.daysUnit') }}
|
||||
{{ days }}天
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
{{
|
||||
$t('apiKeys.createApiKeyModal.activationHint', {
|
||||
days: form.activationDays || 30
|
||||
})
|
||||
}}
|
||||
Key 将在首次使用后激活,激活后 {{ form.activationDays || 30 }} 天过期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.servicePermissions')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -546,9 +525,7 @@
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.allServices')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -557,9 +534,7 @@
|
||||
type="radio"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.claudeOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -568,9 +543,7 @@
|
||||
type="radio"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.geminiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -579,25 +552,23 @@
|
||||
type="radio"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.openaiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.permissionHint') }}
|
||||
控制此 API Key 可以访问哪些服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.dedicatedAccountBinding')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>专属账号绑定 (可选)</label
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:disabled="accountsLoading"
|
||||
title="{{ $t('apiKeys.createApiKeyModal.refreshAccounts') }}"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
@@ -608,73 +579,69 @@
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{
|
||||
accountsLoading
|
||||
? $t('apiKeys.createApiKeyModal.refreshing')
|
||||
: $t('apiKeys.createApiKeyModal.refreshAccounts')
|
||||
}}</span>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.claudeDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Claude 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectClaudeAccount')"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.geminiDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Gemini 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectGeminiAccount')"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.openaiDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>OpenAI 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectOpenaiAccount')"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
$t('apiKeys.createApiKeyModal.bedrockDedicatedAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Bedrock 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
:default-option-text="$t('apiKeys.createApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="[]"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.selectBedrockAccount')"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.accountBindingHint') }}
|
||||
选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -690,15 +657,13 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="enableModelRestriction"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.enableModelRestriction') }}
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">{{
|
||||
$t('apiKeys.createApiKeyModal.restrictedModelsList')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label>
|
||||
<div
|
||||
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2"
|
||||
>
|
||||
@@ -717,7 +682,7 @@
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400">
|
||||
{{ $t('apiKeys.createApiKeyModal.noRestrictedModels') }}
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -736,7 +701,7 @@
|
||||
v-if="availableQuickModels.length === 0"
|
||||
class="text-sm italic text-gray-400"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.allCommonModelsRestricted') }}
|
||||
所有常用模型已在限制列表中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -745,7 +710,7 @@
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1"
|
||||
:placeholder="$t('apiKeys.createApiKeyModal.addRestrictedModelPlaceholder')"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
/>
|
||||
@@ -759,7 +724,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
{{ $t('apiKeys.createApiKeyModal.modelRestrictionHint') }}
|
||||
设置此API Key无法访问的模型,例如:claude-opus-4-20250514
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,7 +743,7 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="enableClientRestriction"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.enableClientRestriction') }}
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -787,9 +752,9 @@
|
||||
class="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/20"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('apiKeys.createApiKeyModal.allowedClients')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>允许的客户端</label
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
@@ -819,7 +784,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.createApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-4 py-2.5 text-sm font-semibold"
|
||||
@@ -828,11 +793,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-plus mr-2" />
|
||||
{{
|
||||
loading
|
||||
? $t('apiKeys.createApiKeyModal.creating')
|
||||
: $t('apiKeys.createApiKeyModal.create')
|
||||
}}
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -843,15 +804,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
accounts: {
|
||||
type: Object,
|
||||
@@ -928,31 +886,61 @@ onMounted(async () => {
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
})
|
||||
|
||||
// 刷新账号列表
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -986,13 +974,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -1008,9 +1014,9 @@ const refreshAccounts = async () => {
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast(t('apiKeys.createApiKeyModal.refreshAccountsSuccess'), 'success')
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.createApiKeyModal.refreshAccountsFailed'), 'error')
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -1071,17 +1077,13 @@ const updateCustomExpireAt = () => {
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const { locale } = useI18n()
|
||||
return date.toLocaleString(
|
||||
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加限制的模型
|
||||
@@ -1139,14 +1141,14 @@ const createApiKey = async () => {
|
||||
errors.value.name = ''
|
||||
|
||||
if (!form.name || !form.name.trim()) {
|
||||
errors.value.name = t('apiKeys.createApiKeyModal.nameError')
|
||||
errors.value.name = '请输入API Key名称'
|
||||
return
|
||||
}
|
||||
|
||||
// 批量创建时验证数量
|
||||
if (form.createType === 'batch') {
|
||||
if (!form.batchCount || form.batchCount < 2 || form.batchCount > 500) {
|
||||
showToast(t('apiKeys.createApiKeyModal.batchCountError'), 'error')
|
||||
showToast('批量创建数量必须在 2-500 之间', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1156,14 +1158,14 @@ const createApiKey = async () => {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmTitle'),
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmMessage'),
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmContinue'),
|
||||
t('apiKeys.createApiKeyModal.costLimitConfirmBack')
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续创建',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(t('apiKeys.createApiKeyModal.costLimitFallbackMessage'))
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
if (!confirmed) {
|
||||
return
|
||||
@@ -1252,11 +1254,11 @@ const createApiKey = async () => {
|
||||
const result = await apiClient.post('/admin/api-keys', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(t('apiKeys.createApiKeyModal.createSuccess'), 'success')
|
||||
showToast('API Key 创建成功', 'success')
|
||||
emit('success', result.data)
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.createApiKeyModal.createFailed'), 'error')
|
||||
showToast(result.message || '创建失败', 'error')
|
||||
}
|
||||
} else {
|
||||
// 批量创建
|
||||
@@ -1270,18 +1272,15 @@ const createApiKey = async () => {
|
||||
const result = await apiClient.post('/admin/api-keys/batch', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t('apiKeys.createApiKeyModal.batchCreateSuccess', { count: result.data.length }),
|
||||
'success'
|
||||
)
|
||||
showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
|
||||
emit('batch-success', result.data)
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.createApiKeyModal.batchCreateFailed'), 'error')
|
||||
showToast(result.message || '批量创建失败', 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.createApiKeyModal.createFailed'), 'error')
|
||||
showToast('创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<i class="fas fa-edit text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ t('apiKeys.editApiKeyModal.title') }}
|
||||
编辑 API Key
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -30,18 +30,20 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>{{ t('apiKeys.editApiKeyModal.name') }}</label
|
||||
>名称</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.namePlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
placeholder="请输入API Key名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
{{ t('apiKeys.editApiKeyModal.nameHint') }}
|
||||
用于识别此 API Key 的用途
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +51,7 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>{{ t('apiKeys.editApiKeyModal.owner') }}</label
|
||||
>所有者</label
|
||||
>
|
||||
<select
|
||||
v-model="form.ownerId"
|
||||
@@ -57,13 +59,11 @@
|
||||
>
|
||||
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||
{{ user.displayName }} ({{ user.username }})
|
||||
<span v-if="user.role === 'admin'" class="text-gray-500">{{
|
||||
t('apiKeys.editApiKeyModal.adminLabel')
|
||||
}}</span>
|
||||
<span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
{{ t('apiKeys.editApiKeyModal.ownerHint') }}
|
||||
分配此 API Key 给指定用户或管理员,管理员分配时不受用户 API Key 数量限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -71,13 +71,13 @@
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>{{ t('apiKeys.editApiKeyModal.tags') }}</label
|
||||
>标签</label
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 已选择的标签 -->
|
||||
<div v-if="form.tags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.selectedTags') }}
|
||||
已选择的标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -100,7 +100,7 @@
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.clickToSelectTags') }}
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -119,13 +119,13 @@
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.createNewTag') }}
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.newTagPlaceholder')"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
@@ -140,7 +140,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.tagsHint') }}
|
||||
用于标记不同团队或用途,方便筛选管理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,76 +156,68 @@
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitTitle') }}
|
||||
速率限制设置 (可选)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.rateLimitWindow')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>时间窗口 (分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.noLimit')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitWindowHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.rateLimitRequests')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>请求次数限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="1"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.noLimit')"
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitRequestsHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.rateLimitCost')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>费用限制 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitCost"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.noLimit')"
|
||||
placeholder="无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.rateLimitCostHint') }}
|
||||
</p>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
|
||||
{{ t('apiKeys.editApiKeyModal.usageExamples') }}
|
||||
💡 使用示例
|
||||
</h5>
|
||||
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
|
||||
<div>
|
||||
<strong>{{ t('apiKeys.editApiKeyModal.example1') }}</strong>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用</div>
|
||||
<div>
|
||||
<strong>{{ t('apiKeys.editApiKeyModal.example2') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ t('apiKeys.editApiKeyModal.example3') }}</strong>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,9 +225,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.dailyCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>每日费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -264,27 +256,27 @@
|
||||
type="button"
|
||||
@click="form.dailyCostLimit = ''"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.dailyCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.dailyCostHint') }}
|
||||
设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.weeklyOpusCostLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -313,36 +305,36 @@
|
||||
type="button"
|
||||
@click="form.weeklyOpusCostLimit = ''"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.weeklyOpusCostLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.weeklyOpusHint') }}
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.concurrencyLimit')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>并发限制</label
|
||||
>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
min="0"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.concurrencyLimitPlaceholder')"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.concurrencyHint') }}
|
||||
设置此 API Key 可同时处理的最大请求数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -359,18 +351,18 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="editIsActive"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.activeStatus') }}
|
||||
激活账号
|
||||
</label>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.activeStatusHint') }}
|
||||
取消勾选将禁用此 API Key,暂停所有请求,客户端返回 401 错误
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.servicePermissions')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -379,9 +371,7 @@
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.allServices')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -390,9 +380,7 @@
|
||||
type="radio"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.claudeOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -401,9 +389,7 @@
|
||||
type="radio"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.geminiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -412,25 +398,23 @@
|
||||
type="radio"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.openaiOnly')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.permissionsHint') }}
|
||||
控制此 API Key 可以访问哪些服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.editApiKeyModal.accountBinding')
|
||||
}}</label>
|
||||
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>专属账号绑定</label
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:disabled="accountsLoading"
|
||||
:title="t('apiKeys.editApiKeyModal.refreshAccounts')"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
@@ -441,73 +425,69 @@
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{
|
||||
accountsLoading
|
||||
? t('apiKeys.editApiKeyModal.refreshing')
|
||||
: t('apiKeys.editApiKeyModal.refreshAccounts')
|
||||
}}</span>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.claudeAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Claude 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectClaudeAccount')"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.geminiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Gemini 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectGeminiAccount')"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.openaiAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>OpenAI 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectOpenaiAccount')"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.bedrockAccount')
|
||||
}}</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>Bedrock 专属账号</label
|
||||
>
|
||||
<AccountSelector
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
:default-option-text="t('apiKeys.editApiKeyModal.useSharedPool')"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
:groups="[]"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.selectBedrockAccount')"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.accountBindingHint') }}
|
||||
修改绑定账号将影响此API Key的请求路由
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -523,15 +503,15 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="editEnableModelRestriction"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.enableModelRestriction') }}
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.restrictedModels')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>限制的模型列表</label
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
@@ -553,7 +533,7 @@
|
||||
v-if="form.restrictedModels.length === 0"
|
||||
class="text-sm text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.noRestrictedModels') }}
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -572,7 +552,7 @@
|
||||
v-if="availableQuickModels.length === 0"
|
||||
class="text-sm italic text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.allCommonModelsRestricted') }}
|
||||
所有常用模型已在限制列表中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -581,7 +561,7 @@
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="t('apiKeys.editApiKeyModal.addRestrictedModelPlaceholder')"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
/>
|
||||
@@ -595,7 +575,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.modelRestrictionHint') }}
|
||||
设置此API Key无法访问的模型,例如:claude-opus-4-20250514
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -614,17 +594,17 @@
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
for="editEnableClientRestriction"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.enableClientRestriction') }}
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.editApiKeyModal.allowedClients')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>允许的客户端</label
|
||||
>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.editApiKeyModal.clientRestrictionHint') }}
|
||||
勾选允许使用此API Key的客户端
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
@@ -655,7 +635,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ t('apiKeys.editApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -664,9 +644,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
loading ? t('apiKeys.editApiKeyModal.saving') : t('apiKeys.editApiKeyModal.save')
|
||||
}}
|
||||
{{ loading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -677,7 +655,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
@@ -697,9 +674,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
@@ -811,14 +785,14 @@ const updateApiKey = async () => {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmTitle'),
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmMessage'),
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmContinue'),
|
||||
t('apiKeys.editApiKeyModal.costLimitConfirmBack')
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续保存',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(t('apiKeys.editApiKeyModal.costLimitConfirmMessage'))
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
if (!confirmed) {
|
||||
return
|
||||
@@ -924,10 +898,10 @@ const updateApiKey = async () => {
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.editApiKeyModal.updateFailed'), 'error')
|
||||
showToast(result.message || '更新失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.editApiKeyModal.updateFailed'), 'error')
|
||||
showToast('更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -937,15 +911,23 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -979,13 +961,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -1001,9 +1001,9 @@ const refreshAccounts = async () => {
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast(t('apiKeys.editApiKeyModal.refreshAccountsSuccess'), 'success')
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.editApiKeyModal.refreshAccountsFailed'), 'error')
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -1017,7 +1017,7 @@ const loadUsers = async () => {
|
||||
availableUsers.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
// console.error('Failed to load users:', error)
|
||||
availableUsers.value = [
|
||||
{
|
||||
id: 'admin',
|
||||
@@ -1043,7 +1043,7 @@ onMounted(async () => {
|
||||
supportedClients.value = clients || []
|
||||
availableTags.value = tags || []
|
||||
} catch (error) {
|
||||
console.error('Error loading initial data:', error)
|
||||
// console.error('Error loading initial data:', error)
|
||||
// Fallback to empty arrays if loading fails
|
||||
supportedClients.value = []
|
||||
availableTags.value = []
|
||||
@@ -1051,10 +1051,29 @@ onMounted(async () => {
|
||||
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
@@ -1062,6 +1081,9 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
|
||||
form.name = props.apiKey.name
|
||||
|
||||
// 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户
|
||||
@@ -1071,7 +1093,7 @@ onMounted(async () => {
|
||||
// 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置
|
||||
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
|
||||
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
|
||||
console.log('Token limit migration detected, consider setting cost limit')
|
||||
// console.log('检测到历史Token限制,请考虑设置费用限制')
|
||||
}
|
||||
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
@@ -1087,7 +1109,10 @@ onMounted(async () => {
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
}
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
|
||||
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
|
||||
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
||||
|
||||
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
|
||||
@@ -18,11 +18,9 @@
|
||||
<i class="fas fa-clock text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ $t('apiKeys.expiryEditModal.title') }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改过期时间</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.expiryEditModal.subtitle', { name: apiKey.name || 'API Key' }) }}
|
||||
为 "{{ apiKey.name || 'API Key' }}" 设置新的过期时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,20 +39,14 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t('apiKeys.expiryEditModal.currentStatus') }}
|
||||
</p>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">当前状态</p>
|
||||
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
<!-- 未激活状态 -->
|
||||
<template v-if="apiKey.expirationMode === 'activation' && !apiKey.isActivated">
|
||||
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
||||
{{ $t('apiKeys.expiryEditModal.notActivated') }}
|
||||
未激活
|
||||
<span class="ml-2 text-xs font-normal text-gray-600">
|
||||
{{
|
||||
$t('apiKeys.expiryEditModal.activationDaysHint', {
|
||||
days: apiKey.activationDays || 30
|
||||
})
|
||||
}}
|
||||
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
||||
</span>
|
||||
</template>
|
||||
<!-- 已设置过期时间 -->
|
||||
@@ -71,7 +63,7 @@
|
||||
<!-- 永不过期 -->
|
||||
<template v-else>
|
||||
<i class="fas fa-infinity mr-1 text-gray-500" />
|
||||
{{ $t('apiKeys.expiryEditModal.neverExpire') }}
|
||||
永不过期
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -97,23 +89,19 @@
|
||||
@click="handleActivateNow"
|
||||
>
|
||||
<i class="fas fa-rocket mr-2" />
|
||||
{{
|
||||
$t('apiKeys.expiryEditModal.activateButton', { days: apiKey.activationDays || 30 })
|
||||
}}
|
||||
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{
|
||||
$t('apiKeys.expiryEditModal.activationInfo', { days: apiKey.activationDays || 30 })
|
||||
}}
|
||||
点击立即激活此 API Key,激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 快捷选项 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.expiryEditModal.selectNewDuration') }}
|
||||
</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>选择新的期限</label
|
||||
>
|
||||
<div class="mb-3 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="option in quickOptions"
|
||||
@@ -138,16 +126,16 @@
|
||||
@click="selectQuickOption('custom')"
|
||||
>
|
||||
<i class="fas fa-calendar-alt mr-1" />
|
||||
{{ $t('apiKeys.expiryEditModal.custom') }}
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义日期选择 -->
|
||||
<div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn">
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('apiKeys.expiryEditModal.selectDateAndTime') }}
|
||||
</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>选择日期和时间</label
|
||||
>
|
||||
<input
|
||||
v-model="localForm.customExpireDate"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
@@ -156,7 +144,7 @@
|
||||
@change="updateCustomExpiryPreview"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('apiKeys.expiryEditModal.selectFutureDateTime') }}
|
||||
选择一个未来的日期和时间作为过期时间
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +157,7 @@
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-medium text-blue-700 dark:text-blue-400">
|
||||
<i class="fas fa-arrow-right mr-1" />
|
||||
{{ $t('apiKeys.expiryEditModal.newExpiryTime') }}
|
||||
新的过期时间
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-blue-900 dark:text-blue-200">
|
||||
<template v-if="localForm.expiresAt">
|
||||
@@ -184,7 +172,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-infinity mr-1" />
|
||||
{{ $t('apiKeys.expiryEditModal.neverExpire') }}
|
||||
永不过期
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -202,7 +190,7 @@
|
||||
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.expiryEditModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-4 py-2.5 font-semibold"
|
||||
@@ -211,11 +199,7 @@
|
||||
>
|
||||
<div v-if="saving" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
saving
|
||||
? $t('apiKeys.expiryEditModal.saving')
|
||||
: $t('apiKeys.expiryEditModal.saveChanges')
|
||||
}}
|
||||
{{ saving ? '保存中...' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,9 +210,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -254,14 +235,14 @@ const localForm = reactive({
|
||||
|
||||
// 快捷选项
|
||||
const quickOptions = [
|
||||
{ value: '', label: t('apiKeys.expiryEditModal.neverExpireOption') },
|
||||
{ value: '1d', label: t('apiKeys.expiryEditModal.oneDay') },
|
||||
{ value: '7d', label: t('apiKeys.expiryEditModal.sevenDays') },
|
||||
{ value: '30d', label: t('apiKeys.expiryEditModal.thirtyDays') },
|
||||
{ value: '90d', label: t('apiKeys.expiryEditModal.ninetyDays') },
|
||||
{ value: '180d', label: t('apiKeys.expiryEditModal.oneHundredEightyDays') },
|
||||
{ value: '365d', label: t('apiKeys.expiryEditModal.threeSixtyFiveDays') },
|
||||
{ value: '730d', label: t('apiKeys.expiryEditModal.twoYears') }
|
||||
{ value: '', label: '永不过期' },
|
||||
{ value: '1d', label: '1 天' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
{ value: '90d', label: '90 天' },
|
||||
{ value: '180d', label: '180 天' },
|
||||
{ value: '365d', label: '1 年' },
|
||||
{ value: '730d', label: '2 年' }
|
||||
]
|
||||
|
||||
// 计算最小日期时间
|
||||
@@ -356,17 +337,13 @@ const updateCustomExpiryPreview = () => {
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const { locale } = useI18n()
|
||||
return date.toLocaleString(
|
||||
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否已过期
|
||||
@@ -386,22 +363,22 @@ const getExpiryStatus = (expiresAt) => {
|
||||
|
||||
if (diffMs < 0) {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.expired'),
|
||||
text: '已过期',
|
||||
class: 'text-red-600'
|
||||
}
|
||||
} else if (diffDays <= 7) {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.daysToExpire', { days: diffDays }),
|
||||
text: `${diffDays} 天后过期`,
|
||||
class: 'text-orange-600'
|
||||
}
|
||||
} else if (diffDays <= 30) {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.daysToExpire', { days: diffDays }),
|
||||
text: `${diffDays} 天后过期`,
|
||||
class: 'text-yellow-600'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
text: t('apiKeys.expiryEditModal.monthsToExpire', { months: Math.ceil(diffDays / 30) }),
|
||||
text: `${Math.ceil(diffDays / 30)} 个月后过期`,
|
||||
class: 'text-green-600'
|
||||
}
|
||||
}
|
||||
@@ -422,19 +399,15 @@ const handleActivateNow = async () => {
|
||||
let confirmed = true
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('apiKeys.expiryEditModal.activateConfirmTitle'),
|
||||
t('apiKeys.expiryEditModal.activateConfirmMessage', {
|
||||
days: props.apiKey.activationDays || 30
|
||||
}),
|
||||
t('apiKeys.expiryEditModal.confirmActivate'),
|
||||
t('apiKeys.expiryEditModal.confirmCancel')
|
||||
'激活 API Key',
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
|
||||
'确定激活',
|
||||
'取消'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(
|
||||
t('apiKeys.expiryEditModal.activateConfirmMessage', {
|
||||
days: props.apiKey.activationDays || 30
|
||||
})
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,13 @@
|
||||
<i class="fas fa-check text-lg text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ t('apiKeys.newApiKeyModal.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.newApiKeyModal.subtitle') }}
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">API Key 创建成功</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">请妥善保存您的 API Key</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
:title="t('apiKeys.newApiKeyModal.directCloseTooltip')"
|
||||
title="直接关闭(不推荐)"
|
||||
@click="handleDirectClose"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
@@ -40,11 +36,10 @@
|
||||
<i class="fas fa-exclamation-triangle text-sm text-white" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">
|
||||
{{ t('apiKeys.newApiKeyModal.warningTitle') }}
|
||||
</h5>
|
||||
<h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">重要提醒</h5>
|
||||
<p class="text-sm text-amber-800 dark:text-amber-300">
|
||||
{{ t('apiKeys.newApiKeyModal.warningMessage') }}
|
||||
这是您唯一能看到完整 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API
|
||||
Key。请立即复制并妥善保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,9 +48,9 @@
|
||||
<!-- API Key 信息 -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.newApiKeyModal.apiKeyName')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API Key 名称</label
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
@@ -64,22 +59,22 @@
|
||||
</div>
|
||||
|
||||
<div v-if="apiKey.description">
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.newApiKeyModal.remarks')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>备注</label
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{
|
||||
apiKey.description || t('apiKeys.newApiKeyModal.noDescription')
|
||||
apiKey.description || '无描述'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.newApiKeyModal.apiKey')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API Key</label
|
||||
>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex min-h-[60px] items-center break-all rounded-lg border border-gray-700 bg-gray-900 p-4 pr-14 font-mono text-sm text-white dark:border-gray-600 dark:bg-gray-900"
|
||||
@@ -89,11 +84,7 @@
|
||||
<div class="absolute right-3 top-3">
|
||||
<button
|
||||
class="btn-icon-sm bg-gray-700 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
:title="
|
||||
showFullKey
|
||||
? t('apiKeys.newApiKeyModal.hideApiKey')
|
||||
: t('apiKeys.newApiKeyModal.showFullApiKey')
|
||||
"
|
||||
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
|
||||
type="button"
|
||||
@click="toggleKeyVisibility"
|
||||
>
|
||||
@@ -102,7 +93,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiKeys.newApiKeyModal.visibilityHint') }}
|
||||
点击眼睛图标切换显示模式,使用下方按钮复制完整 API Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,13 +105,13 @@
|
||||
@click="copyApiKey"
|
||||
>
|
||||
<i class="fas fa-copy" />
|
||||
{{ t('apiKeys.newApiKeyModal.copyApiKey') }}
|
||||
复制 API Key
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('apiKeys.newApiKeyModal.alreadySaved') }}
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,11 +121,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
@@ -171,13 +159,13 @@ const getDisplayedApiKey = () => {
|
||||
const copyApiKey = async () => {
|
||||
const key = props.apiKey.apiKey || props.apiKey.key || ''
|
||||
if (!key) {
|
||||
showToast(t('apiKeys.newApiKeyModal.apiKeyNotFound'), 'error')
|
||||
showToast('API Key 不存在', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
showToast(t('apiKeys.newApiKeyModal.copySuccess'), 'success')
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
} catch (error) {
|
||||
// console.error('Failed to copy:', error)
|
||||
// 降级方案:创建一个临时文本区域
|
||||
@@ -187,9 +175,9 @@ const copyApiKey = async () => {
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast(t('apiKeys.newApiKeyModal.copySuccess'), 'success')
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
} catch (fallbackError) {
|
||||
showToast(t('apiKeys.newApiKeyModal.copyFailed'), 'error')
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
@@ -200,17 +188,19 @@ const copyApiKey = async () => {
|
||||
const handleClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.newApiKeyModal.closeReminderTitle'),
|
||||
t('apiKeys.newApiKeyModal.closeReminderMessage'),
|
||||
t('apiKeys.newApiKeyModal.confirmClose'),
|
||||
t('apiKeys.newApiKeyModal.cancel')
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'取消'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.newApiKeyModal.closeReminderMessage'))
|
||||
const confirmed = confirm(
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
@@ -221,17 +211,17 @@ const handleClose = async () => {
|
||||
const handleDirectClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
t('apiKeys.newApiKeyModal.directCloseTitle'),
|
||||
t('apiKeys.newApiKeyModal.directCloseMessage'),
|
||||
t('apiKeys.newApiKeyModal.stillClose'),
|
||||
t('apiKeys.newApiKeyModal.goBack')
|
||||
'确定要关闭吗?',
|
||||
'您还没有保存API Key,关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
|
||||
'仍然关闭',
|
||||
'返回复制'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(t('apiKeys.newApiKeyModal.directCloseFallback'))
|
||||
const confirmed = confirm('您还没有保存API Key,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
>
|
||||
<i class="fas fa-clock text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
{{ $t('apiKeys.renewApiKeyModal.title') }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
@@ -30,18 +28,13 @@
|
||||
<i class="fas fa-info text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-1 font-semibold text-gray-800">
|
||||
{{ $t('apiKeys.renewApiKeyModal.apiKeyInfo') }}
|
||||
</h4>
|
||||
<h4 class="mb-1 font-semibold text-gray-800">API Key 信息</h4>
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ apiKey.name }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
{{ $t('apiKeys.renewApiKeyModal.currentExpiry')
|
||||
}}{{
|
||||
apiKey.expiresAt
|
||||
? formatExpireDate(apiKey.expiresAt)
|
||||
: $t('apiKeys.renewApiKeyModal.neverExpires')
|
||||
当前过期时间:{{
|
||||
apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,21 +42,19 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">{{
|
||||
$t('apiKeys.renewApiKeyModal.renewDuration')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">续期时长</label>
|
||||
<select
|
||||
v-model="form.renewDuration"
|
||||
class="form-input w-full"
|
||||
@change="updateRenewExpireAt"
|
||||
>
|
||||
<option value="7d">{{ $t('apiKeys.renewApiKeyModal.extend7Days') }}</option>
|
||||
<option value="30d">{{ $t('apiKeys.renewApiKeyModal.extend30Days') }}</option>
|
||||
<option value="90d">{{ $t('apiKeys.renewApiKeyModal.extend90Days') }}</option>
|
||||
<option value="180d">{{ $t('apiKeys.renewApiKeyModal.extend180Days') }}</option>
|
||||
<option value="365d">{{ $t('apiKeys.renewApiKeyModal.extend365Days') }}</option>
|
||||
<option value="custom">{{ $t('apiKeys.renewApiKeyModal.customDate') }}</option>
|
||||
<option value="permanent">{{ $t('apiKeys.renewApiKeyModal.setPermanent') }}</option>
|
||||
<option value="7d">延长 7 天</option>
|
||||
<option value="30d">延长 30 天</option>
|
||||
<option value="90d">延长 90 天</option>
|
||||
<option value="180d">延长 180 天</option>
|
||||
<option value="365d">延长 365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
<option value="permanent">设为永不过期</option>
|
||||
</select>
|
||||
<div v-if="form.renewDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
@@ -75,8 +66,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.newExpiresAt" class="mt-2 text-xs text-gray-500">
|
||||
{{ $t('apiKeys.renewApiKeyModal.newExpiry')
|
||||
}}{{ formatExpireDate(form.newExpiresAt) }}
|
||||
新的过期时间:{{ formatExpireDate(form.newExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +77,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('apiKeys.renewApiKeyModal.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -97,11 +87,7 @@
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-clock mr-2" />
|
||||
{{
|
||||
loading
|
||||
? $t('apiKeys.renewApiKeyModal.renewing')
|
||||
: $t('apiKeys.renewApiKeyModal.confirmRenew')
|
||||
}}
|
||||
{{ loading ? '续期中...' : '确认续期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,12 +97,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
@@ -150,10 +133,7 @@ const minDateTime = computed(() => {
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
// 根据当前语言设置选择合适的locale
|
||||
const locale =
|
||||
t('common.locale') === 'en' ? 'en-US' : t('common.locale') === 'zh-TW' ? 'zh-TW' : 'zh-CN'
|
||||
return date.toLocaleString(locale, {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -229,14 +209,14 @@ const renewApiKey = async () => {
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(t('apiKeys.renewApiKeyModal.renewSuccess'), 'success')
|
||||
showToast('API Key 续期成功', 'success')
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || t('apiKeys.renewApiKeyModal.renewFailed'), 'error')
|
||||
showToast(result.message || '续期失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('apiKeys.renewApiKeyModal.renewFailed'), 'error')
|
||||
showToast('续期失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<i class="fas fa-chart-line text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ t('apiKeys.usageDetailModal.title') }} - {{ apiKey.name }}
|
||||
使用统计详情 - {{ apiKey.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<button class="p-1 text-gray-400 transition-colors hover:text-gray-600" @click="close">
|
||||
@@ -34,17 +34,14 @@
|
||||
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4 dark:border-blue-700 dark:from-blue-900/20 dark:to-blue-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.totalRequests')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">总请求数</span>
|
||||
<i class="fas fa-paper-plane text-blue-500" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(totalRequests) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.usageDetailModal.today') }}: {{ formatNumber(dailyRequests) }}
|
||||
{{ t('apiKeys.usageDetailModal.times') }}
|
||||
今日: {{ formatNumber(dailyRequests) }} 次
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,16 +50,14 @@
|
||||
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4 dark:border-green-700 dark:from-green-900/20 dark:to-green-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">总Token数</span>
|
||||
<i class="fas fa-coins text-green-500" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatTokenCount(totalTokens) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.usageDetailModal.today') }}: {{ formatTokenCount(dailyTokens) }}
|
||||
今日: {{ formatTokenCount(dailyTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,16 +66,14 @@
|
||||
class="rounded-lg border border-yellow-200 bg-gradient-to-br from-yellow-50 to-yellow-100 p-4 dark:border-yellow-700 dark:from-yellow-900/20 dark:to-yellow-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.totalCost')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">总费用</span>
|
||||
<i class="fas fa-dollar-sign text-yellow-600" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
${{ totalCost.toFixed(4) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiKeys.usageDetailModal.today') }}: ${{ dailyCost.toFixed(4) }}
|
||||
今日: ${{ dailyCost.toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,9 +82,7 @@
|
||||
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4 dark:border-purple-700 dark:from-purple-900/20 dark:to-purple-800/20"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiKeys.usageDetailModal.averageRate')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">平均速率</span>
|
||||
<i class="fas fa-tachometer-alt text-purple-500" />
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
@@ -113,15 +104,13 @@
|
||||
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-chart-pie mr-2 text-indigo-500" />
|
||||
{{ t('apiKeys.usageDetailModal.tokenDistribution') }}
|
||||
Token 使用分布
|
||||
</h4>
|
||||
<div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-arrow-down mr-2 text-green-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.inputTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">输入 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatTokenCount(inputTokens) }}
|
||||
@@ -130,9 +119,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-arrow-up mr-2 text-blue-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.outputTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">输出 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatTokenCount(outputTokens) }}
|
||||
@@ -141,9 +128,7 @@
|
||||
<div v-if="cacheCreateTokens > 0" class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-save mr-2 text-purple-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.cacheCreateTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">缓存创建 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-purple-600">
|
||||
{{ formatTokenCount(cacheCreateTokens) }}
|
||||
@@ -152,9 +137,7 @@
|
||||
<div v-if="cacheReadTokens > 0" class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-download mr-2 text-purple-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.cacheReadTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">缓存读取 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-purple-600">
|
||||
{{ formatTokenCount(cacheReadTokens) }}
|
||||
@@ -169,14 +152,12 @@
|
||||
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-2 text-red-500" />
|
||||
{{ t('apiKeys.usageDetailModal.limitSettings') }}
|
||||
限制设置
|
||||
</h4>
|
||||
<div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50">
|
||||
<div v-if="apiKey.dailyCostLimit > 0" class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.dailyCostLimit')
|
||||
}}</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">每日费用限制</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
${{ apiKey.dailyCostLimit.toFixed(2) }}
|
||||
</span>
|
||||
@@ -195,11 +176,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('apiKeys.usageDetailModal.usedPercentage', {
|
||||
percentage: dailyCostPercentage.toFixed(1)
|
||||
})
|
||||
}}
|
||||
已使用 {{ dailyCostPercentage.toFixed(1) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,9 +184,7 @@
|
||||
v-if="apiKey.concurrencyLimit > 0"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-gray-600 dark:text-gray-400">{{
|
||||
t('apiKeys.usageDetailModal.concurrencyLimit')
|
||||
}}</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">并发限制</span>
|
||||
<span class="font-semibold text-purple-600">
|
||||
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
|
||||
</span>
|
||||
@@ -218,14 +193,14 @@
|
||||
<div v-if="apiKey.rateLimitWindow > 0" class="space-y-2">
|
||||
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-clock mr-1 text-blue-500" />
|
||||
{{ t('apiKeys.usageDetailModal.timeWindowLimit') }}
|
||||
时间窗口限制
|
||||
</h5>
|
||||
<WindowCountdown
|
||||
:cost-limit="apiKey.rateLimitCost"
|
||||
:current-cost="apiKey.currentWindowCost"
|
||||
:current-requests="apiKey.currentWindowRequests"
|
||||
:current-tokens="apiKey.currentWindowTokens"
|
||||
:label="t('apiKeys.usageDetailModal.windowStatus')"
|
||||
label="窗口状态"
|
||||
:rate-limit-window="apiKey.rateLimitWindow"
|
||||
:request-limit="apiKey.rateLimitRequests"
|
||||
:show-progress="true"
|
||||
@@ -243,7 +218,7 @@
|
||||
<!-- 底部按钮 -->
|
||||
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
||||
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
||||
{{ t('apiKeys.usageDetailModal.close') }}
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,11 +228,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WindowCountdown from './WindowCountdown.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -302,9 +274,7 @@ const dailyCostPercentage = computed(() => {
|
||||
// 方法
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
// 根据当前语言环境自动选择合适的地区设置
|
||||
const currentLocale = t('common.locale')
|
||||
return num.toLocaleString(currentLocale)
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化Token数量(使用K/M单位)
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500">{{ displayLabel }}</span>
|
||||
<span class="text-gray-500">{{ label }}</span>
|
||||
<span v-if="windowState === 'active'" class="font-medium text-gray-700">
|
||||
<i class="fas fa-clock mr-1 text-blue-500" />
|
||||
{{ formatTime(remainingSeconds) }}
|
||||
</span>
|
||||
<span v-else-if="windowState === 'expired'" class="font-medium text-orange-600">
|
||||
<i class="fas fa-sync-alt mr-1" />
|
||||
{{ t('apiKeys.windowCountdown.expired') }}
|
||||
窗口已过期
|
||||
</span>
|
||||
<span v-else-if="windowState === 'notStarted'" class="font-medium text-gray-500">
|
||||
<i class="fas fa-pause-circle mr-1" />
|
||||
{{ t('apiKeys.windowCountdown.notStarted') }}
|
||||
</span>
|
||||
<span v-else class="font-medium text-gray-400">
|
||||
{{ rateLimitWindow }} {{ t('apiKeys.windowCountdown.minutes') }}
|
||||
窗口未激活
|
||||
</span>
|
||||
<span v-else class="font-medium text-gray-400"> {{ rateLimitWindow }} 分钟 </span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条(仅在有限制时显示) -->
|
||||
<div v-if="showProgress" class="space-y-0.5">
|
||||
<div v-if="hasRequestLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">{{ t('apiKeys.windowCountdown.requests') }}</span>
|
||||
<span class="text-gray-400">请求</span>
|
||||
<span class="text-gray-600"> {{ currentRequests || 0 }}/{{ requestLimit }} </span>
|
||||
</div>
|
||||
<div class="h-1 w-full rounded-full bg-gray-200">
|
||||
@@ -38,7 +36,7 @@
|
||||
<!-- Token限制(向后兼容) -->
|
||||
<div v-if="hasTokenLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">{{ t('apiKeys.windowCountdown.tokens') }}</span>
|
||||
<span class="text-gray-400">Token</span>
|
||||
<span class="text-gray-600">
|
||||
{{ formatTokenCount(currentTokens || 0) }}/{{ formatTokenCount(tokenLimit) }}
|
||||
</span>
|
||||
@@ -55,7 +53,7 @@
|
||||
<!-- 费用限制(新功能) -->
|
||||
<div v-if="hasCostLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">{{ t('apiKeys.windowCountdown.cost') }}</span>
|
||||
<span class="text-gray-400">费用</span>
|
||||
<span class="text-gray-600">
|
||||
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
|
||||
</span>
|
||||
@@ -73,29 +71,22 @@
|
||||
<!-- 额外提示信息 -->
|
||||
<div v-if="windowState === 'active' && showTooltip" class="text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<span v-if="remainingSeconds < 60">{{ t('apiKeys.windowCountdown.aboutToReset') }}</span>
|
||||
<span v-if="remainingSeconds < 60">即将重置</span>
|
||||
<span v-else-if="remainingSeconds < 300"
|
||||
>{{ Math.ceil(remainingSeconds / 60) }}
|
||||
{{ t('apiKeys.windowCountdown.minutesUntilReset') }}</span
|
||||
>
|
||||
<span v-else
|
||||
>{{ formatDetailedTime(remainingSeconds)
|
||||
}}{{ t('apiKeys.windowCountdown.untilReset') }}</span
|
||||
>{{ Math.ceil(remainingSeconds / 60) }} 分钟后重置</span
|
||||
>
|
||||
<span v-else>{{ formatDetailedTime(remainingSeconds) }}后重置</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '窗口限制'
|
||||
},
|
||||
rateLimitWindow: {
|
||||
type: Number,
|
||||
@@ -152,10 +143,6 @@ const remainingSeconds = ref(props.windowRemainingSeconds)
|
||||
let intervalId = null
|
||||
|
||||
// 计算属性
|
||||
const displayLabel = computed(() => {
|
||||
return props.label || t('apiKeys.windowCountdown.windowLimit')
|
||||
})
|
||||
|
||||
const windowState = computed(() => {
|
||||
if (props.windowStartTime === null) {
|
||||
return 'notStarted' // 窗口未开始
|
||||
@@ -195,9 +182,9 @@ const formatDetailedTime = (seconds) => {
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}${t('apiKeys.windowCountdown.hours')}${minutes}${t('apiKeys.windowCountdown.minutes')}`
|
||||
return `${hours}小时${minutes}分钟`
|
||||
} else {
|
||||
return `${minutes}${t('apiKeys.windowCountdown.minutes')}`
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.usageRatio') }}
|
||||
使用占比
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span
|
||||
>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}{{ t('apiStats.requests') }}</span
|
||||
>
|
||||
<span>{{ formatNumber(getStatUsage(stat)?.requests || 0) }}次</span>
|
||||
<span>{{ getStatUsage(stat)?.formattedCost || '$0.00' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,10 +41,7 @@
|
||||
<!-- 其他Keys汇总 -->
|
||||
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span
|
||||
>{{ t('apiStats.otherKeys') }} {{ otherKeysCount }} {{ t('apiStats.individual')
|
||||
}}{{ t('apiStats.keys') }}</span
|
||||
>
|
||||
<span>其他 {{ otherKeysCount }} 个Keys</span>
|
||||
<span>{{ otherPercentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +54,7 @@
|
||||
>
|
||||
<div class="text-center">
|
||||
<i class="fas fa-chart-pie mb-2 text-2xl" />
|
||||
<p>{{ t('apiStats.usageRatioOnlyInMultiMode') }}</p>
|
||||
<p>使用占比仅在多Key查询时显示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +63,7 @@
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-chart-pie mr-2" />
|
||||
{{ t('apiStats.noData') }}
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -76,11 +71,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
<div class="wide-card-title mb-6">
|
||||
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-200">
|
||||
<i class="fas fa-chart-line mr-3" />
|
||||
{{ t('apiStats.usageStatsQuery') }}
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.apiKeyDescription') }}
|
||||
</p>
|
||||
<p class="text-base text-gray-600 dark:text-gray-400">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
@@ -18,7 +16,7 @@
|
||||
<!-- API Key 标签 -->
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-key mr-2" />
|
||||
{{ multiKeyMode ? t('apiStats.enterApiKeys') : t('apiStats.enterApiKey') }}
|
||||
{{ multiKeyMode ? '输入您的 API Keys(每行一个或用逗号分隔)' : '输入您的 API Key' }}
|
||||
</label>
|
||||
|
||||
<!-- 模式切换和查询按钮组 -->
|
||||
@@ -30,20 +28,20 @@
|
||||
<button
|
||||
class="mode-switch-btn"
|
||||
:class="{ active: !multiKeyMode }"
|
||||
:title="t('apiStats.singleModeTitle')"
|
||||
title="单一模式"
|
||||
@click="multiKeyMode = false"
|
||||
>
|
||||
<i class="fas fa-key" />
|
||||
<span class="ml-2 hidden sm:inline">{{ t('apiStats.singleMode') }}</span>
|
||||
<span class="ml-2 hidden sm:inline">单一</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-switch-btn"
|
||||
:class="{ active: multiKeyMode }"
|
||||
:title="t('apiStats.aggregateModeTitle')"
|
||||
title="聚合模式"
|
||||
@click="multiKeyMode = true"
|
||||
>
|
||||
<i class="fas fa-layer-group" />
|
||||
<span class="ml-2 hidden sm:inline">{{ t('apiStats.aggregateMode') }}</span>
|
||||
<span class="ml-2 hidden sm:inline">聚合</span>
|
||||
<span
|
||||
v-if="multiKeyMode && parsedApiKeys.length > 0"
|
||||
class="ml-1 rounded-full bg-white/20 px-1.5 py-0.5 text-xs font-semibold"
|
||||
@@ -64,7 +62,7 @@
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full"
|
||||
:disabled="loading"
|
||||
:placeholder="t('apiStats.apiKeyPlaceholder')"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
type="password"
|
||||
@keyup.enter="queryStats"
|
||||
/>
|
||||
@@ -75,14 +73,14 @@
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full resize-y"
|
||||
:disabled="loading"
|
||||
:placeholder="t('apiStats.apiKeysPlaceholder')"
|
||||
placeholder="请输入您的 API Keys,支持以下格式: cr_xxx cr_yyy 或 cr_xxx, cr_yyy"
|
||||
rows="4"
|
||||
@keyup.ctrl.enter="queryStats"
|
||||
/>
|
||||
<button
|
||||
v-if="apiKey && !loading"
|
||||
class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
:title="t('apiStats.clearInput')"
|
||||
title="清空输入"
|
||||
@click="clearInput"
|
||||
>
|
||||
<i class="fas fa-times-circle" />
|
||||
@@ -99,7 +97,7 @@
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner" />
|
||||
<i v-else class="fas fa-search" />
|
||||
{{ loading ? t('common.loading') : t('apiStats.queryButton') }}
|
||||
{{ loading ? '查询中...' : '查询统计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +105,11 @@
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2" />
|
||||
{{ multiKeyMode ? t('apiStats.securityNoticeMulti') : t('apiStats.securityNoticeSingle') }}
|
||||
{{
|
||||
multiKeyMode
|
||||
? '您的 API Keys 仅用于查询统计数据,不会被存储。聚合模式下部分个体化信息将不显示。'
|
||||
: '您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- 多 Key 模式额外提示 -->
|
||||
@@ -116,7 +118,7 @@
|
||||
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
>
|
||||
<i class="fas fa-lightbulb mr-2" />
|
||||
<span>{{ t('apiStats.multiKeyTip') }}</span>
|
||||
<span>提示:最多支持同时查询 30 个 API Keys。使用 Ctrl+Enter 快速查询。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,11 +127,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||
const { queryStats, clearInput } = apiStatsStore
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
|
||||
{{ multiKeyMode ? t('apiStats.limitConfigAggregate') : t('apiStats.limitConfig') }}
|
||||
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
|
||||
</h3>
|
||||
|
||||
<!-- 多 Key 模式下的聚合统计信息 -->
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-layer-group mr-2 text-blue-500" />
|
||||
{{ t('apiStats.apiKeysOverview') }}
|
||||
API Keys 概况
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-800 dark:text-blue-200"
|
||||
@@ -31,17 +31,13 @@
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ aggregatedStats.totalKeys }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.totalKeys') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">总计 Keys</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-bold text-green-600">
|
||||
{{ aggregatedStats.activeKeys }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.activeKeys') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">激活 Keys</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,15 +48,13 @@
|
||||
>
|
||||
<div class="mb-3 flex items-center">
|
||||
<i class="fas fa-chart-pie mr-2 text-purple-500" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
t('apiStats.aggregateStatsSummary')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">聚合统计摘要</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-database mr-1 text-gray-400" />
|
||||
{{ t('apiStats.totalRequests') }}
|
||||
总请求数
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
||||
@@ -69,7 +63,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-coins mr-1 text-yellow-500" />
|
||||
{{ t('apiStats.totalTokens') }}
|
||||
总 Tokens
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
||||
@@ -78,7 +72,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-dollar-sign mr-1 text-green-500" />
|
||||
{{ t('apiStats.totalCost') }}
|
||||
总费用
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ aggregatedStats.usage.formattedCost }}
|
||||
@@ -94,7 +88,7 @@
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-2 text-red-600 dark:text-red-400" />
|
||||
<span class="text-red-700 dark:text-red-300">
|
||||
{{ t('apiStats.invalidKeysCount', { count: invalidKeys.length }) }}
|
||||
{{ invalidKeys.length }} 个无效的 API Key
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +97,7 @@
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('apiStats.aggregateStatsNote') }}
|
||||
每个 API Key 有独立的限制设置,聚合模式下不显示单个限制配置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,9 +106,9 @@
|
||||
<!-- 每日费用限制 -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.dailyCostLimit')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>每日费用限制</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
|
||||
<span v-if="statsData.limits.dailyCostLimit > 0">
|
||||
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{
|
||||
@@ -155,7 +149,7 @@
|
||||
:current-cost="statsData.limits.currentWindowCost"
|
||||
:current-requests="statsData.limits.currentWindowRequests"
|
||||
:current-tokens="statsData.limits.currentWindowTokens"
|
||||
:label="t('apiStats.timeWindowLimit')"
|
||||
label="时间窗口限制"
|
||||
:rate-limit-window="statsData.limits.rateLimitWindow"
|
||||
:request-limit="statsData.limits.rateLimitRequests"
|
||||
:show-progress="true"
|
||||
@@ -169,21 +163,19 @@
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<span v-if="statsData.limits.rateLimitCost > 0">
|
||||
{{ t('apiStats.orRelationshipRequests') }}
|
||||
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
|
||||
</span>
|
||||
<span v-else-if="statsData.limits.tokenLimit > 0">
|
||||
{{ t('apiStats.orRelationshipTokens') }}
|
||||
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
|
||||
</span>
|
||||
<span v-else>{{ t('apiStats.onlyRequestsLimit') }}</span>
|
||||
<span v-else> 仅限制请求次数 </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他限制信息 -->
|
||||
<div class="space-y-2 border-t border-gray-100 pt-2 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.concurrencyLimit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">并发限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span v-if="statsData.limits.concurrencyLimit > 0">
|
||||
{{ statsData.limits.concurrencyLimit }}
|
||||
@@ -194,9 +186,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.modelLimit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">模型限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span
|
||||
v-if="
|
||||
@@ -206,22 +196,16 @@
|
||||
class="text-orange-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
||||
{{
|
||||
t('apiStats.restrictedModelsCount', {
|
||||
count: statsData.restrictions.restrictedModels.length
|
||||
})
|
||||
}}
|
||||
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.allowAllModels') }}
|
||||
允许所有模型
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.clientLimit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span
|
||||
v-if="
|
||||
@@ -231,15 +215,11 @@
|
||||
class="text-orange-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
||||
{{
|
||||
t('apiStats.restrictedClientsCount', {
|
||||
count: statsData.restrictions.allowedClients.length
|
||||
})
|
||||
}}
|
||||
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.allowAllClients') }}
|
||||
允许所有客户端
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -261,7 +241,7 @@
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
>
|
||||
<i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.detailedLimitInfo') }}
|
||||
详细限制信息
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2">
|
||||
@@ -277,7 +257,7 @@
|
||||
class="mb-2 flex items-center text-sm font-bold text-amber-800 dark:text-amber-300 md:mb-3 md:text-base"
|
||||
>
|
||||
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.restrictedModelsList') }}
|
||||
受限模型列表
|
||||
</h4>
|
||||
<div class="space-y-1 md:space-y-2">
|
||||
<div
|
||||
@@ -291,7 +271,7 @@
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('apiStats.restrictedModelsNote') }}
|
||||
此 API Key 不能访问以上列出的模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -307,7 +287,7 @@
|
||||
class="mb-2 flex items-center text-sm font-bold text-blue-800 dark:text-blue-300 md:mb-3 md:text-base"
|
||||
>
|
||||
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.allowedClientsList') }}
|
||||
允许的客户端
|
||||
</h4>
|
||||
<div class="space-y-1 md:space-y-2">
|
||||
<div
|
||||
@@ -321,7 +301,7 @@
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400 md:mt-3">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
{{ t('apiStats.allowedClientsNote') }}
|
||||
此 API Key 只能被以上列出的客户端使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,12 +311,9 @@
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.modelUsageStats') }}
|
||||
模型使用统计
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
@@ -19,9 +19,7 @@
|
||||
<i
|
||||
class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl"
|
||||
/>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
{{ t('apiStats.loadingModelStats') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 md:text-base">加载模型统计数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计数据 -->
|
||||
@@ -33,42 +31,38 @@
|
||||
{{ model.model }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ model.requests }}{{ t('apiStats.requestCount') }}
|
||||
{{ model.requests }} 次请求
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-3 flex-shrink-0 text-right">
|
||||
<div class="text-base font-bold text-green-600 md:text-lg">
|
||||
{{ model.formatted?.total || '$0.000000' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ t('apiStats.totalCost') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm">
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ t('apiStats.inputTokens') }}</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">输入 Token</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.inputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ t('apiStats.outputTokens') }}</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">输出 Token</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.outputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('apiStats.cacheCreateTokens') }}
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">缓存创建</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.cacheCreateTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ t('apiStats.cacheReadTokens') }}</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">缓存读取</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.cacheReadTokens) }}
|
||||
</div>
|
||||
@@ -81,11 +75,7 @@
|
||||
<div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 md:py-8">
|
||||
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
|
||||
<p class="text-sm md:text-base">
|
||||
{{
|
||||
t('apiStats.noModelData', {
|
||||
period: statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth')
|
||||
})
|
||||
}}
|
||||
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,11 +83,8 @@
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -11,57 +11,45 @@
|
||||
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500'
|
||||
"
|
||||
/>
|
||||
{{ multiKeyMode ? t('apiStats.batchQuerySummary') : t('apiStats.apiKeyInfo') }}
|
||||
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}
|
||||
</h3>
|
||||
|
||||
<!-- 多 Key 模式下的概要信息 -->
|
||||
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.queryKeysCount')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys 数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ aggregatedStats.totalKeys }} {{ t('apiStats.individual') }}
|
||||
{{ aggregatedStats.totalKeys }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.activeKeysCount')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys 数</span>
|
||||
<span class="text-sm font-medium text-green-600 md:text-base">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
{{ aggregatedStats.activeKeys }} {{ t('apiStats.individual') }}
|
||||
{{ aggregatedStats.activeKeys }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.invalidKeysCount')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys 数</span>
|
||||
<span class="text-sm font-medium text-red-600 md:text-base">
|
||||
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
|
||||
{{ invalidKeys.length }} {{ t('apiStats.individual') }}
|
||||
{{ invalidKeys.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.totalRequests')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总 Token 数</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.totalCost')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
|
||||
<span class="text-sm font-medium text-indigo-600 md:text-base">
|
||||
{{ aggregatedStats.usage.formattedCost }}
|
||||
</span>
|
||||
@@ -72,9 +60,7 @@
|
||||
v-if="individualStats.length > 1"
|
||||
class="border-t border-gray-200 pt-2 dark:border-gray-700"
|
||||
>
|
||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('apiStats.keyContribution') }}
|
||||
</div>
|
||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">各 Key 贡献占比</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="stat in topContributors"
|
||||
@@ -93,18 +79,14 @@
|
||||
<!-- 单 Key 模式下的详细信息 -->
|
||||
<div v-else class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.name')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
|
||||
<span
|
||||
class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"
|
||||
>{{ statsData.name }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.status')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">状态</span>
|
||||
<span
|
||||
class="text-sm font-medium md:text-base"
|
||||
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
|
||||
@@ -113,39 +95,35 @@
|
||||
class="mr-1 text-xs md:text-sm"
|
||||
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
|
||||
/>
|
||||
{{ statsData.isActive ? t('apiStats.active') : t('apiStats.inactive') }}
|
||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.permissions')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">权限</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatPermissions(statsData.permissions)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.createdAt')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">创建时间</span>
|
||||
<span
|
||||
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
|
||||
>{{ formatDate(statsData.createdAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base">{{
|
||||
t('apiStats.expiresAt')
|
||||
}}</span>
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>过期时间</span
|
||||
>
|
||||
<!-- 未激活状态 -->
|
||||
<div
|
||||
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
|
||||
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
|
||||
>
|
||||
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.notActivated') }}
|
||||
未激活
|
||||
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>({{ t('apiStats.firstUseDays', { days: statsData.activationDays || 30 }) }})</span
|
||||
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
||||
>
|
||||
</div>
|
||||
<!-- 已设置过期时间 -->
|
||||
@@ -155,7 +133,7 @@
|
||||
class="text-sm font-medium text-red-600 md:text-base"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.expired') }}
|
||||
已过期
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
|
||||
@@ -174,7 +152,7 @@
|
||||
<!-- 永不过期 -->
|
||||
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
|
||||
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
||||
{{ t('apiStats.neverExpires') }}
|
||||
永不过期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,10 +165,10 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.usageStatsOverview') }}
|
||||
使用统计概览
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3 md:gap-4">
|
||||
@@ -199,9 +177,7 @@
|
||||
{{ formatNumber(currentPeriodData.requests) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{
|
||||
statsPeriod === 'daily' ? t('apiStats.todayRequests') : t('apiStats.monthlyRequests')
|
||||
}}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
@@ -209,7 +185,7 @@
|
||||
{{ formatNumber(currentPeriodData.allTokens) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? t('apiStats.todayTokens') : t('apiStats.monthlyTokens') }}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
@@ -217,7 +193,7 @@
|
||||
{{ currentPeriodData.formattedCost || '$0.000000' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? t('apiStats.todayCost') : t('apiStats.monthlyCost') }}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
@@ -225,11 +201,7 @@
|
||||
{{ formatNumber(currentPeriodData.inputTokens) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{
|
||||
statsPeriod === 'daily'
|
||||
? t('apiStats.todayInputTokens')
|
||||
: t('apiStats.monthlyInputTokens')
|
||||
}}
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,12 +213,9 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const {
|
||||
statsData,
|
||||
@@ -276,21 +245,13 @@ const calculateContribution = (stat) => {
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return t('apiStats.none')
|
||||
if (!dateString) return '无'
|
||||
|
||||
try {
|
||||
const date = dayjs(dateString)
|
||||
// 根据当前语言环境选择日期格式
|
||||
const locale = t('common.locale', 'zh-CN')
|
||||
if (locale === 'en') {
|
||||
return date.format('YYYY-MM-DD HH:mm')
|
||||
} else if (locale === 'zh-TW') {
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
} else {
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
}
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
} catch (error) {
|
||||
return t('apiStats.formatError')
|
||||
return '格式错误'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,10 +306,10 @@ const formatPermissions = (permissions) => {
|
||||
const permissionMap = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
all: t('apiStats.allModels')
|
||||
all: '全部模型'
|
||||
}
|
||||
|
||||
return permissionMap[permissions] || permissions || t('apiStats.unknown')
|
||||
return permissionMap[permissions] || permissions || '未知'
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
|
||||
{{ t('apiStats.tokenDistribution') }}
|
||||
Token 使用分布
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</span
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
<div class="space-y-2 md:space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-arrow-right mr-1 text-xs text-green-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.inputToken') }}
|
||||
输入 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.inputTokens)
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-arrow-left mr-1 text-xs text-blue-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.outputToken') }}
|
||||
输出 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.outputTokens)
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.cacheCreateToken') }}
|
||||
缓存创建 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.cacheCreateTokens)
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
|
||||
<i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" />
|
||||
{{ t('apiStats.cacheReadToken') }}
|
||||
缓存读取 Token
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
||||
formatNumber(currentPeriodData.cacheReadTokens)
|
||||
@@ -51,9 +51,9 @@
|
||||
</div>
|
||||
<div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700 md:mt-4 md:pt-4">
|
||||
<div class="flex items-center justify-between font-bold text-gray-900 dark:text-gray-100">
|
||||
<span class="text-sm md:text-base">{{
|
||||
statsPeriod === 'daily' ? t('apiStats.todayTotal') : t('apiStats.monthlyTotal')
|
||||
}}</span>
|
||||
<span class="text-sm md:text-base"
|
||||
>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
|
||||
>
|
||||
<span class="text-lg md:text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,11 +62,8 @@
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="$t('common.accountSelector.searchPlaceholder')"
|
||||
placeholder="搜索账号名称..."
|
||||
style="padding-left: 40px; padding-right: 36px"
|
||||
type="text"
|
||||
@input="handleSearch"
|
||||
@@ -68,9 +68,7 @@
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
|
||||
@click="selectAccount(null)"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{
|
||||
props.defaultOptionText || $t('common.accountSelector.useSharedPool')
|
||||
}}</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 分组选项 -->
|
||||
@@ -78,7 +76,7 @@
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ $t('common.accountSelector.schedulingGroups') }}
|
||||
调度分组
|
||||
</div>
|
||||
<div
|
||||
v-for="group in filteredGroups"
|
||||
@@ -90,8 +88,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>{{ group.memberCount || 0
|
||||
}}{{ $t('common.accountSelector.membersUnit') }}</span
|
||||
>{{ group.memberCount || 0 }} 个成员</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,8 +101,10 @@
|
||||
>
|
||||
{{
|
||||
platform === 'claude'
|
||||
? $t('common.accountSelector.claudeOAuthAccounts')
|
||||
: $t('common.accountSelector.oauthAccounts')
|
||||
? 'Claude OAuth 专属账号'
|
||||
: platform === 'openai'
|
||||
? 'OpenAI 专属账号'
|
||||
: 'OAuth 专属账号'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
@@ -143,7 +142,7 @@
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ $t('common.accountSelector.claudeConsoleAccounts') }}
|
||||
Claude Console 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredConsoleAccounts"
|
||||
@@ -177,13 +176,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 账号(仅 OpenAI) -->
|
||||
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
OpenAI-Responses 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOpenAIResponsesAccounts"
|
||||
:key="account.id"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
|
||||
}"
|
||||
@click="selectAccount(`responses:${account.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
account.isActive === 'true' || account.isActive === true
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: account.status === 'rate_limited'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
"
|
||||
>
|
||||
{{ getAccountStatusText(account) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ formatDate(account.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="searchQuery && !hasResults"
|
||||
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-search mb-2 text-2xl" />
|
||||
<p class="text-sm">{{ $t('common.accountSelector.noResultsFound') }}</p>
|
||||
<p class="text-sm">没有找到匹配的账号</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,9 +232,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t: $t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -206,7 +241,7 @@ const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['claude', 'gemini'].includes(value)
|
||||
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
|
||||
},
|
||||
accounts: {
|
||||
type: Array,
|
||||
@@ -222,11 +257,11 @@ const props = defineProps({
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null
|
||||
default: '请选择账号'
|
||||
},
|
||||
defaultOptionText: {
|
||||
type: String,
|
||||
default: null
|
||||
default: '使用共享账号池'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -243,16 +278,13 @@ const lastDirection = ref('') // 记住上次的显示方向
|
||||
// 获取选中的标签
|
||||
const selectedLabel = computed(() => {
|
||||
// 如果没有选中值,显示默认选项文本
|
||||
if (!props.modelValue)
|
||||
return props.defaultOptionText || $t('common.accountSelector.useSharedPool')
|
||||
if (!props.modelValue) return props.defaultOptionText
|
||||
|
||||
// 分组
|
||||
if (props.modelValue.startsWith('group:')) {
|
||||
const groupId = props.modelValue.substring(6)
|
||||
const group = props.groups.find((g) => g.id === groupId)
|
||||
return group
|
||||
? `${group.name} (${group.memberCount || 0}${$t('common.accountSelector.membersUnit')})`
|
||||
: ''
|
||||
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
|
||||
}
|
||||
|
||||
// Console 账号
|
||||
@@ -264,6 +296,15 @@ const selectedLabel = computed(() => {
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账号
|
||||
if (props.modelValue.startsWith('responses:')) {
|
||||
const accountId = props.modelValue.substring(10)
|
||||
const account = props.accounts.find(
|
||||
(a) => a.id === accountId && a.platform === 'openai-responses'
|
||||
)
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
}
|
||||
|
||||
// OAuth 账号
|
||||
const account = props.accounts.find((a) => a.id === props.modelValue)
|
||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||
@@ -271,26 +312,36 @@ const selectedLabel = computed(() => {
|
||||
|
||||
// 获取账户状态文本
|
||||
const getAccountStatusText = (account) => {
|
||||
if (!account) return $t('common.accountSelector.accountStatus.unknown')
|
||||
if (!account) return '未知'
|
||||
|
||||
// 处理 OpenAI-Responses 账号(isActive 可能是字符串)
|
||||
const isActive = account.isActive === 'true' || account.isActive === true
|
||||
|
||||
// 优先使用 isActive 判断
|
||||
if (account.isActive === false) {
|
||||
if (!isActive) {
|
||||
// 根据 status 提供更详细的状态信息
|
||||
switch (account.status) {
|
||||
case 'unauthorized':
|
||||
return $t('common.accountSelector.accountStatus.unauthorized')
|
||||
return '未授权'
|
||||
case 'error':
|
||||
return $t('common.accountSelector.accountStatus.tokenError')
|
||||
return 'Token错误'
|
||||
case 'created':
|
||||
return $t('common.accountSelector.accountStatus.pending')
|
||||
return '待验证'
|
||||
case 'rate_limited':
|
||||
return $t('common.accountSelector.accountStatus.rateLimited')
|
||||
return '限流中'
|
||||
case 'quota_exceeded':
|
||||
return '额度超限'
|
||||
default:
|
||||
return $t('common.accountSelector.accountStatus.error')
|
||||
return '异常'
|
||||
}
|
||||
}
|
||||
|
||||
return $t('common.accountSelector.accountStatus.active')
|
||||
// 对于激活的账号,如果是限流状态也要显示
|
||||
if (account.status === 'rate_limited') {
|
||||
return '限流中'
|
||||
}
|
||||
|
||||
return '正常'
|
||||
}
|
||||
|
||||
// 按创建时间倒序排序账号
|
||||
@@ -302,18 +353,42 @@ const sortedAccounts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤的分组
|
||||
// 过滤的分组(根据平台类型过滤)
|
||||
const filteredGroups = computed(() => {
|
||||
if (!searchQuery.value) return props.groups
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
// 只显示与当前平台匹配的分组
|
||||
let groups = props.groups.filter((group) => {
|
||||
// 如果分组有platform属性,则必须匹配当前平台
|
||||
// 如果没有platform属性,则认为是旧数据,根据平台判断
|
||||
if (group.platform) {
|
||||
return group.platform === props.platform
|
||||
}
|
||||
// 向后兼容:如果没有platform字段,通过其他方式判断
|
||||
return true
|
||||
})
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
groups = groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 过滤的 OAuth 账号
|
||||
const filteredOAuthAccounts = computed(() => {
|
||||
let accounts = sortedAccounts.value.filter((a) =>
|
||||
props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console'
|
||||
)
|
||||
let accounts = []
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
|
||||
} else if (props.platform === 'openai') {
|
||||
// 对于 OpenAI,只显示 openai 类型的账号
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
||||
} else {
|
||||
// 其他平台显示所有非特殊类型的账号
|
||||
accounts = sortedAccounts.value.filter(
|
||||
(a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform)
|
||||
)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
@@ -337,12 +412,27 @@ const filteredConsoleAccounts = computed(() => {
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 过滤的 OpenAI-Responses 账号
|
||||
const filteredOpenAIResponsesAccounts = computed(() => {
|
||||
if (props.platform !== 'openai') return []
|
||||
|
||||
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 是否有搜索结果
|
||||
const hasResults = computed(() => {
|
||||
return (
|
||||
filteredGroups.value.length > 0 ||
|
||||
filteredOAuthAccounts.value.length > 0 ||
|
||||
filteredConsoleAccounts.value.length > 0
|
||||
filteredConsoleAccounts.value.length > 0 ||
|
||||
filteredOpenAIResponsesAccounts.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
@@ -354,12 +444,12 @@ const formatDate = (dateString) => {
|
||||
const diffInHours = (now - date) / (1000 * 60 * 60)
|
||||
|
||||
if (diffInHours < 24) {
|
||||
return $t('common.accountSelector.dateFormat.today')
|
||||
return '今天创建'
|
||||
} else if (diffInHours < 48) {
|
||||
return $t('common.accountSelector.dateFormat.yesterday')
|
||||
return '昨天创建'
|
||||
} else if (diffInHours < 168) {
|
||||
// 7天内
|
||||
return `${Math.floor(diffInHours / 24)}${$t('common.accountSelector.dateFormat.daysAgo')}`
|
||||
return `${Math.floor(diffInHours / 24)} 天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
@@ -49,26 +49,22 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
const isVisible = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const title = ref('')
|
||||
const message = ref('')
|
||||
const confirmText = ref(t('common.confirmDialog.confirm'))
|
||||
const cancelText = ref(t('common.confirmDialog.cancel'))
|
||||
const confirmText = ref('确认')
|
||||
const cancelText = ref('取消')
|
||||
let resolvePromise = null
|
||||
|
||||
// 显示确认对话框
|
||||
const showConfirm = (
|
||||
titleText,
|
||||
messageText,
|
||||
confirmTextParam = t('common.confirmDialog.confirm'),
|
||||
cancelTextParam = t('common.confirmDialog.cancel')
|
||||
confirmTextParam = '确认',
|
||||
cancelTextParam = '取消'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
title.value = titleText
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-yellow-600 hover:to-orange-600"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
@@ -60,17 +55,14 @@ const props = defineProps({
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '继续'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '取消'
|
||||
}
|
||||
})
|
||||
|
||||
const confirmLabel = computed(() => props.confirmText || t('common.confirmModal.continue'))
|
||||
const cancelLabel = computed(() => props.cancelText || t('common.confirmModal.cancel'))
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<span
|
||||
class="select-none whitespace-nowrap text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{{ selectedLabel || placeholderText }}
|
||||
{{ selectedLabel || placeholder }}
|
||||
</span>
|
||||
<i
|
||||
:class="[
|
||||
@@ -65,9 +65,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -80,7 +77,7 @@ const props = defineProps({
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '请选择'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
@@ -99,8 +96,6 @@ const triggerRef = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const dropdownStyle = ref({})
|
||||
|
||||
const placeholderText = computed(() => props.placeholder || t('common.customDropdown.placeholder'))
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
||||
return selected ? selected.label : ''
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<div class="language-switch" :class="containerClass">
|
||||
<!-- 下拉菜单模式 -->
|
||||
<div v-if="mode === 'dropdown'" class="relative">
|
||||
<button
|
||||
ref="dropdownTrigger"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:border-gray-500"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ currentLocaleInfo.flag }}</span>
|
||||
<i
|
||||
:class="[
|
||||
'fas fa-chevron-down text-xs transition-transform duration-200',
|
||||
showDropdown ? 'rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 下拉选项 -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-40 rounded-lg border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="locale in supportedLocales"
|
||||
:key="locale.code"
|
||||
class="flex w-full items-center gap-3 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400':
|
||||
locale.code === localeStore.currentLocale
|
||||
}"
|
||||
@click="switchLocale(locale.code)"
|
||||
>
|
||||
<span class="text-base font-medium">{{ locale.flag }}</span>
|
||||
<span class="flex-1 text-left">{{ locale.name }}</span>
|
||||
<i
|
||||
v-if="locale.code === localeStore.currentLocale"
|
||||
class="fas fa-check text-xs text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 按钮模式 -->
|
||||
<div v-else-if="mode === 'button'" class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="locale in supportedLocales"
|
||||
:key="locale.code"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-all duration-200"
|
||||
:class="[
|
||||
locale.code === localeStore.currentLocale
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
]"
|
||||
@click="switchLocale(locale.code)"
|
||||
>
|
||||
<span>{{ locale.flag }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 图标模式 -->
|
||||
<div v-else-if="mode === 'icon'" class="relative">
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-all duration-200 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span class="text-lg">{{ currentLocaleInfo.flag }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 简化的下拉选项 -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-36 rounded-lg border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="locale in supportedLocales"
|
||||
:key="locale.code"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400':
|
||||
locale.code === localeStore.currentLocale
|
||||
}"
|
||||
@click="switchLocale(locale.code)"
|
||||
>
|
||||
<span class="font-medium">{{ locale.flag }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocaleStore } from '@/stores/locale'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'dropdown', // dropdown | button | icon
|
||||
validator: (value) => ['dropdown', 'button', 'icon'].includes(value)
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium', // small | medium | large
|
||||
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
// 发出事件
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
// 存储和响应式数据
|
||||
const { t } = useI18n()
|
||||
const localeStore = useLocaleStore()
|
||||
const showDropdown = ref(false)
|
||||
const dropdownTrigger = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const currentLocaleInfo = computed(() => localeStore.getCurrentLocaleInfo(t))
|
||||
const supportedLocales = computed(() => localeStore.getSupportedLocales(t))
|
||||
|
||||
const containerClass = computed(() => {
|
||||
const classes = []
|
||||
|
||||
if (props.size === 'small') {
|
||||
classes.push('text-xs')
|
||||
} else if (props.size === 'large') {
|
||||
classes.push('text-base')
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
// 切换语言
|
||||
const switchLocale = (locale) => {
|
||||
localeStore.setLocale(locale)
|
||||
showDropdown.value = false
|
||||
emit('change', locale)
|
||||
}
|
||||
|
||||
// 切换下拉菜单显示
|
||||
const toggleDropdown = () => {
|
||||
showDropdown.value = !showDropdown.value
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownTrigger.value && !dropdownTrigger.value.contains(event.target)) {
|
||||
showDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-switch {
|
||||
/* 自定义样式可以在这里添加 */
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<template v-if="!loading">
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:alt="$t('common.logoTitle.logoAlt')"
|
||||
alt="Logo"
|
||||
class="h-8 w-8 object-contain"
|
||||
:src="logoSrc"
|
||||
@error="handleLogoError"
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// Props
|
||||
defineProps({
|
||||
@@ -89,37 +88,32 @@ defineProps({
|
||||
// Store
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
|
||||
// 主题选项配置
|
||||
const themeOptions = computed(() => [
|
||||
const themeOptions = [
|
||||
{
|
||||
value: 'light',
|
||||
label: t('common.themeToggle.light.label'),
|
||||
shortLabel: t('common.themeToggle.light.shortLabel'),
|
||||
label: '浅色模式',
|
||||
shortLabel: '浅色',
|
||||
icon: 'fas fa-sun'
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: t('common.themeToggle.dark.label'),
|
||||
shortLabel: t('common.themeToggle.dark.shortLabel'),
|
||||
label: '深色模式',
|
||||
shortLabel: '深色',
|
||||
icon: 'fas fa-moon'
|
||||
},
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('common.themeToggle.auto.label'),
|
||||
shortLabel: t('common.themeToggle.auto.shortLabel'),
|
||||
label: '跟随系统',
|
||||
shortLabel: '自动',
|
||||
icon: 'fas fa-circle-half-stroke'
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const themeTooltip = computed(() => {
|
||||
const current = themeOptions.value.find((opt) => opt.value === themeStore.themeMode)
|
||||
return current
|
||||
? `${t('common.themeToggle.clickToSwitch')} - ${current.label}`
|
||||
: t('common.themeToggle.toggleTheme')
|
||||
const current = themeOptions.find((opt) => opt.value === themeStore.themeMode)
|
||||
return current ? `点击切换主题 - ${current.label}` : '切换主题'
|
||||
})
|
||||
|
||||
// 方法
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<i :class="getIconClass(toast.type)" />
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div v-if="toast.title || getDefaultTitle(toast.type)" class="toast-title">
|
||||
{{ toast.title || getDefaultTitle(toast.type) }}
|
||||
<div v-if="toast.title" class="toast-title">
|
||||
{{ toast.title }}
|
||||
</div>
|
||||
<div class="toast-message">
|
||||
{{ toast.message }}
|
||||
@@ -35,9 +35,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
const toasts = ref([])
|
||||
@@ -54,11 +51,6 @@ const getIconClass = (type) => {
|
||||
return iconMap[type] || iconMap.info
|
||||
}
|
||||
|
||||
// 获取默认标题
|
||||
const getDefaultTitle = (type) => {
|
||||
return t(`common.toastNotification.defaultTitles.${type}`)
|
||||
}
|
||||
|
||||
// 添加Toast
|
||||
const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
const id = ++toastIdCounter
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-robot mr-2 text-purple-500" />
|
||||
{{ $t('dashboard.modelDistribution.title') }}
|
||||
模型使用分布
|
||||
</h2>
|
||||
|
||||
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
||||
<el-radio-button label="daily">
|
||||
{{ $t('dashboard.modelDistribution.periods.daily') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="total">
|
||||
{{ $t('dashboard.modelDistribution.periods.total') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="daily"> 今日 </el-radio-button>
|
||||
<el-radio-button label="total"> 累计 </el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
@@ -21,16 +17,16 @@
|
||||
class="py-12 text-center text-gray-500"
|
||||
>
|
||||
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
|
||||
<p>{{ $t('dashboard.modelDistribution.noData') }}</p>
|
||||
<p>暂无模型使用数据</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Pie Chart -->
|
||||
<!-- 饼图 -->
|
||||
<div class="relative" style="height: 300px">
|
||||
<canvas ref="chartCanvas" />
|
||||
</div>
|
||||
|
||||
<!-- Data List -->
|
||||
<!-- 数据列表 -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(stat, index) in sortedStats"
|
||||
@@ -42,14 +38,8 @@
|
||||
<span class="font-medium text-gray-700">{{ stat.model }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-800">
|
||||
{{ formatNumber(stat.requests) }}
|
||||
{{ $t('dashboard.modelDistribution.units.requests') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ formatNumber(stat.totalTokens) }}
|
||||
{{ $t('dashboard.modelDistribution.units.tokens') }}
|
||||
</p>
|
||||
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
|
||||
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,13 +50,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
@@ -123,8 +110,8 @@ const createChart = () => {
|
||||
).toFixed(1)
|
||||
return [
|
||||
`${stat.model}: ${percentage}%`,
|
||||
`${t('dashboard.modelDistribution.chart.tooltip.requests')}: ${formatNumber(stat.requests)}`,
|
||||
`${t('dashboard.modelDistribution.chart.tooltip.tokens')}: ${formatNumber(stat.totalTokens)}`
|
||||
`请求: ${formatNumber(stat.requests)}`,
|
||||
`Tokens: ${formatNumber(stat.totalTokens)}`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-chart-area mr-2 text-blue-500" />
|
||||
{{ $t('dashboard.usageTrend.title') }}
|
||||
使用趋势
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
||||
<el-radio-button label="day">
|
||||
{{ $t('dashboard.usageTrend.granularity.byDay') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="hour">
|
||||
{{ $t('dashboard.usageTrend.granularity.byHour') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button label="day"> 按天 </el-radio-button>
|
||||
<el-radio-button label="hour"> 按小时 </el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select
|
||||
@@ -25,7 +21,7 @@
|
||||
<el-option
|
||||
v-for="period in periodOptions"
|
||||
:key="period.days"
|
||||
:label="$t('dashboard.usageTrend.periodOptions.recentDays', { days: period.days })"
|
||||
:label="`最近${period.days}天`"
|
||||
:value="period.days"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -39,25 +35,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
const { t } = useI18n()
|
||||
|
||||
const trendPeriod = ref(7)
|
||||
const granularity = ref('day')
|
||||
|
||||
const periodOptions = computed(() => [
|
||||
{ days: 1, label: t('dashboard.usageTrend.periodOptions.last24Hours') },
|
||||
{ days: 7, label: t('dashboard.usageTrend.periodOptions.last7Days') },
|
||||
{ days: 30, label: t('dashboard.usageTrend.periodOptions.last30Days') }
|
||||
])
|
||||
const periodOptions = [
|
||||
{ days: 1, label: '24小时' },
|
||||
{ days: 7, label: '7天' },
|
||||
{ days: 30, label: '30天' }
|
||||
]
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
||||
@@ -87,7 +81,7 @@ const createChart = () => {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.usageTrend.chartLabels.requests'),
|
||||
label: '请求次数',
|
||||
data: dashboardStore.trendData.map((item) => item.requests),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||
@@ -95,7 +89,7 @@ const createChart = () => {
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: t('dashboard.usageTrend.chartLabels.tokens'),
|
||||
label: 'Token使用量',
|
||||
data: dashboardStore.trendData.map((item) => item.tokens),
|
||||
borderColor: '#f093fb',
|
||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||
@@ -133,7 +127,7 @@ const createChart = () => {
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.usageTrend.chartLabels.requestsAxis')
|
||||
text: '请求次数'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
@@ -142,7 +136,7 @@ const createChart = () => {
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.usageTrend.chartLabels.tokensAxis')
|
||||
text: 'Token使用量'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:subtitle="t('header.adminPanel')"
|
||||
subtitle="管理后台"
|
||||
:title="oemSettings.siteName"
|
||||
title-class="text-white dark:text-gray-100"
|
||||
>
|
||||
@@ -27,10 +27,10 @@
|
||||
class="inline-flex animate-pulse items-center gap-1 rounded-full border border-green-600 bg-green-500 px-2 py-0.5 text-xs text-white transition-colors hover:bg-green-600"
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
:title="t('header.newVersionAvailable')"
|
||||
title="有新版本可用"
|
||||
>
|
||||
<i class="fas fa-arrow-up text-[10px]" />
|
||||
<span>{{ t('header.newVersion') }}</span>
|
||||
<span>新版本</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,11 +38,6 @@
|
||||
</div>
|
||||
<!-- 主题切换和用户菜单 -->
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<!-- 语言切换按钮 -->
|
||||
<div class="flex items-center">
|
||||
<LanguageSwitch mode="dropdown" size="medium" />
|
||||
</div>
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle mode="dropdown" />
|
||||
@@ -60,7 +55,7 @@
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
>
|
||||
<i class="fas fa-user-circle text-sm sm:text-base" />
|
||||
<span class="hidden sm:inline">{{ currentUser.username || t('common.admin') }}</span>
|
||||
<span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span>
|
||||
<i
|
||||
class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
|
||||
:class="{ 'rotate-180': userMenuOpen }"
|
||||
@@ -77,9 +72,7 @@
|
||||
<!-- 版本信息 -->
|
||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{
|
||||
t('header.currentVersion')
|
||||
}}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">当前版本</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-300"
|
||||
>v{{ versionInfo.current || '...' }}</span
|
||||
>
|
||||
@@ -87,7 +80,7 @@
|
||||
<div v-if="versionInfo.hasUpdate" class="mt-2">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-arrow-up mr-1" />{{ t('header.hasUpdate') }}
|
||||
<i class="fas fa-arrow-up mr-1" />有新版本
|
||||
</span>
|
||||
<span class="font-mono text-green-600 dark:text-green-400"
|
||||
>v{{ versionInfo.latest }}</span
|
||||
@@ -98,14 +91,14 @@
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fas fa-external-link-alt mr-1" />{{ t('header.viewUpdate') }}
|
||||
<i class="fas fa-external-link-alt mr-1" />查看更新
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="versionInfo.checkingUpdate"
|
||||
class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-1" />{{ t('header.checkingUpdate') }}
|
||||
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
|
||||
</div>
|
||||
<div v-else class="mt-2 text-center">
|
||||
<!-- 已是最新版提醒 -->
|
||||
@@ -116,7 +109,7 @@
|
||||
class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5 dark:border-green-800 dark:bg-green-900/30"
|
||||
>
|
||||
<p class="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
<i class="fas fa-check-circle mr-1" />{{ t('header.alreadyLatest') }}
|
||||
<i class="fas fa-check-circle mr-1" />当前已是最新版本
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -125,7 +118,7 @@
|
||||
class="text-xs text-blue-500 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
@click="checkForUpdates()"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />{{ t('header.checkUpdate') }}
|
||||
<i class="fas fa-sync-alt mr-1" />检查更新
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -136,7 +129,7 @@
|
||||
@click="openChangePasswordModal"
|
||||
>
|
||||
<i class="fas fa-key text-blue-500" />
|
||||
<span>{{ t('header.changeAccountInfo') }}</span>
|
||||
<span>修改账户信息</span>
|
||||
</button>
|
||||
|
||||
<hr class="my-2 border-gray-200 dark:border-gray-700" />
|
||||
@@ -146,7 +139,7 @@
|
||||
@click="logout"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt text-red-500" />
|
||||
<span>{{ t('header.logout') }}</span>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,9 +160,7 @@
|
||||
>
|
||||
<i class="fas fa-key text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ t('header.changePasswordModal.title') }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改账户信息</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||
@@ -184,72 +175,68 @@
|
||||
@submit.prevent="changePassword"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.currentUsername')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>当前用户名</label
|
||||
>
|
||||
<input
|
||||
class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
|
||||
disabled
|
||||
type="text"
|
||||
:value="currentUser.username || t('common.admin')"
|
||||
:value="currentUser.username || 'Admin'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('header.changePasswordModal.currentUsernameHint') }}
|
||||
当前用户名,输入新用户名以修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.newUsername')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>新用户名</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.newUsername"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.newUsernamePlaceholder')"
|
||||
placeholder="输入新用户名(留空保持不变)"
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('header.changePasswordModal.newUsernameHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">留空表示不修改用户名</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.currentPassword')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>当前密码</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.currentPasswordPlaceholder')"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.newPassword')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>新密码</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.newPasswordPlaceholder')"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('header.changePasswordModal.newPasswordHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">密码长度至少8位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">{{
|
||||
t('header.changePasswordModal.confirmPassword')
|
||||
}}</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>确认新密码</label
|
||||
>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('header.changePasswordModal.confirmPasswordPlaceholder')"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
@@ -261,7 +248,7 @@
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
@@ -270,11 +257,7 @@
|
||||
>
|
||||
<div v-if="changePasswordLoading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{
|
||||
changePasswordLoading
|
||||
? t('header.changePasswordModal.saving')
|
||||
: t('header.changePasswordModal.save')
|
||||
}}
|
||||
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -285,20 +268,17 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = computed(() => authStore.user || {})
|
||||
const currentUser = computed(() => authStore.user || { username: 'Admin' })
|
||||
|
||||
// OEM设置
|
||||
const oemSettings = computed(() => authStore.oemSettings || {})
|
||||
@@ -405,12 +385,12 @@ const closeChangePasswordModal = () => {
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
|
||||
showToast(t('header.changePasswordModal.passwordMismatch'), 'error')
|
||||
showToast('两次输入的密码不一致', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (changePasswordForm.newPassword.length < 8) {
|
||||
showToast(t('header.changePasswordModal.passwordTooShort'), 'error')
|
||||
showToast('新密码长度至少8位', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -425,8 +405,8 @@ const changePassword = async () => {
|
||||
|
||||
if (data.success) {
|
||||
const message = changePasswordForm.newUsername
|
||||
? t('header.changePasswordModal.accountInfoChangeSuccess')
|
||||
: t('header.changePasswordModal.passwordChangeSuccess')
|
||||
? '账户信息修改成功,请重新登录'
|
||||
: '密码修改成功,请重新登录'
|
||||
showToast(message, 'success')
|
||||
closeChangePasswordModal()
|
||||
|
||||
@@ -436,10 +416,10 @@ const changePassword = async () => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} else {
|
||||
showToast(data.message || t('header.changePasswordModal.changeFailed'), 'error')
|
||||
showToast(data.message || '修改失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('header.changePasswordModal.changePasswordFailed'), 'error')
|
||||
showToast('修改密码失败', 'error')
|
||||
} finally {
|
||||
changePasswordLoading.value = false
|
||||
}
|
||||
@@ -447,10 +427,10 @@ const changePassword = async () => {
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
if (confirm(t('header.logoutConfirm'))) {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
showToast(t('header.logoutSuccess'), 'success')
|
||||
showToast('已安全退出', 'success')
|
||||
}
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
@@ -22,13 +22,10 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -127,7 +124,7 @@ const handleTabChange = async (tabKey) => {
|
||||
} catch (err) {
|
||||
// 如果路由切换失败,恢复activeTab状态
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
console.error(t('layout.mainLayout.routing.routeChangeError'), err)
|
||||
console.error('路由切换失败:', err)
|
||||
// 恢复到当前路由对应的tab
|
||||
initActiveTab()
|
||||
}
|
||||
|
||||
@@ -38,11 +38,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
@@ -57,49 +54,24 @@ const authStore = useAuthStore()
|
||||
// 根据 LDAP 配置动态生成 tabs
|
||||
const tabs = computed(() => {
|
||||
const baseTabs = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
name: t('layout.tabBar.tabs.dashboard.name'),
|
||||
shortName: t('layout.tabBar.tabs.dashboard.shortName'),
|
||||
icon: 'fas fa-tachometer-alt'
|
||||
},
|
||||
{
|
||||
key: 'apiKeys',
|
||||
name: t('layout.tabBar.tabs.apiKeys.name'),
|
||||
shortName: t('layout.tabBar.tabs.apiKeys.shortName'),
|
||||
icon: 'fas fa-key'
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
name: t('layout.tabBar.tabs.accounts.name'),
|
||||
shortName: t('layout.tabBar.tabs.accounts.shortName'),
|
||||
icon: 'fas fa-user-circle'
|
||||
}
|
||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
|
||||
]
|
||||
|
||||
// 只有在 LDAP 启用时才显示用户管理
|
||||
if (authStore.oemSettings?.ldapEnabled) {
|
||||
baseTabs.push({
|
||||
key: 'userManagement',
|
||||
name: t('layout.tabBar.tabs.userManagement.name'),
|
||||
shortName: t('layout.tabBar.tabs.userManagement.shortName'),
|
||||
name: '用户管理',
|
||||
shortName: '用户',
|
||||
icon: 'fas fa-users'
|
||||
})
|
||||
}
|
||||
|
||||
baseTabs.push(
|
||||
{
|
||||
key: 'tutorial',
|
||||
name: t('layout.tabBar.tabs.tutorial.name'),
|
||||
shortName: t('layout.tabBar.tabs.tutorial.shortName'),
|
||||
icon: 'fas fa-graduation-cap'
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
name: t('layout.tabBar.tabs.settings.name'),
|
||||
shortName: t('layout.tabBar.tabs.settings.shortName'),
|
||||
icon: 'fas fa-cogs'
|
||||
}
|
||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
||||
)
|
||||
|
||||
return baseTabs
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ t('user.createApiKeyModal.title') }}</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -23,16 +23,13 @@
|
||||
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="name">
|
||||
{{ t('user.createApiKeyModal.form.nameLabel') }}
|
||||
{{ t('user.createApiKeyModal.form.nameRequired') }}
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
:disabled="loading"
|
||||
:placeholder="t('user.createApiKeyModal.form.namePlaceholder')"
|
||||
placeholder="Enter API key name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
@@ -40,14 +37,14 @@
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700" for="description">
|
||||
{{ t('user.createApiKeyModal.form.descriptionLabel') }}
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
:disabled="loading"
|
||||
:placeholder="t('user.createApiKeyModal.form.descriptionPlaceholder')"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -76,7 +73,7 @@
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ t('user.createApiKeyModal.buttons.cancel') }}
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -104,9 +101,9 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{{ t('user.createApiKeyModal.buttons.creating') }}
|
||||
Creating...
|
||||
</span>
|
||||
<span v-else>{{ t('user.createApiKeyModal.buttons.createApiKey') }}</span>
|
||||
<span v-else>Create API Key</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -124,13 +121,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h4 class="text-sm font-medium text-green-800">
|
||||
{{ t('user.createApiKeyModal.success.title') }}
|
||||
</h4>
|
||||
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
|
||||
<div class="mt-3">
|
||||
<p class="mb-2 text-sm text-green-700">
|
||||
<strong>{{ t('user.createApiKeyModal.success.warning.important') }}</strong>
|
||||
{{ t('user.createApiKeyModal.success.warning.message') }}
|
||||
<strong>Important:</strong> Copy your API key now. You won't be able to see it
|
||||
again!
|
||||
</p>
|
||||
<div class="rounded-md border border-green-300 bg-white p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -154,7 +149,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.createApiKeyModal.buttons.copy') }}
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +159,7 @@
|
||||
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('user.createApiKeyModal.buttons.done') }}
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +172,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
@@ -190,7 +184,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -211,7 +204,7 @@ const resetForm = () => {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
error.value = t('user.createApiKeyModal.validation.nameRequired')
|
||||
error.value = 'API key name is required'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,14 +221,13 @@ const handleSubmit = async () => {
|
||||
|
||||
if (result.success) {
|
||||
newApiKey.value = result.apiKey
|
||||
showToast(t('user.createApiKeyModal.messages.createSuccess'), 'success')
|
||||
showToast('API key created successfully!', 'success')
|
||||
} else {
|
||||
error.value = result.message || t('user.createApiKeyModal.errors.createFailed')
|
||||
error.value = result.message || 'Failed to create API key'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create API key error:', err)
|
||||
error.value =
|
||||
err.response?.data?.message || err.message || t('user.createApiKeyModal.errors.createFailed')
|
||||
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -244,10 +236,10 @@ const handleSubmit = async () => {
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showToast(t('user.createApiKeyModal.messages.copySuccess'), 'success')
|
||||
showToast('API key copied to clipboard!', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showToast(t('user.createApiKeyModal.messages.copyFailed'), 'error')
|
||||
showToast('Failed to copy to clipboard', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">
|
||||
{{ t('user.userApiKeysManager.title') }}
|
||||
</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">
|
||||
{{ t('user.userApiKeysManager.description') }}
|
||||
Manage your API keys to access Claude Relay services
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
@@ -23,7 +21,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.userApiKeysManager.buttons.createApiKey') }}
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +43,8 @@
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
{{ t('user.userApiKeysManager.warnings.maxKeysReached', { maxApiKeys }) }}
|
||||
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
|
||||
existing key to create a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +72,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">{{ t('user.userApiKeysManager.loading') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys List -->
|
||||
@@ -101,37 +100,29 @@
|
||||
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
|
||||
>
|
||||
{{ t('user.userApiKeysManager.status.deleted') }}
|
||||
Deleted
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!apiKey.isActive"
|
||||
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
{{ t('user.userApiKeysManager.status.deleted') }}
|
||||
Deleted
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ apiKey.description || t('user.userApiKeysManager.status.noDescription') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
|
||||
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
|
||||
<span
|
||||
>{{ t('user.userApiKeysManager.dateLabels.created') }}:
|
||||
{{ formatDate(apiKey.createdAt) }}</span
|
||||
>
|
||||
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
|
||||
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||
>{{ t('user.userApiKeysManager.dateLabels.deleted') }}:
|
||||
{{ formatDate(apiKey.deletedAt) }}</span
|
||||
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
|
||||
>
|
||||
<span v-else-if="apiKey.lastUsedAt"
|
||||
>{{ t('user.userApiKeysManager.dateLabels.lastUsed') }}:
|
||||
{{ formatDate(apiKey.lastUsedAt) }}</span
|
||||
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
|
||||
>
|
||||
<span v-else>{{ t('user.userApiKeysManager.status.neverUsed') }}</span>
|
||||
<span v-else>Never used</span>
|
||||
<span
|
||||
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
|
||||
>{{ t('user.userApiKeysManager.dateLabels.expires') }}:
|
||||
{{ formatDate(apiKey.expiresAt) }}</span
|
||||
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,10 +131,7 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Usage Stats -->
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
<div>
|
||||
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||
{{ t('user.userApiKeysManager.usage.requests') }}
|
||||
</div>
|
||||
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
|
||||
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +139,7 @@
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
|
||||
:title="t('user.userApiKeysManager.actions.viewApiKey')"
|
||||
title="View API Key"
|
||||
@click="showApiKey(apiKey)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -177,7 +165,7 @@
|
||||
allowUserDeleteApiKeys
|
||||
"
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
||||
:title="t('user.userApiKeysManager.actions.deleteApiKey')"
|
||||
title="Delete API Key"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -211,12 +199,8 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ t('user.userApiKeysManager.emptyState.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ t('user.userApiKeysManager.emptyState.description') }}
|
||||
</p>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@@ -230,7 +214,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.userApiKeysManager.buttons.createApiKey') }}
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,10 +236,10 @@
|
||||
<!-- Confirm Delete Modal -->
|
||||
<ConfirmModal
|
||||
confirm-class="bg-red-600 hover:bg-red-700"
|
||||
:confirm-text="t('user.userApiKeysManager.buttons.delete')"
|
||||
:message="t('user.userApiKeysManager.confirmDelete.message', { name: selectedApiKey?.name })"
|
||||
confirm-text="Delete"
|
||||
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
|
||||
:show="showDeleteModal"
|
||||
:title="t('user.userApiKeysManager.confirmDelete.title')"
|
||||
title="Delete API Key"
|
||||
@cancel="showDeleteModal = false"
|
||||
@confirm="handleDeleteConfirm"
|
||||
/>
|
||||
@@ -264,15 +248,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
||||
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -310,12 +291,7 @@ const formatNumber = (num) => {
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
const localeMap = {
|
||||
'zh-cn': 'zh-CN',
|
||||
'zh-tw': 'zh-TW',
|
||||
en: 'en-US'
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString(localeMap[locale.value] || 'en-US', {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -330,7 +306,7 @@ const loadApiKeys = async () => {
|
||||
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
showToast(t('user.userApiKeysManager.messages.loadFailed'), 'error')
|
||||
showToast('Failed to load API keys', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -351,12 +327,12 @@ const handleDeleteConfirm = async () => {
|
||||
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
|
||||
|
||||
if (result.success) {
|
||||
showToast(t('user.userApiKeysManager.messages.deleteSuccess'), 'success')
|
||||
showToast('API key deleted successfully', 'success')
|
||||
await loadApiKeys()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error)
|
||||
showToast(t('user.userApiKeysManager.messages.deleteFailed'), 'error')
|
||||
showToast('Failed to delete API key', 'error')
|
||||
} finally {
|
||||
showDeleteModal.value = false
|
||||
selectedApiKey.value = null
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">{{ t('user.userUsageStats.title') }}</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">{{ t('user.userUsageStats.subtitle') }}</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<select
|
||||
@@ -11,10 +11,10 @@
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
@change="loadUsageStats"
|
||||
>
|
||||
<option value="day">{{ t('user.userUsageStats.periodSelection.day') }}</option>
|
||||
<option value="week">{{ t('user.userUsageStats.periodSelection.week') }}</option>
|
||||
<option value="month">{{ t('user.userUsageStats.periodSelection.month') }}</option>
|
||||
<option value="quarter">{{ t('user.userUsageStats.periodSelection.quarter') }}</option>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
<option value="month">Last 30 Days</option>
|
||||
<option value="quarter">Last 90 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">{{ t('user.userUsageStats.loadingStats') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
@@ -66,9 +66,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.totalRequests') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||
</dd>
|
||||
@@ -98,9 +96,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.inputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -130,9 +126,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.outputTokens') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||
</dd>
|
||||
@@ -162,9 +156,7 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">
|
||||
{{ t('user.userUsageStats.statsCards.totalCost') }}
|
||||
</dt>
|
||||
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||
</dd>
|
||||
@@ -178,9 +170,7 @@
|
||||
<!-- Daily Usage Chart -->
|
||||
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||
{{ t('user.userUsageStats.usageTrend.title') }}
|
||||
</h3>
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
|
||||
|
||||
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
|
||||
<div
|
||||
@@ -200,14 +190,10 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ t('user.userUsageStats.usageTrend.chartTitle') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ t('user.userUsageStats.usageTrend.dailyTrendsDescription') }}
|
||||
</p>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{{ t('user.userUsageStats.usageTrend.chartIntegrationNote') }}
|
||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,9 +206,7 @@
|
||||
class="rounded-lg bg-white shadow"
|
||||
>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||
{{ t('user.userUsageStats.modelUsage.title') }}
|
||||
</h3>
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="model in usageStats.modelStats"
|
||||
@@ -238,13 +222,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-900">
|
||||
{{
|
||||
t('user.userUsageStats.modelUsage.requestsCount', {
|
||||
count: formatNumber(model.requests)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
|
||||
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,9 +233,7 @@
|
||||
<!-- Detailed Usage Table -->
|
||||
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||
{{ t('user.userUsageStats.apiKeyUsage.title') }}
|
||||
</h3>
|
||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
@@ -266,37 +242,37 @@
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.apiKey') }}
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.requests') }}
|
||||
Requests
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.inputTokens') }}
|
||||
Input Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.outputTokens') }}
|
||||
Output Tokens
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.cost') }}
|
||||
Cost
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
scope="col"
|
||||
>
|
||||
{{ t('user.userUsageStats.apiKeyUsage.headers.status') }}
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -331,10 +307,10 @@
|
||||
>
|
||||
{{
|
||||
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
||||
? t('user.userUsageStats.apiKeyUsage.status.deleted')
|
||||
? 'Deleted'
|
||||
: apiKey.isActive
|
||||
? t('user.userUsageStats.apiKeyUsage.status.active')
|
||||
: t('user.userUsageStats.apiKeyUsage.status.disabled')
|
||||
? 'Active'
|
||||
: 'Disabled'
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
@@ -363,11 +339,10 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
{{ t('user.userUsageStats.noData.title') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ t('user.userUsageStats.noData.description') }}
|
||||
You haven't made any API requests yet. Create an API key and start using the service to see
|
||||
usage statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,12 +350,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -409,7 +381,7 @@ const loadUsageStats = async () => {
|
||||
userApiKeys.value = apiKeys
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage stats:', error)
|
||||
showToast(t('user.userUsageStats.loadFailed'), 'error')
|
||||
showToast('Failed to load usage statistics', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<div class="mt-3">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ t('user.viewApiKeyModal.title') }}</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -24,35 +24,29 @@
|
||||
<div v-if="apiKey" class="space-y-4">
|
||||
<!-- API Key Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.name')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="apiKey.description">
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.description')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.apiKey')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<div class="mt-1 flex items-center space-x-2">
|
||||
<div class="flex-1">
|
||||
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||
<code class="break-all font-mono text-sm text-gray-900">{{
|
||||
apiKey.key || t('user.viewApiKeyModal.apiKeyDisplay.notAvailable')
|
||||
apiKey.key || 'Not available'
|
||||
}}</code>
|
||||
</div>
|
||||
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||
<code class="font-mono text-sm text-gray-900">{{
|
||||
apiKey.keyPreview || t('user.viewApiKeyModal.apiKeyDisplay.keyPreview')
|
||||
apiKey.keyPreview || 'cr_****'
|
||||
}}</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,11 +90,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{
|
||||
showFullKey
|
||||
? t('user.viewApiKeyModal.buttons.hide')
|
||||
: t('user.viewApiKeyModal.buttons.show')
|
||||
}}
|
||||
{{ showFullKey ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showFullKey && apiKey.key"
|
||||
@@ -115,20 +105,18 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.viewApiKeyModal.buttons.copy') }}
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
|
||||
{{ t('user.viewApiKeyModal.apiKeyDisplay.fullKeyNotice') }}
|
||||
Full API key is only shown when first created or regenerated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.status')
|
||||
}}</label>
|
||||
<label class="block text-sm font-medium text-gray-700">Status</label>
|
||||
<div class="mt-1">
|
||||
<span
|
||||
:class="[
|
||||
@@ -136,47 +124,33 @@
|
||||
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
apiKey.isActive
|
||||
? t('user.viewApiKeyModal.status.active')
|
||||
: t('user.viewApiKeyModal.status.disabled')
|
||||
}}
|
||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats -->
|
||||
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">{{
|
||||
t('user.viewApiKeyModal.fields.usageStatistics')
|
||||
}}</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.requests') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Requests:</span>
|
||||
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.inputTokens') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Input Tokens:</span>
|
||||
<span class="ml-2 font-medium">{{
|
||||
formatNumber(apiKey.usage.inputTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.outputTokens') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Output Tokens:</span>
|
||||
<span class="ml-2 font-medium">{{
|
||||
formatNumber(apiKey.usage.outputTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.usageStats.totalCost') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Total Cost:</span>
|
||||
<span class="ml-2 font-medium"
|
||||
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
|
||||
>
|
||||
@@ -187,17 +161,15 @@
|
||||
<!-- Timestamps -->
|
||||
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">{{ t('user.viewApiKeyModal.timestamps.created') }}:</span>
|
||||
<span class="text-gray-500">Created:</span>
|
||||
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
|
||||
<span class="text-gray-500"
|
||||
>{{ t('user.viewApiKeyModal.timestamps.lastUsed') }}:</span
|
||||
>
|
||||
<span class="text-gray-500">Last Used:</span>
|
||||
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="apiKey.expiresAt" class="flex justify-between">
|
||||
<span class="text-gray-500">{{ t('user.viewApiKeyModal.timestamps.expires') }}:</span>
|
||||
<span class="text-gray-500">Expires:</span>
|
||||
<span
|
||||
:class="[
|
||||
'font-medium',
|
||||
@@ -214,7 +186,7 @@
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ t('user.viewApiKeyModal.buttons.close') }}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,11 +197,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -256,26 +225,22 @@ const formatNumber = (num) => {
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
const { locale } = useI18n()
|
||||
return new Date(dateString).toLocaleDateString(
|
||||
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showToast(t('user.viewApiKeyModal.messages.copySuccess'), 'success')
|
||||
showToast('Copied to clipboard!', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showToast(t('user.viewApiKeyModal.messages.copyFailed'), 'error')
|
||||
showToast('Failed to copy to clipboard', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export function useChartConfig() {
|
||||
// 设置Chart.js默认配置
|
||||
@@ -52,9 +51,7 @@ export function useChartConfig() {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
const localeMap = { 'zh-cn': 'zh-CN', 'zh-tw': 'zh-TW', en: 'en-US' }
|
||||
const currentLocale = localeMap[i18n.global.locale.value] || 'en-US'
|
||||
label += new Intl.NumberFormat(currentLocale).format(context.parsed.y)
|
||||
label += new Intl.NumberFormat('zh-CN').format(context.parsed.y)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmOptions = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: i18n.global.t('common.confirmModal.continue'),
|
||||
cancelText: i18n.global.t('common.confirmModal.cancel')
|
||||
confirmText: '继续',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
export function useConfirm() {
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = i18n.global.t('common.confirmModal.continue'),
|
||||
cancelText = i18n.global.t('common.confirmModal.cancel')
|
||||
) => {
|
||||
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmOptions.value = {
|
||||
title,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// API Stats 专用 API 客户端
|
||||
// 与管理员 API 隔离,不需要认证
|
||||
|
||||
import i18n from '@/i18n'
|
||||
|
||||
class ApiStatsClient {
|
||||
constructor() {
|
||||
this.baseURL = window.location.origin
|
||||
@@ -28,9 +26,7 @@ class ApiStatsClient {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
data.message || i18n.global.t('common.errors.requestFailed', { status: response.status })
|
||||
)
|
||||
throw new Error(data.message || `请求失败: ${response.status}`)
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
@@ -4,7 +4,7 @@ export const APP_CONFIG = {
|
||||
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,
|
||||
|
||||
@@ -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
@@ -1,12 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useUserStore } from './stores/user'
|
||||
import i18n from './i18n'
|
||||
import './assets/styles/main.css'
|
||||
import './assets/styles/global.css'
|
||||
|
||||
@@ -20,11 +20,10 @@ app.use(pinia)
|
||||
// 使用路由
|
||||
app.use(router)
|
||||
|
||||
// 使用Vue I18n
|
||||
app.use(i18n)
|
||||
|
||||
// 使用Element Plus - 语言配置在 App.vue 中通过 ElConfigProvider 处理
|
||||
app.use(ElementPlus)
|
||||
// 使用Element Plus
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn
|
||||
})
|
||||
|
||||
// 设置axios拦截器
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { APP_CONFIG } from '@/config/app'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
// 路由懒加载
|
||||
const LoginView = () => import('@/views/LoginView.vue')
|
||||
@@ -151,7 +150,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
console.log('Router navigation:', {
|
||||
console.log('路由导航:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
fullPath: to.fullPath,
|
||||
@@ -178,6 +177,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
} catch (error) {
|
||||
// If the error is about disabled account, redirect to login with error
|
||||
if (error.message && error.message.includes('disabled')) {
|
||||
// Import showToast to display the error
|
||||
const { showToast } = await import('@/utils/toast')
|
||||
showToast(error.message, 'error')
|
||||
}
|
||||
return next('/user-login')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export const useAccountsStore = defineStore('accounts', () => {
|
||||
// 状态
|
||||
@@ -28,7 +27,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
claudeAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed'))
|
||||
throw new Error(response.message || '获取Claude账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -47,7 +46,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
claudeConsoleAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed'))
|
||||
throw new Error(response.message || '获取Claude Console账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -66,7 +65,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
bedrockAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed'))
|
||||
throw new Error(response.message || '获取Bedrock账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -85,7 +84,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
geminiAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed'))
|
||||
throw new Error(response.message || '获取Gemini账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -104,7 +103,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
openaiAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed'))
|
||||
throw new Error(response.message || '获取OpenAI账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -123,7 +122,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
azureOpenaiAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.loadAccountsFailed'))
|
||||
throw new Error(response.message || '获取Azure OpenAI账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -184,7 +183,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchClaudeAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '创建Claude账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -204,9 +203,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchClaudeConsoleAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message || i18n.global.t('common.errors.createClaudeConsoleAccountFailed')
|
||||
)
|
||||
throw new Error(response.message || '创建Claude Console账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -226,7 +223,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchBedrockAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '创建Bedrock账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -246,7 +243,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchGeminiAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '创建Gemini账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -266,7 +263,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchOpenAIAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '创建OpenAI账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -286,9 +283,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchAzureOpenAIAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message || i18n.global.t('common.errors.createAzureOpenAIAccountFailed')
|
||||
)
|
||||
throw new Error(response.message || '创建Azure OpenAI账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -328,7 +323,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchClaudeAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '更新Claude账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -348,9 +343,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchClaudeConsoleAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message || i18n.global.t('common.errors.updateClaudeConsoleAccountFailed')
|
||||
)
|
||||
throw new Error(response.message || '更新Claude Console账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -370,7 +363,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchBedrockAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '更新Bedrock账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -390,7 +383,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchGeminiAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '更新Gemini账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -410,7 +403,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchOpenAIAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '更新OpenAI账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -430,9 +423,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchAzureOpenAIAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message || i18n.global.t('common.errors.updateAzureOpenAIAccountFailed')
|
||||
)
|
||||
throw new Error(response.message || '更新Azure OpenAI账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -507,7 +498,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
}
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.schedulingToggleFailed'))
|
||||
throw new Error(response.message || '切换状态失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -562,7 +553,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
}
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.deleteFailed'))
|
||||
throw new Error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -582,7 +573,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
await fetchClaudeAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || 'Token刷新失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -599,7 +590,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '生成授权URL失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -614,7 +605,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '交换授权码失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -632,9 +623,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message || i18n.global.t('common.errors.generateSetupTokenUrlFailed')
|
||||
)
|
||||
throw new Error(response.message || '生成Setup Token URL失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -652,7 +641,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('common.errors.exchangeSetupTokenFailed'))
|
||||
throw new Error(response.message || '交换Setup Token授权码失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -667,7 +656,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '生成授权URL失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -682,7 +671,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '交换授权码失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -697,7 +686,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '生成授权URL失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -712,7 +701,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('accounts.operationFailed'))
|
||||
throw new Error(response.message || '交换授权码失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiClient } from '@/config/api'
|
||||
import i18n from '@/i18n'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -23,7 +22,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
if (response.success) {
|
||||
apiKeys.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.loadFailed'))
|
||||
throw new Error(response.message || '获取API Keys失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -43,7 +42,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
await fetchApiKeys()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed'))
|
||||
throw new Error(response.message || '创建API Key失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -63,7 +62,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.updateFailed'))
|
||||
throw new Error(response.message || '更新API Key失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -83,7 +82,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed'))
|
||||
throw new Error(response.message || '切换状态失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -103,7 +102,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed'))
|
||||
throw new Error(response.message || '续期失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -123,7 +122,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.deleteFailed'))
|
||||
throw new Error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -142,10 +141,10 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
if (response.success) {
|
||||
return response.stats
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed'))
|
||||
throw new Error(response.message || '获取统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(i18n.global.t('common.errors.getApiKeyStatsFailed'), err)
|
||||
console.error('获取API Key统计失败:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -167,10 +166,10 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
if (response.success) {
|
||||
return response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || i18n.global.t('apiKeys.operationFailed'))
|
||||
throw new Error(response.message || '获取标签失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(i18n.global.t('common.errors.getTagsFailed'), err)
|
||||
console.error('获取标签失败:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiStatsClient } from '@/config/apiStats'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 状态
|
||||
@@ -94,7 +93,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
}
|
||||
|
||||
if (!apiKey.value.trim()) {
|
||||
error.value = i18n.global.t('apiStats.enterApiKey')
|
||||
error.value = '请输入 API Key'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,18 +125,14 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 更新 URL
|
||||
updateURL()
|
||||
} else {
|
||||
throw new Error(
|
||||
statsResult.message || i18n.global.t('common.errors.requestFailed', { status: 500 })
|
||||
)
|
||||
throw new Error(statsResult.message || '查询失败')
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
idResult.message || i18n.global.t('common.errors.requestFailed', { status: 500 })
|
||||
)
|
||||
throw new Error(idResult.message || '获取 API Key ID 失败')
|
||||
}
|
||||
} catch (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
|
||||
modelStats.value = []
|
||||
apiId.value = null
|
||||
@@ -214,7 +209,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
} else {
|
||||
throw new Error(result.message || i18n.global.t('apiStats.errors.loadModelStatsFailed'))
|
||||
throw new Error(result.message || '加载模型统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load model stats error:', err)
|
||||
@@ -271,13 +266,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 清除错误信息
|
||||
error.value = ''
|
||||
} else {
|
||||
throw new Error(
|
||||
result.message || i18n.global.t('common.errors.requestFailed', { status: 500 })
|
||||
)
|
||||
throw new Error(result.message || '查询失败')
|
||||
}
|
||||
} catch (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
|
||||
modelStats.value = []
|
||||
} finally {
|
||||
@@ -337,7 +330,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
async function queryBatchStats() {
|
||||
const keys = parseApiKeys()
|
||||
if (keys.length === 0) {
|
||||
error.value = i18n.global.t('apiStats.errors.enterAtLeastOneKey')
|
||||
error.value = '请输入至少一个有效的 API Key'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -367,7 +360,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
})
|
||||
|
||||
if (validIds.length === 0) {
|
||||
throw new Error(i18n.global.t('common.errors.allApiKeysInvalid'))
|
||||
throw new Error('所有 API Key 都无效')
|
||||
}
|
||||
|
||||
apiIds.value = validIds
|
||||
@@ -391,11 +384,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 更新 URL
|
||||
updateBatchURL()
|
||||
} else {
|
||||
throw new Error(batchResult.message || i18n.global.t('apiStats.errors.batchQueryFailed'))
|
||||
throw new Error(batchResult.message || '批量查询失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch query error:', err)
|
||||
error.value = err.message || i18n.global.t('apiStats.errors.batchQueryFailed')
|
||||
error.value = err.message || '批量查询统计数据失败'
|
||||
aggregatedStats.value = null
|
||||
individualStats.value = []
|
||||
} finally {
|
||||
@@ -415,7 +408,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
} else {
|
||||
throw new Error(result.message || i18n.global.t('apiStats.errors.batchModelStatsFailed'))
|
||||
throw new Error(result.message || '加载批量模型统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load batch model stats error:', err)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '@/router'
|
||||
import i18n from '@/i18n'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -40,10 +39,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
await router.push('/dashboard')
|
||||
} else {
|
||||
loginError.value = result.message || i18n.global.t('login.loginFailed')
|
||||
loginError.value = result.message || '登录失败'
|
||||
}
|
||||
} catch (error) {
|
||||
loginError.value = error.message || i18n.global.t('login.loginFailedCheck')
|
||||
loginError.value = error.message || '登录失败,请检查用户名和密码'
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
@@ -103,11 +102,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// 设置页面标题
|
||||
if (result.data.siteName) {
|
||||
document.title = `${result.data.siteName} - ${i18n.global.t('header.adminPanel')}`
|
||||
document.title = `${result.data.siteName} - 管理后台`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(i18n.global.t('common.errors.loadOemSettingsFailed'), error)
|
||||
console.error('加载OEM设置失败:', error)
|
||||
} finally {
|
||||
oemLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiClient } from '@/config/api'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export const useClientsStore = defineStore('clients', {
|
||||
state: () => ({
|
||||
@@ -25,13 +24,13 @@ export const useClientsStore = defineStore('clients', {
|
||||
if (response.success) {
|
||||
this.supportedClients = response.data || []
|
||||
} else {
|
||||
this.error = response.message || i18n.global.t('common.errors.loadSupportedClientsFailed')
|
||||
this.error = response.message || '加载支持的客户端失败'
|
||||
console.error('Failed to load supported clients:', this.error)
|
||||
}
|
||||
|
||||
return this.supportedClients
|
||||
} catch (error) {
|
||||
this.error = error.message || i18n.global.t('common.errors.loadSupportedClientsFailed')
|
||||
this.error = error.message || '加载支持的客户端失败'
|
||||
console.error('Error loading supported clients:', error)
|
||||
return []
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// 状态
|
||||
@@ -42,7 +41,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
realtimeTPM: 0,
|
||||
metricsWindow: 5,
|
||||
isHistoricalMetrics: false,
|
||||
systemStatus: i18n.global.t('system.status.normal'),
|
||||
systemStatus: '正常',
|
||||
uptime: 0,
|
||||
systemTimezone: 8 // 默认 UTC+8
|
||||
})
|
||||
@@ -69,9 +68,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
customEnd: '',
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: i18n.global.t('dashboard.today'), days: 1 },
|
||||
{ value: '7days', label: i18n.global.t('dashboard.last7Days'), days: 7 },
|
||||
{ value: '30days', label: i18n.global.t('dashboard.last30Days'), days: 30 }
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -90,11 +89,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return i18n.global.t('dashboard.uptimeFormat.daysHours', { days, hours })
|
||||
return `${days}天 ${hours}小时`
|
||||
} else if (hours > 0) {
|
||||
return i18n.global.t('dashboard.uptimeFormat.hoursMinutes', { hours, minutes })
|
||||
return `${hours}小时 ${minutes}分钟`
|
||||
} else {
|
||||
return i18n.global.t('dashboard.uptimeFormat.minutes', { minutes })
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -199,9 +198,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
realtimeTPM: realtimeMetrics.tpm || 0,
|
||||
metricsWindow: realtimeMetrics.windowMinutes || 5,
|
||||
isHistoricalMetrics: realtimeMetrics.isHistorical || false,
|
||||
systemStatus: systemHealth.redisConnected
|
||||
? i18n.global.t('system.status.normal')
|
||||
: i18n.global.t('system.status.abnormal'),
|
||||
systemStatus: systemHealth.redisConnected ? '正常' : '异常',
|
||||
uptime: systemHealth.uptime || 0,
|
||||
systemTimezone: dashboardResponse.data.systemTimezone || 8
|
||||
}
|
||||
@@ -221,7 +218,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(i18n.global.t('common.errors.loadDashboardFailed'), error)
|
||||
console.error('加载仪表板数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -309,7 +306,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
trendData.value = response.data
|
||||
}
|
||||
} 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
|
||||
}
|
||||
} 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) {
|
||||
console.error(i18n.global.t('common.errors.loadApiKeysTrendFailed'), error)
|
||||
console.error('加载API Keys趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,14 +636,14 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// 小时粒度:限制 24 小时
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
if (hoursDiff > 24) {
|
||||
showToast(i18n.global.t('dashboard.errors.rangeTooLongHour'), 'warning')
|
||||
showToast('小时粒度下日期范围不能超过24小时', 'warning')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 天粒度:限制 31 天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 31) {
|
||||
showToast(i18n.global.t('dashboard.errors.rangeTooLongDay'), 'warning')
|
||||
showToast('日期范围不能超过 31 天', 'warning')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -665,13 +662,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// 根据粒度更新预设选项
|
||||
if (granularity === 'hour') {
|
||||
dateFilter.value.presetOptions = [
|
||||
{
|
||||
value: 'last24h',
|
||||
label: i18n.global.t('dashboard.usageTrend.periodOptions.last24Hours'),
|
||||
hours: 24
|
||||
},
|
||||
{ value: 'yesterday', label: i18n.global.t('dashboard.yesterday'), hours: 24 },
|
||||
{ value: 'dayBefore', label: i18n.global.t('dashboard.dayBefore'), hours: 24 }
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
|
||||
// 检查当前自定义日期范围是否超过24小时
|
||||
@@ -684,7 +677,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const end = new Date(dateFilter.value.customRange[1])
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
if (hoursDiff > 24) {
|
||||
showToast(i18n.global.t('dashboard.errors.rangeTooLongHourSwitched'), 'warning')
|
||||
showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning')
|
||||
setDateFilterPreset('last24h')
|
||||
return
|
||||
}
|
||||
@@ -698,9 +691,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
} else {
|
||||
// 天粒度
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'today', label: i18n.global.t('dashboard.today'), days: 1 },
|
||||
{ value: '7days', label: i18n.global.t('dashboard.last7Days'), days: 7 },
|
||||
{ value: '30days', label: i18n.global.t('dashboard.last30Days'), days: 30 }
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 状态
|
||||
@@ -78,7 +77,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const applyOemSettings = () => {
|
||||
// 更新页面标题
|
||||
if (oemSettings.value.siteName) {
|
||||
document.title = `${oemSettings.value.siteName} - ${i18n.global.t('header.adminPanel')}`
|
||||
document.title = `${oemSettings.value.siteName} - 管理后台`
|
||||
}
|
||||
|
||||
// 更新favicon
|
||||
@@ -95,9 +94,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const localeMap = { 'zh-cn': 'zh-CN', 'zh-tw': 'zh-TW', en: 'en-US' }
|
||||
const currentLocale = localeMap[i18n.global.locale.value] || 'en-US'
|
||||
return new Date(dateString).toLocaleString(currentLocale, {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -113,13 +110,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
// 检查文件大小 (350KB)
|
||||
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']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push(i18n.global.t('settings.validation.iconTypeNotSupported'))
|
||||
errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios'
|
||||
import i18n from '@/i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const API_BASE = '/users'
|
||||
@@ -41,7 +40,7 @@ export const useUserStore = defineStore('user', {
|
||||
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.data.message || i18n.global.t('user.login.loginFailed'))
|
||||
throw new Error(response.data.message || 'Login failed')
|
||||
}
|
||||
} catch (error) {
|
||||
this.clearAuth()
|
||||
@@ -116,9 +115,7 @@ export const useUserStore = defineStore('user', {
|
||||
this.clearAuth()
|
||||
// If it's a disabled account error, throw a specific error
|
||||
if (error.response?.status === 403) {
|
||||
throw new Error(
|
||||
error.response.data?.message || i18n.global.t('user.login.accountDisabled')
|
||||
)
|
||||
throw new Error(error.response.data?.message || 'Your account has been disabled')
|
||||
}
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -37,9 +37,7 @@ export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
.replace('ss', seconds)
|
||||
}
|
||||
|
||||
// 相对时间格式化(使用 i18n)
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// 相对时间格式化
|
||||
export function formatRelativeTime(date) {
|
||||
if (!date) return ''
|
||||
|
||||
@@ -52,13 +50,13 @@ export function formatRelativeTime(date) {
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) {
|
||||
return i18n.global.t('common.time.daysAgo', { days: diffDays })
|
||||
return `${diffDays}天前`
|
||||
} else if (diffHours > 0) {
|
||||
return i18n.global.t('common.time.hoursAgo', { hours: diffHours })
|
||||
return `${diffHours}小时前`
|
||||
} else if (diffMins > 0) {
|
||||
return i18n.global.t('common.time.minutesAgo', { minutes: diffMins })
|
||||
return `${diffMins}分钟前`
|
||||
} else {
|
||||
return i18n.global.t('common.time.justNow')
|
||||
return '刚刚'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<div class="mb-4 flex flex-col gap-4 sm:mb-6">
|
||||
<div>
|
||||
<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>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
{{ t('accounts.description') }}
|
||||
管理您的 Claude、Gemini、OpenAI、Azure OpenAI、OpenAI-Responses 与 CCR 账户及代理配置
|
||||
</p>
|
||||
</div>
|
||||
<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-color="text-indigo-500"
|
||||
:options="sortOptions"
|
||||
:placeholder="t('accounts.sortBy')"
|
||||
placeholder="选择排序"
|
||||
@change="sortAccounts()"
|
||||
/>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
icon="fa-server"
|
||||
icon-color="text-blue-500"
|
||||
:options="platformOptions"
|
||||
:placeholder="t('accounts.selectPlatform')"
|
||||
placeholder="选择平台"
|
||||
@change="filterByPlatform"
|
||||
/>
|
||||
</div>
|
||||
@@ -53,14 +53,18 @@
|
||||
icon="fa-layer-group"
|
||||
icon-color="text-purple-500"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('accounts.selectGroup')"
|
||||
placeholder="选择分组"
|
||||
@change="filterByGroup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip :content="t('accounts.refreshTooltip')" effect="dark" placement="bottom">
|
||||
<el-tooltip
|
||||
content="刷新数据 (Ctrl/⌘+点击强制刷新所有缓存)"
|
||||
effect="dark"
|
||||
placement="bottom"
|
||||
>
|
||||
<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"
|
||||
:disabled="accountsLoading"
|
||||
@@ -77,7 +81,7 @@
|
||||
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'
|
||||
]"
|
||||
/>
|
||||
<span class="relative">{{ t('accounts.refresh') }}</span>
|
||||
<span class="relative">刷新</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -89,14 +93,14 @@
|
||||
@click.stop="openCreateAccountModal"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>{{ t('accounts.addAccount') }}</span>
|
||||
<span>添加账户</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accountsLoading" class="py-12 text-center">
|
||||
<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 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" />
|
||||
</div>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400">{{ t('accounts.noAccounts') }}</p>
|
||||
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ t('accounts.noAccountsHint') }}
|
||||
</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>
|
||||
</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"
|
||||
@click="sortAccounts('name')"
|
||||
>
|
||||
{{ t('accounts.name') }}
|
||||
名称
|
||||
<i
|
||||
v-if="accountsSortBy === 'name'"
|
||||
: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"
|
||||
@click="sortAccounts('platform')"
|
||||
>
|
||||
{{ t('accounts.platformType') }}
|
||||
平台/类型
|
||||
<i
|
||||
v-if="accountsSortBy === 'platform'"
|
||||
: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"
|
||||
@click="sortAccounts('status')"
|
||||
>
|
||||
{{ t('accounts.status') }}
|
||||
状态
|
||||
<i
|
||||
v-if="accountsSortBy === 'status'"
|
||||
: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"
|
||||
@click="sortAccounts('priority')"
|
||||
>
|
||||
{{ t('accounts.priority') }}
|
||||
优先级
|
||||
<i
|
||||
v-if="accountsSortBy === 'priority'"
|
||||
:class="[
|
||||
@@ -179,40 +181,40 @@
|
||||
<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"
|
||||
>
|
||||
{{ t('accounts.proxy') }}
|
||||
代理
|
||||
</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"
|
||||
>
|
||||
{{ t('accounts.dailyUsage') }}
|
||||
今日使用
|
||||
</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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ t('accounts.sessionWindow') }}</span>
|
||||
<span>会话窗口</span>
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<div>{{ t('accounts.sessionWindowTooltip.title') }}</div>
|
||||
<div>会话窗口进度表示5小时窗口的时间进度</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
|
||||
></div>
|
||||
<span>{{ t('accounts.sessionWindowTooltip.normal') }}</span>
|
||||
<span>正常:请求正常处理</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
|
||||
></div>
|
||||
<span>{{ t('accounts.sessionWindowTooltip.warning') }}</span>
|
||||
<span>警告:接近限制</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
|
||||
></div>
|
||||
<span>{{ t('accounts.sessionWindowTooltip.rejected') }}</span>
|
||||
<span>拒绝:达到速率限制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,12 +228,12 @@
|
||||
<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"
|
||||
>
|
||||
{{ t('accounts.lastUsed') }}
|
||||
最后使用
|
||||
</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"
|
||||
>
|
||||
{{ t('accounts.actions') }}
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -256,19 +258,19 @@
|
||||
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"
|
||||
>
|
||||
<i class="fas fa-lock mr-1" />{{ t('accounts.dedicated') }}
|
||||
<i class="fas fa-lock mr-1" />专属
|
||||
</span>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
<i class="fas fa-layer-group mr-1" />{{ t('accounts.groupScheduling') }}
|
||||
<i class="fas fa-layer-group mr-1" />分组调度
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
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>
|
||||
</div>
|
||||
<!-- 显示所有分组 - 换行显示 -->
|
||||
@@ -280,7 +282,7 @@
|
||||
v-for="group in account.groupInfos"
|
||||
: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"
|
||||
:title="t('accounts.belongsToGroup', { name: group.name })"
|
||||
:title="`所属分组: ${group.name}`"
|
||||
>
|
||||
<i class="fas fa-folder mr-1" />{{ group.name }}
|
||||
</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"
|
||||
>
|
||||
<i class="fas fa-question text-xs text-gray-700" />
|
||||
<span class="text-xs font-semibold text-gray-800">{{
|
||||
t('accounts.unknown')
|
||||
}}</span>
|
||||
<span class="text-xs font-semibold text-gray-800">未知</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -426,14 +426,14 @@
|
||||
/>
|
||||
{{
|
||||
account.status === 'blocked'
|
||||
? t('accounts.blocked')
|
||||
? '已封锁'
|
||||
: account.status === 'unauthorized'
|
||||
? t('accounts.abnormal')
|
||||
? '异常'
|
||||
: account.status === 'temp_error'
|
||||
? t('accounts.tempError')
|
||||
? '临时异常'
|
||||
: account.isActive
|
||||
? t('accounts.normal')
|
||||
: t('accounts.abnormal')
|
||||
? '正常'
|
||||
: '异常'
|
||||
}}
|
||||
</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"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
{{ t('accounts.rateLimited') }}
|
||||
限流中
|
||||
<span
|
||||
v-if="
|
||||
account.rateLimitStatus &&
|
||||
typeof account.rateLimitStatus === 'object' &&
|
||||
account.rateLimitStatus.minutesRemaining > 0
|
||||
"
|
||||
>({{
|
||||
t('accounts.rateLimitTime', {
|
||||
time: formatRateLimitTime(account.rateLimitStatus.minutesRemaining)
|
||||
})
|
||||
}})</span
|
||||
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</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"
|
||||
>
|
||||
<i class="fas fa-pause-circle mr-1" />
|
||||
{{ t('accounts.notSchedulable') }}
|
||||
不可调度
|
||||
<el-tooltip
|
||||
v-if="getSchedulableReason(account)"
|
||||
:content="getSchedulableReason(account)"
|
||||
@@ -484,7 +480,7 @@
|
||||
v-if="account.accountType === 'dedicated'"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('accounts.bound', { count: account.boundApiKeysCount || 0 }) }}
|
||||
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -524,14 +520,14 @@
|
||||
>
|
||||
{{ formatProxyDisplay(account.proxy) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-400">{{ t('accounts.noProxy') }}</div>
|
||||
<div v-else class="text-gray-400">无代理</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<div v-if="account.usage && account.usage.daily" class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<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 class="flex items-center gap-2">
|
||||
@@ -550,10 +546,10 @@
|
||||
v-if="account.usage.averages && account.usage.averages.rpm > 0"
|
||||
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 v-else class="text-xs text-gray-400">{{ t('accounts.noData') }}</div>
|
||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div
|
||||
@@ -613,11 +609,7 @@
|
||||
v-if="account.sessionWindow.remainingTime > 0"
|
||||
class="font-medium text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
{{
|
||||
t('accounts.remaining', {
|
||||
time: formatRemainingTime(account.sessionWindow.remainingTime)
|
||||
})
|
||||
}}
|
||||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -625,9 +617,7 @@
|
||||
<div v-else-if="account.platform === 'claude-console'" class="space-y-2">
|
||||
<div v-if="Number(account.dailyQuota) > 0">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-300">{{
|
||||
t('accounts.quotaProgress')
|
||||
}}</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">额度进度</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ getQuotaUsagePercent(account).toFixed(1) }}%
|
||||
</span>
|
||||
@@ -651,10 +641,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('accounts.remainingQuota', { amount: formatRemainingQuota(account) }) }}
|
||||
<span class="ml-2 text-gray-400">{{
|
||||
t('accounts.reset', { time: account.quotaResetTime || '00:00' })
|
||||
}}</span>
|
||||
剩余 ${{ formatRemainingQuota(account) }}
|
||||
<span class="ml-2 text-gray-400"
|
||||
>重置 {{ account.quotaResetTime || '00:00' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400">
|
||||
@@ -692,15 +682,11 @@
|
||||
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
|
||||
]"
|
||||
:disabled="account.isResetting"
|
||||
:title="
|
||||
account.isResetting
|
||||
? t('accounts.resetting')
|
||||
: t('accounts.resetStatusTooltip')
|
||||
"
|
||||
:title="account.isResetting ? '重置中...' : '重置所有异常状态'"
|
||||
@click="resetAccountStatus(account)"
|
||||
>
|
||||
<i :class="['fas fa-redo', account.isResetting ? 'animate-spin' : '']" />
|
||||
<span class="ml-1">{{ t('accounts.resetStatus') }}</span>
|
||||
<span class="ml-1">重置状态</span>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
@@ -712,33 +698,27 @@
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]"
|
||||
:disabled="account.isTogglingSchedulable"
|
||||
:title="
|
||||
account.schedulable
|
||||
? t('accounts.disableTooltip')
|
||||
: t('accounts.enableTooltip')
|
||||
"
|
||||
:title="account.schedulable ? '点击禁用调度' : '点击启用调度'"
|
||||
@click="toggleSchedulable(account)"
|
||||
>
|
||||
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" />
|
||||
<span class="ml-1">{{
|
||||
account.schedulable ? t('accounts.scheduling') : t('accounts.disabled')
|
||||
}}</span>
|
||||
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
||||
</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"
|
||||
:title="t('accounts.editTooltip')"
|
||||
:title="'编辑账户'"
|
||||
@click="editAccount(account)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
<span class="ml-1">{{ t('accounts.edit') }}</span>
|
||||
<span class="ml-1">编辑</span>
|
||||
</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"
|
||||
:title="t('accounts.deleteTooltip')"
|
||||
:title="'删除账户'"
|
||||
@click="deleteAccount(account)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
<span class="ml-1">{{ t('accounts.delete') }}</span>
|
||||
<span class="ml-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -819,14 +799,12 @@
|
||||
<!-- 使用统计 -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('accounts.dailyUsageLabel') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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">
|
||||
{{ account.usage?.daily?.requests || 0 }} {{ t('accounts.requests') }}
|
||||
{{ account.usage?.daily?.requests || 0 }} 次
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -844,9 +822,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('accounts.sessionWindowLabel') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p>
|
||||
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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 gap-1">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">{{
|
||||
t('accounts.sessionWindowLabel')
|
||||
}}</span>
|
||||
<el-tooltip :content="t('accounts.sessionWindowTooltipMobile')" placement="top">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
|
||||
<el-tooltip
|
||||
content="会话窗口进度不代表使用量,仅表示距离下一个5小时窗口的剩余时间"
|
||||
placement="top"
|
||||
>
|
||||
<i
|
||||
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"
|
||||
class="font-medium text-indigo-600"
|
||||
>
|
||||
{{
|
||||
t('accounts.remaining', {
|
||||
time: formatRemainingTime(account.sessionWindow.remainingTime)
|
||||
})
|
||||
}}
|
||||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-500"> {{ t('accounts.ended') }} </span>
|
||||
<span v-else class="text-gray-500"> 已结束 </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最后使用时间 -->
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{
|
||||
t('accounts.lastUsedLabel')
|
||||
}}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">最后使用</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">
|
||||
{{
|
||||
account.lastUsedAt
|
||||
? formatRelativeTime(account.lastUsedAt)
|
||||
: t('accounts.neverUsed')
|
||||
}}
|
||||
{{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -942,7 +909,7 @@
|
||||
v-if="account.proxyConfig && account.proxyConfig.type !== 'none'"
|
||||
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">
|
||||
{{ account.proxyConfig.type.toUpperCase() }}
|
||||
</span>
|
||||
@@ -950,9 +917,7 @@
|
||||
|
||||
<!-- 调度优先级 -->
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{
|
||||
t('accounts.priorityLabel')
|
||||
}}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">优先级</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ account.priority || 50 }}
|
||||
</span>
|
||||
@@ -972,7 +937,7 @@
|
||||
@click="toggleSchedulable(account)"
|
||||
>
|
||||
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
|
||||
{{ account.schedulable ? t('accounts.pause') : t('accounts.enable') }}
|
||||
{{ account.schedulable ? '暂停' : '启用' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -980,7 +945,7 @@
|
||||
@click="editAccount(account)"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" />
|
||||
{{ t('accounts.edit') }}
|
||||
编辑
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -1036,7 +1001,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -1045,9 +1009,6 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 使用确认弹窗
|
||||
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 sortOptions = computed(() => [
|
||||
{ value: 'name', label: t('accounts.sortByName'), icon: 'fa-font' },
|
||||
{ value: 'dailyTokens', label: t('accounts.sortByDailyTokens'), icon: 'fa-coins' },
|
||||
{ value: 'dailyRequests', label: t('accounts.sortByDailyRequests'), icon: 'fa-chart-line' },
|
||||
{ value: 'totalTokens', label: t('accounts.sortByTotalTokens'), icon: 'fa-database' },
|
||||
{ value: 'lastUsed', label: t('accounts.sortByLastUsed'), icon: 'fa-clock' }
|
||||
const sortOptions = ref([
|
||||
{ value: 'name', label: '按名称排序', icon: 'fa-font' },
|
||||
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
|
||||
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
|
||||
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
|
||||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }
|
||||
])
|
||||
|
||||
const platformOptions = computed(() => [
|
||||
{ value: 'all', label: t('accounts.allPlatforms'), icon: 'fa-globe' },
|
||||
{ value: 'claude', label: t('accounts.claudePlatform'), icon: 'fa-brain' },
|
||||
{ value: 'claude-console', label: t('accounts.claudeConsolePlatform'), icon: 'fa-terminal' },
|
||||
{ value: 'gemini', label: t('accounts.geminiPlatform'), icon: 'fa-google' },
|
||||
{ value: 'openai', label: t('accounts.openaiPlatform'), icon: 'fa-openai' },
|
||||
{ value: 'azure_openai', label: t('accounts.azureOpenaiPlatform'), icon: 'fab fa-microsoft' },
|
||||
{ value: 'bedrock', label: t('accounts.bedrockPlatform'), icon: 'fab fa-aws' },
|
||||
{ value: 'openai-responses', label: t('accounts.openaiResponsesPlatform'), icon: 'fa-server' },
|
||||
{ value: 'ccr', label: t('accounts.ccrPlatform'), icon: 'fa-code-branch' }
|
||||
const platformOptions = ref([
|
||||
{ value: 'all', label: '所有平台', icon: 'fa-globe' },
|
||||
{ value: 'claude', label: 'Claude', icon: 'fa-brain' },
|
||||
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||||
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
|
||||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
|
||||
])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: 'all', label: t('accounts.allAccounts'), icon: 'fa-globe' },
|
||||
{ value: 'ungrouped', label: t('accounts.ungroupedAccounts'), icon: 'fa-user' }
|
||||
{ value: 'all', label: '所有账户', icon: 'fa-globe' },
|
||||
{ value: 'ungrouped', label: '未分组账户', icon: 'fa-user' }
|
||||
]
|
||||
accountGroups.value.forEach((group) => {
|
||||
options.push({
|
||||
@@ -1432,7 +1393,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
|
||||
accounts.value = filteredAccounts
|
||||
} catch (error) {
|
||||
showToast(t('accounts.loadAccountsFailed'), 'error')
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -1464,16 +1425,16 @@ const formatNumber = (num) => {
|
||||
|
||||
// 格式化最后使用时间
|
||||
const formatLastUsed = (dateString) => {
|
||||
if (!dateString) return t('accounts.neverUsed')
|
||||
if (!dateString) return '从未使用'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return t('accounts.justNow')
|
||||
if (diff < 3600000) return t('accounts.minutesAgo', { minutes: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('accounts.hoursAgo', { hours: Math.floor(diff / 3600000) })
|
||||
if (diff < 604800000) return t('accounts.daysAgo', { days: Math.floor(diff / 86400000) })
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
@@ -1570,15 +1531,15 @@ const formatSessionWindow = (windowStart, windowEnd) => {
|
||||
|
||||
// 格式化剩余时间
|
||||
const formatRemainingTime = (minutes) => {
|
||||
if (!minutes || minutes <= 0) return t('accounts.ended')
|
||||
if (!minutes || minutes <= 0) return '已结束'
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
|
||||
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) {
|
||||
// 超过1天,显示天数和小时
|
||||
if (hours > 0) {
|
||||
return t('accounts.daysAndHours', { days, hours })
|
||||
return `${days}天${hours}小时`
|
||||
}
|
||||
return t('accounts.daysOnly', { days })
|
||||
return `${days}天`
|
||||
} else if (hours > 0) {
|
||||
// 超过1小时但不到1天,显示小时和分钟
|
||||
if (mins > 0) {
|
||||
return t('accounts.hoursAndMinutes', { hours, minutes: mins })
|
||||
return `${hours}小时${mins}分钟`
|
||||
}
|
||||
return t('accounts.hoursOnly', { hours })
|
||||
return `${hours}小时`
|
||||
} else {
|
||||
// 不到1小时,只显示分钟
|
||||
return t('accounts.minutesOnly', { minutes: mins })
|
||||
return `${mins}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1645,15 +1606,18 @@ const deleteAccount = async (account) => {
|
||||
).length
|
||||
|
||||
if (boundKeysCount > 0) {
|
||||
showToast(t('accounts.cannotDeleteBoundAccount', { count: boundKeysCount }), 'error')
|
||||
showToast(
|
||||
`无法删除此账号,有 ${boundKeysCount} 个API Key绑定到此账号,请先解绑所有API Key`,
|
||||
'error'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
t('accounts.deleteAccountTitle'),
|
||||
t('accounts.deleteAccountMessage', { name: account.name }),
|
||||
t('accounts.deleteAccountButton'),
|
||||
t('accounts.deleteAccountCancel')
|
||||
'删除账户',
|
||||
`确定要删除账户 "${account.name}" 吗?\n\n此操作不可恢复。`,
|
||||
'删除',
|
||||
'取消'
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -1681,15 +1645,15 @@ const deleteAccount = async (account) => {
|
||||
const data = await apiClient.delete(endpoint)
|
||||
|
||||
if (data.success) {
|
||||
showToast(t('accounts.accountDeleted'), 'success')
|
||||
showToast('账户已删除', 'success')
|
||||
// 清空分组成员缓存,因为账户可能从分组中移除
|
||||
groupMembersLoaded.value = false
|
||||
loadAccounts()
|
||||
} else {
|
||||
showToast(data.message || t('accounts.deleteFailed'), 'error')
|
||||
showToast(data.message || '删除失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('accounts.deleteFailed'), 'error')
|
||||
showToast('删除失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,13 +1664,13 @@ const resetAccountStatus = async (account) => {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
t('accounts.resetStatusConfirmTitle'),
|
||||
t('accounts.resetStatusConfirmMessage'),
|
||||
t('accounts.resetStatusConfirmButton'),
|
||||
t('accounts.resetStatusCancelButton')
|
||||
'重置账户状态',
|
||||
'确定要重置此账户的所有异常状态吗?这将清除限流状态、401错误计数等所有异常标记。',
|
||||
'确定重置',
|
||||
'取消'
|
||||
)
|
||||
} else {
|
||||
confirmed = confirm(t('accounts.resetStatusConfirmMessage'))
|
||||
confirmed = confirm('确定要重置此账户的所有异常状态吗?')
|
||||
}
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -1727,7 +1691,7 @@ const resetAccountStatus = async (account) => {
|
||||
} else if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
|
||||
} else {
|
||||
showToast(t('accounts.unsupportedAccountTypeReset'), 'error')
|
||||
showToast('不支持的账户类型', 'error')
|
||||
account.isResetting = false
|
||||
return
|
||||
}
|
||||
@@ -1735,14 +1699,14 @@ const resetAccountStatus = async (account) => {
|
||||
const data = await apiClient.post(endpoint)
|
||||
|
||||
if (data.success) {
|
||||
showToast(t('accounts.statusResetSuccess'), 'success')
|
||||
showToast('账户状态已重置', 'success')
|
||||
// 强制刷新,绕过前端缓存,确保最终一致性
|
||||
loadAccounts(true)
|
||||
} else {
|
||||
showToast(data.message || t('accounts.statusResetFailed'), 'error')
|
||||
showToast(data.message || '状态重置失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('accounts.statusResetFailed'), 'error')
|
||||
showToast('状态重置失败', 'error')
|
||||
} finally {
|
||||
account.isResetting = false
|
||||
}
|
||||
@@ -1773,7 +1737,7 @@ const toggleSchedulable = async (account) => {
|
||||
} else if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||||
} else {
|
||||
showToast(t('accounts.unsupportedAccountType'), 'warning')
|
||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1781,15 +1745,12 @@ const toggleSchedulable = async (account) => {
|
||||
|
||||
if (data.success) {
|
||||
account.schedulable = data.schedulable
|
||||
showToast(
|
||||
data.schedulable ? t('accounts.enabledScheduling') : t('accounts.disabledScheduling'),
|
||||
'success'
|
||||
)
|
||||
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
|
||||
} else {
|
||||
showToast(data.message || t('accounts.operationFailed'), 'error')
|
||||
showToast(data.message || '操作失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('accounts.schedulingToggleFailed'), 'error')
|
||||
showToast('切换调度状态失败', 'error')
|
||||
} finally {
|
||||
account.isTogglingSchedulable = false
|
||||
}
|
||||
@@ -1798,7 +1759,7 @@ const toggleSchedulable = async (account) => {
|
||||
// 处理创建成功
|
||||
const handleCreateSuccess = () => {
|
||||
showCreateAccountModal.value = false
|
||||
showToast(t('accounts.accountCreateSuccess'), 'success')
|
||||
showToast('账户创建成功', 'success')
|
||||
// 清空缓存,因为可能涉及分组关系变化
|
||||
clearCache()
|
||||
loadAccounts()
|
||||
@@ -1807,7 +1768,7 @@ const handleCreateSuccess = () => {
|
||||
// 处理编辑成功
|
||||
const handleEditSuccess = () => {
|
||||
showEditAccountModal.value = false
|
||||
showToast(t('accounts.accountUpdateSuccess'), 'success')
|
||||
showToast('账户更新成功', 'success')
|
||||
// 清空分组成员缓存,因为账户类型和分组可能发生变化
|
||||
groupMembersLoaded.value = false
|
||||
loadAccounts()
|
||||
@@ -1849,11 +1810,11 @@ const getClaudeAccountType = (account) => {
|
||||
|
||||
// 根据 has_claude_max 和 has_claude_pro 判断
|
||||
if (info.hasClaudeMax === true) {
|
||||
return t('accounts.claudeMax')
|
||||
return 'Claude Max'
|
||||
} else if (info.hasClaudePro === true) {
|
||||
return t('accounts.claudePro')
|
||||
return 'Claude Pro'
|
||||
} else {
|
||||
return t('accounts.claudeFree')
|
||||
return 'Claude Free'
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,返回默认值
|
||||
@@ -1872,13 +1833,13 @@ const getSchedulableReason = (account) => {
|
||||
// Claude Console 账户的错误状态
|
||||
if (account.platform === 'claude-console') {
|
||||
if (account.status === 'unauthorized') {
|
||||
return t('accounts.invalidApiKey')
|
||||
return 'API Key无效或已过期(401错误)'
|
||||
}
|
||||
if (account.overloadStatus === 'overloaded') {
|
||||
return t('accounts.serviceOverload')
|
||||
return '服务过载(529错误)'
|
||||
}
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
return t('accounts.rateLimitTriggered')
|
||||
return '触发限流(429错误)'
|
||||
}
|
||||
if (account.status === 'blocked' && account.errorMessage) {
|
||||
return account.errorMessage
|
||||
@@ -1888,7 +1849,7 @@ const getSchedulableReason = (account) => {
|
||||
// Claude 官方账户的错误状态
|
||||
if (account.platform === 'claude') {
|
||||
if (account.status === 'unauthorized') {
|
||||
return t('accounts.authFailed')
|
||||
return '认证失败(401错误)'
|
||||
}
|
||||
if (account.status === 'temp_error' && account.errorMessage) {
|
||||
return account.errorMessage
|
||||
@@ -1897,7 +1858,7 @@ const getSchedulableReason = (account) => {
|
||||
return account.errorMessage
|
||||
}
|
||||
if (account.isRateLimited) {
|
||||
return t('accounts.rateLimitTriggered')
|
||||
return '触发限流(429错误)'
|
||||
}
|
||||
// 自动停止调度的原因
|
||||
if (account.stoppedReason) {
|
||||
@@ -1951,15 +1912,15 @@ const getSchedulableReason = (account) => {
|
||||
}
|
||||
|
||||
// 默认为手动停止
|
||||
return t('accounts.manualStop')
|
||||
return '手动停止调度'
|
||||
}
|
||||
|
||||
// 获取账户状态文本
|
||||
const getAccountStatusText = (account) => {
|
||||
// 检查是否被封锁
|
||||
if (account.status === 'blocked') return t('accounts.blocked')
|
||||
if (account.status === 'blocked') return '已封锁'
|
||||
// 检查是否未授权(401错误)
|
||||
if (account.status === 'unauthorized') return t('accounts.abnormal')
|
||||
if (account.status === 'unauthorized') return '异常'
|
||||
// 检查是否限流
|
||||
if (
|
||||
account.isRateLimited ||
|
||||
@@ -1967,15 +1928,15 @@ const getAccountStatusText = (account) => {
|
||||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||||
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
@@ -6,15 +6,10 @@
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:subtitle="currentTab === 'stats' ? t('apiStats.title') : t('apiStats.tutorialTitle')"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:title="oemSettings.siteName"
|
||||
/>
|
||||
<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">
|
||||
<ThemeToggle mode="dropdown" />
|
||||
@@ -33,9 +28,7 @@
|
||||
to="/user-login"
|
||||
>
|
||||
<i class="fas fa-user text-sm md:text-base" />
|
||||
<span class="text-xs font-semibold tracking-wide md:text-sm">{{
|
||||
t('apiStats.userLogin')
|
||||
}}</span>
|
||||
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
|
||||
</router-link>
|
||||
<!-- 管理后台按钮 -->
|
||||
<router-link
|
||||
@@ -44,9 +37,7 @@
|
||||
to="/dashboard"
|
||||
>
|
||||
<i class="fas fa-shield-alt text-sm md:text-base" />
|
||||
<span class="text-xs font-semibold tracking-wide md:text-sm">{{
|
||||
t('apiStats.adminPanel')
|
||||
}}</span>
|
||||
<span class="text-xs font-semibold tracking-wide md:text-sm">管理后台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,14 +54,14 @@
|
||||
@click="currentTab = 'stats'"
|
||||
>
|
||||
<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
|
||||
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
|
||||
@click="currentTab = 'tutorial'"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,9 +92,9 @@
|
||||
>
|
||||
<div class="flex items-center gap-2 md:gap-3">
|
||||
<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">{{
|
||||
t('apiStats.timeRange')
|
||||
}}</span>
|
||||
<span class="text-base font-medium text-gray-700 dark:text-gray-200 md:text-lg"
|
||||
>统计时间范围</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex w-full gap-2 md:w-auto">
|
||||
<button
|
||||
@@ -113,7 +104,7 @@
|
||||
@click="switchPeriod('daily')"
|
||||
>
|
||||
<i class="fas fa-calendar-day text-xs md:text-sm" />
|
||||
{{ t('apiStats.today') }}
|
||||
今日
|
||||
</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"
|
||||
@@ -122,7 +113,7 @@
|
||||
@click="switchPeriod('monthly')"
|
||||
>
|
||||
<i class="fas fa-calendar-alt text-xs md:text-sm" />
|
||||
{{ t('apiStats.thisMonth') }}
|
||||
本月
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +140,7 @@
|
||||
<!-- 教程内容 -->
|
||||
<div v-if="currentTab === 'tutorial'" class="tab-content">
|
||||
<div class="glass-strong rounded-3xl shadow-xl">
|
||||
<component :is="currentTutorialComponent" />
|
||||
<TutorialView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,28 +150,21 @@
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useLocaleStore } from '@/stores/locale'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
|
||||
import StatsOverview from '@/components/apistats/StatsOverview.vue'
|
||||
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
||||
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
||||
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||
import TutorialViewZhCn from './tutorials/TutorialView-zh-cn.vue'
|
||||
import TutorialViewZhTw from './tutorials/TutorialView-zh-tw.vue'
|
||||
import TutorialViewEn from './tutorials/TutorialView-en.vue'
|
||||
import TutorialView from './TutorialView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const localeStore = useLocaleStore()
|
||||
|
||||
// 当前标签页
|
||||
const currentTab = ref('stats')
|
||||
@@ -188,17 +172,6 @@ const currentTab = ref('stats')
|
||||
// 主题相关
|
||||
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 {
|
||||
apiKey,
|
||||
apiId,
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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 class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||
{{ dashboardData.totalApiKeys }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('dashboard.activeApiKeys') }}: {{ dashboardData.activeApiKeys || 0 }}
|
||||
活跃: {{ dashboardData.activeApiKeys || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<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-1">
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.serviceAccounts') }}
|
||||
服务账户
|
||||
</p>
|
||||
<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">
|
||||
@@ -42,12 +42,7 @@
|
||||
dashboardData.accountsByPlatform.claude.total > 0
|
||||
"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
:title="
|
||||
t('dashboard.claudeAccount', {
|
||||
total: dashboardData.accountsByPlatform.claude.total,
|
||||
normal: dashboardData.accountsByPlatform.claude.normal
|
||||
})
|
||||
"
|
||||
:title="`Claude: ${dashboardData.accountsByPlatform.claude.total} 个 (正常: ${dashboardData.accountsByPlatform.claude.normal})`"
|
||||
>
|
||||
<i class="fas fa-brain text-xs text-indigo-600" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
@@ -61,12 +56,7 @@
|
||||
dashboardData.accountsByPlatform['claude-console'].total > 0
|
||||
"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
:title="
|
||||
t('dashboard.consoleAccount', {
|
||||
total: dashboardData.accountsByPlatform['claude-console'].total,
|
||||
normal: dashboardData.accountsByPlatform['claude-console'].normal
|
||||
})
|
||||
"
|
||||
:title="`Console: ${dashboardData.accountsByPlatform['claude-console'].total} 个 (正常: ${dashboardData.accountsByPlatform['claude-console'].normal})`"
|
||||
>
|
||||
<i class="fas fa-terminal text-xs text-purple-600" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
@@ -80,12 +70,7 @@
|
||||
dashboardData.accountsByPlatform.gemini.total > 0
|
||||
"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
:title="
|
||||
t('dashboard.geminiAccount', {
|
||||
total: dashboardData.accountsByPlatform.gemini.total,
|
||||
normal: dashboardData.accountsByPlatform.gemini.normal
|
||||
})
|
||||
"
|
||||
:title="`Gemini: ${dashboardData.accountsByPlatform.gemini.total} 个 (正常: ${dashboardData.accountsByPlatform.gemini.normal})`"
|
||||
>
|
||||
<i class="fas fa-robot text-xs text-yellow-600" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
@@ -99,12 +84,7 @@
|
||||
dashboardData.accountsByPlatform.bedrock.total > 0
|
||||
"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
:title="
|
||||
t('dashboard.bedrockAccount', {
|
||||
total: dashboardData.accountsByPlatform.bedrock.total,
|
||||
normal: dashboardData.accountsByPlatform.bedrock.normal
|
||||
})
|
||||
"
|
||||
:title="`Bedrock: ${dashboardData.accountsByPlatform.bedrock.total} 个 (正常: ${dashboardData.accountsByPlatform.bedrock.normal})`"
|
||||
>
|
||||
<i class="fab fa-aws text-xs text-orange-600" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
@@ -118,12 +98,7 @@
|
||||
dashboardData.accountsByPlatform.openai.total > 0
|
||||
"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
:title="
|
||||
t('dashboard.openaiAccount', {
|
||||
total: dashboardData.accountsByPlatform.openai.total,
|
||||
normal: dashboardData.accountsByPlatform.openai.normal
|
||||
})
|
||||
"
|
||||
:title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`"
|
||||
>
|
||||
<i class="fas fa-openai text-xs text-gray-100" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
@@ -137,12 +112,7 @@
|
||||
dashboardData.accountsByPlatform.azure_openai.total > 0
|
||||
"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
:title="
|
||||
t('dashboard.azureOpenaiAccount', {
|
||||
total: dashboardData.accountsByPlatform.azure_openai.total,
|
||||
normal: dashboardData.accountsByPlatform.azure_openai.normal
|
||||
})
|
||||
"
|
||||
:title="`Azure OpenAI: ${dashboardData.accountsByPlatform.azure_openai.total} 个 (正常: ${dashboardData.accountsByPlatform.azure_openai.normal})`"
|
||||
>
|
||||
<i class="fab fa-microsoft text-xs text-blue-600" />
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||
@@ -152,18 +122,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
| {{ t('dashboard.abnormalAccounts') }}: {{ dashboardData.abnormalAccounts }}
|
||||
| 异常: {{ dashboardData.abnormalAccounts }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dashboardData.pausedAccounts > 0"
|
||||
class="text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
| {{ t('dashboard.pausedAccounts') }}: {{ dashboardData.pausedAccounts }}
|
||||
| 停止调度: {{ dashboardData.pausedAccounts }}
|
||||
</span>
|
||||
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
|
||||
| {{ t('dashboard.rateLimitedAccounts') }}: {{ dashboardData.rateLimitedAccounts }}
|
||||
| 限流: {{ dashboardData.rateLimitedAccounts }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -177,14 +147,13 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.todayRequests') }}
|
||||
今日请求
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||
{{ dashboardData.todayRequests }}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.systemStatus') }}
|
||||
系统状态
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-green-600 sm:text-3xl">
|
||||
{{ t(`common.system.status.${dashboardData.systemStatusCode || 'normal'}`) }}
|
||||
{{ dashboardData.systemStatus }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('dashboard.uptime') }}: {{ formattedUptime }}
|
||||
运行时间: {{ formattedUptime }}
|
||||
</p>
|
||||
</div>
|
||||
<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="mr-8 flex-1">
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.todayToken') }}
|
||||
今日Token
|
||||
</p>
|
||||
<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">
|
||||
@@ -241,25 +210,25 @@
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4">
|
||||
<span
|
||||
>{{ t('dashboard.inputTokens') }}:
|
||||
>输入:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.todayInputTokens || 0)
|
||||
}}</span></span
|
||||
>
|
||||
<span
|
||||
>{{ t('dashboard.outputTokens') }}:
|
||||
>输出:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.todayOutputTokens || 0)
|
||||
}}</span></span
|
||||
>
|
||||
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600"
|
||||
>{{ t('dashboard.cacheCreateTokens') }}:
|
||||
>缓存创建:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.todayCacheCreateTokens || 0)
|
||||
}}</span></span
|
||||
>
|
||||
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600"
|
||||
>{{ t('dashboard.cacheReadTokens') }}:
|
||||
>缓存读取:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.todayCacheReadTokens || 0)
|
||||
}}</span></span
|
||||
@@ -277,7 +246,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mr-8 flex-1">
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.totalTokenConsumption') }}
|
||||
总Token消耗
|
||||
</p>
|
||||
<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">
|
||||
@@ -297,25 +266,25 @@
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4">
|
||||
<span
|
||||
>{{ t('dashboard.inputTokens') }}:
|
||||
>输入:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.totalInputTokens || 0)
|
||||
}}</span></span
|
||||
>
|
||||
<span
|
||||
>{{ t('dashboard.outputTokens') }}:
|
||||
>输出:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.totalOutputTokens || 0)
|
||||
}}</span></span
|
||||
>
|
||||
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600"
|
||||
>{{ t('dashboard.cacheCreateTokens') }}:
|
||||
>缓存创建:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.totalCacheCreateTokens || 0)
|
||||
}}</span></span
|
||||
>
|
||||
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600"
|
||||
>{{ t('dashboard.cacheReadTokens') }}:
|
||||
>缓存读取:
|
||||
<span class="font-medium">{{
|
||||
formatNumber(dashboardData.totalCacheReadTokens || 0)
|
||||
}}</span></span
|
||||
@@ -333,18 +302,16 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.realtimeRPM') }}
|
||||
<span class="text-xs text-gray-400"
|
||||
>({{ dashboardData.metricsWindow }}{{ t('dashboard.minutes') }})</span
|
||||
>
|
||||
实时RPM
|
||||
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-orange-600 sm:text-3xl">
|
||||
{{ dashboardData.realtimeRPM || 0 }}
|
||||
</p>
|
||||
<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">
|
||||
<i class="fas fa-exclamation-circle" /> {{ t('dashboard.historicalData') }}
|
||||
<i class="fas fa-exclamation-circle" /> 历史数据
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -358,18 +325,16 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
{{ t('dashboard.realtimeTPM') }}
|
||||
<span class="text-xs text-gray-400"
|
||||
>({{ dashboardData.metricsWindow }}{{ t('dashboard.minutes') }})</span
|
||||
>
|
||||
实时TPM
|
||||
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-rose-600 sm:text-3xl">
|
||||
{{ formatNumber(dashboardData.realtimeTPM || 0) }}
|
||||
</p>
|
||||
<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">
|
||||
<i class="fas fa-exclamation-circle" /> {{ t('dashboard.historicalData') }}
|
||||
<i class="fas fa-exclamation-circle" /> 历史数据
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -384,7 +349,7 @@
|
||||
<div class="mb-8">
|
||||
<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">
|
||||
{{ t('dashboard.modelDistributionAndTrend') }}
|
||||
模型使用分布与Token使用趋势
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-end">
|
||||
<!-- 快捷日期选择 -->
|
||||
@@ -417,7 +382,7 @@
|
||||
]"
|
||||
@click="setTrendGranularity('day')"
|
||||
>
|
||||
<i class="fas fa-calendar-day mr-1" />{{ t('dashboard.byDay') }}
|
||||
<i class="fas fa-calendar-day mr-1" />按天
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
@@ -428,7 +393,7 @@
|
||||
]"
|
||||
@click="setTrendGranularity('hour')"
|
||||
>
|
||||
<i class="fas fa-clock mr-1" />{{ t('dashboard.byHour') }}
|
||||
<i class="fas fa-clock mr-1" />按小时
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -439,18 +404,18 @@
|
||||
class="custom-date-picker w-full lg:w-auto"
|
||||
:default-time="defaultTime"
|
||||
:disabled-date="disabledDate"
|
||||
:end-placeholder="t('dashboard.endDatePlaceholder')"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:range-separator="t('dashboard.dateSeparator')"
|
||||
range-separator="至"
|
||||
size="default"
|
||||
:start-placeholder="t('dashboard.startDatePlaceholder')"
|
||||
start-placeholder="开始日期"
|
||||
style="max-width: 400px"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
/>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<i class="fas fa-redo-alt text-xs text-gray-500 dark:text-gray-400" />
|
||||
<span>{{ t('dashboard.autoRefresh') }}</span>
|
||||
<span>自动刷新</span>
|
||||
<span
|
||||
v-if="autoRefreshEnabled"
|
||||
class="ml-1 font-mono text-xs text-blue-600 transition-opacity"
|
||||
@@ -484,13 +449,11 @@
|
||||
<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"
|
||||
:disabled="isRefreshing"
|
||||
:title="t('dashboard.refreshDataNow')"
|
||||
title="立即刷新数据"
|
||||
@click="refreshAllData()"
|
||||
>
|
||||
<i :class="['fas fa-sync-alt text-xs', { 'animate-spin': isRefreshing }]" />
|
||||
<span class="hidden sm:inline">{{
|
||||
isRefreshing ? t('dashboard.refreshing') : t('dashboard.refresh')
|
||||
}}</span>
|
||||
<span class="hidden sm:inline">{{ isRefreshing ? '刷新中' : '刷新' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +463,7 @@
|
||||
<!-- 饼图 -->
|
||||
<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">
|
||||
{{ t('dashboard.tokenUsageDistribution') }}
|
||||
Token使用分布
|
||||
</h4>
|
||||
<div class="relative" style="height: 250px">
|
||||
<canvas ref="modelUsageChart" />
|
||||
@@ -510,10 +473,10 @@
|
||||
<!-- 详细数据表格 -->
|
||||
<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">
|
||||
{{ t('dashboard.detailedStatistics') }}
|
||||
详细统计数据
|
||||
</h4>
|
||||
<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 v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]">
|
||||
<table class="min-w-full">
|
||||
@@ -522,27 +485,27 @@
|
||||
<th
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -603,7 +566,7 @@
|
||||
<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">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg">
|
||||
{{ t('dashboard.apiKeysUsageTrend') }}
|
||||
API Keys 使用趋势
|
||||
</h3>
|
||||
<!-- 维度切换按钮 -->
|
||||
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
|
||||
@@ -616,10 +579,8 @@
|
||||
]"
|
||||
@click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())"
|
||||
>
|
||||
<i class="fas fa-exchange-alt mr-1" /><span class="hidden sm:inline">{{
|
||||
t('dashboard.requestsCount')
|
||||
}}</span
|
||||
><span class="sm:hidden">{{ t('dashboard.requestsCount').split(' ')[0] }}</span>
|
||||
<i class="fas fa-exchange-alt mr-1" /><span class="hidden sm:inline">请求次数</span
|
||||
><span class="sm:hidden">请求</span>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
@@ -630,20 +591,16 @@
|
||||
]"
|
||||
@click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())"
|
||||
>
|
||||
<i class="fas fa-coins mr-1" /><span class="hidden sm:inline">{{
|
||||
t('dashboard.tokenCount')
|
||||
}}</span
|
||||
<i class="fas fa-coins mr-1" /><span class="hidden sm:inline">Token 数量</span
|
||||
><span class="sm:hidden">Token</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
<span v-if="apiKeysTrendData.totalApiKeys > 10">
|
||||
{{ t('dashboard.showingTop10', { count: apiKeysTrendData.totalApiKeys }) }}
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
|
||||
</span>
|
||||
<span v-else>{{
|
||||
t('dashboard.totalApiKeysCount', { count: apiKeysTrendData.totalApiKeys })
|
||||
}}</span>
|
||||
<span v-else> 共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key </span>
|
||||
</div>
|
||||
<div class="sm:h-[350px]" style="height: 300px">
|
||||
<canvas ref="apiKeysUsageTrendChart" />
|
||||
@@ -656,14 +613,12 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { t, locale } = useI18n()
|
||||
const { isDarkMode } = storeToRefs(themeStore)
|
||||
|
||||
const {
|
||||
@@ -849,35 +804,35 @@ function createUsageTrendChart() {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.inputTokensLabel'),
|
||||
label: '输入Token',
|
||||
data: inputData,
|
||||
borderColor: 'rgb(102, 126, 234)',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: t('dashboard.outputTokensLabel'),
|
||||
label: '输出Token',
|
||||
data: outputData,
|
||||
borderColor: 'rgb(240, 147, 251)',
|
||||
backgroundColor: 'rgba(240, 147, 251, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: t('dashboard.cacheCreateTokensLabel'),
|
||||
label: '缓存创建Token',
|
||||
data: cacheCreateData,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: t('dashboard.cacheReadTokensLabel'),
|
||||
label: '缓存读取Token',
|
||||
data: cacheReadData,
|
||||
borderColor: 'rgb(147, 51, 234)',
|
||||
backgroundColor: 'rgba(147, 51, 234, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: t('dashboard.costLabel'),
|
||||
label: '费用 (USD)',
|
||||
data: costData,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
@@ -885,7 +840,7 @@ function createUsageTrendChart() {
|
||||
yAxisID: 'y2'
|
||||
},
|
||||
{
|
||||
label: t('dashboard.requestsLabel'),
|
||||
label: '请求数',
|
||||
data: requestsData,
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
@@ -908,7 +863,7 @@ function createUsageTrendChart() {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.tokenUsageTrend'),
|
||||
text: 'Token使用趋势',
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
@@ -930,14 +885,11 @@ function createUsageTrendChart() {
|
||||
const bLabel = b.dataset.label || ''
|
||||
|
||||
// 费用和请求数使用不同的轴,单独处理
|
||||
if (aLabel === t('dashboard.costLabel') || bLabel === t('dashboard.costLabel')) {
|
||||
return aLabel === t('dashboard.costLabel') ? -1 : 1
|
||||
if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') {
|
||||
return aLabel === '费用 (USD)' ? -1 : 1
|
||||
}
|
||||
if (
|
||||
aLabel === t('dashboard.requestsLabel') ||
|
||||
bLabel === t('dashboard.requestsLabel')
|
||||
) {
|
||||
return aLabel === t('dashboard.requestsLabel') ? 1 : -1
|
||||
if (aLabel === '请求数' || bLabel === '请求数') {
|
||||
return aLabel === '请求数' ? 1 : -1
|
||||
}
|
||||
|
||||
// 其他按token值倒序
|
||||
@@ -948,15 +900,15 @@ function createUsageTrendChart() {
|
||||
const label = context.dataset.label || ''
|
||||
let value = context.parsed.y
|
||||
|
||||
if (label === t('dashboard.costLabel')) {
|
||||
if (label === '费用 (USD)') {
|
||||
// 格式化费用显示
|
||||
if (value < 0.01) {
|
||||
return label + ': $' + value.toFixed(6)
|
||||
} else {
|
||||
return label + ': $' + value.toFixed(4)
|
||||
}
|
||||
} else if (label === t('dashboard.requestsLabel')) {
|
||||
return label + ': ' + value.toLocaleString()
|
||||
} else if (label === '请求数') {
|
||||
return label + ': ' + value.toLocaleString() + ' 次'
|
||||
} else {
|
||||
// 格式化token数显示
|
||||
if (value >= 1000000) {
|
||||
@@ -977,7 +929,7 @@ function createUsageTrendChart() {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity === 'hour' ? t('dashboard.time') : t('dashboard.date'),
|
||||
text: trendGranularity === 'hour' ? '时间' : '日期',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
ticks: {
|
||||
@@ -993,7 +945,7 @@ function createUsageTrendChart() {
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.tokenQuantity'),
|
||||
text: 'Token数量',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
ticks: {
|
||||
@@ -1012,7 +964,7 @@ function createUsageTrendChart() {
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: t('dashboard.requestsQuantity'),
|
||||
text: '请求数',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
grid: {
|
||||
@@ -1196,7 +1148,7 @@ function createApiKeysUsageTrendChart() {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity === 'hour' ? t('dashboard.time') : t('dashboard.date'),
|
||||
text: trendGranularity === 'hour' ? '时间' : '日期',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
ticks: {
|
||||
@@ -1210,10 +1162,7 @@ function createApiKeysUsageTrendChart() {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text:
|
||||
apiKeysTrendMetric.value === 'tokens'
|
||||
? t('dashboard.tokenQuantity')
|
||||
: t('dashboard.requestsQuantity'),
|
||||
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
ticks: {
|
||||
@@ -1337,15 +1286,6 @@ watch(isDarkMode, () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 监听语言变化,重新创建图表
|
||||
watch(locale, () => {
|
||||
nextTick(() => {
|
||||
createModelUsageChart()
|
||||
createUsageTrendChart()
|
||||
createApiKeysUsageTrendChart()
|
||||
})
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
// 加载所有数据
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<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">
|
||||
<!-- 语言切换按钮 -->
|
||||
<LanguageSwitch mode="dropdown" size="medium" />
|
||||
<!-- 主题切换按钮 -->
|
||||
<!-- 主题切换按钮 - 固定在右上角 -->
|
||||
<div class="fixed right-4 top-4 z-50">
|
||||
<ThemeToggle mode="dropdown" />
|
||||
</div>
|
||||
|
||||
@@ -37,33 +34,31 @@
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
|
||||
>{{ t('login.username') }}</label
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
|
||||
>用户名</label
|
||||
>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('login.usernamePlaceholder')"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
|
||||
>{{ t('login.password') }}</label
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
|
||||
>密码</label
|
||||
>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
class="form-input w-full"
|
||||
:placeholder="t('login.passwordPlaceholder')"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
@@ -76,7 +71,7 @@
|
||||
>
|
||||
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2" />
|
||||
<div v-if="authStore.loginLoading" class="loading-spinner mr-2" />
|
||||
{{ authStore.loginLoading ? t('login.loggingIn') : t('login.loginButton') }}
|
||||
{{ authStore.loginLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -92,15 +87,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { t } = useI18n()
|
||||
const oemLoading = computed(() => authStore.oemLoading)
|
||||
|
||||
const loginForm = ref({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@
|
||||
]"
|
||||
@click="handleTabChange('overview')"
|
||||
>
|
||||
{{ t('user.dashboard.overview') }}
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
@@ -43,7 +43,7 @@
|
||||
]"
|
||||
@click="handleTabChange('api-keys')"
|
||||
>
|
||||
{{ t('user.dashboard.apiKeys') }}
|
||||
API Keys
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
@@ -54,7 +54,7 @@
|
||||
]"
|
||||
@click="handleTabChange('usage')"
|
||||
>
|
||||
{{ t('user.dashboard.usageStats') }}
|
||||
Usage Stats
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
@@ -72,8 +72,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('user.dashboard.welcome') }},
|
||||
<span class="font-medium">{{ userStore.userName }}</span>
|
||||
Welcome, <span class="font-medium">{{ userStore.userName }}</span>
|
||||
</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"
|
||||
@click="handleLogout"
|
||||
>
|
||||
{{ t('user.dashboard.logout') }}
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,11 +94,9 @@
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('user.dashboard.title') }}
|
||||
</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard Overview</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('user.dashboard.welcomeMessage') }}
|
||||
Welcome to your Claude Relay dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +123,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.dashboard.activeApiKeys') }}
|
||||
Active API Keys
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ apiKeysStats.active }}
|
||||
@@ -158,7 +155,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.dashboard.deletedApiKeys') }}
|
||||
Deleted API Keys
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ apiKeysStats.deleted }}
|
||||
@@ -190,7 +187,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.dashboard.totalRequests') }}
|
||||
Total Requests
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ formatNumber(userProfile?.totalUsage?.requests || 0) }}
|
||||
@@ -222,7 +219,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.dashboard.inputTokens') }}
|
||||
Input Tokens
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }}
|
||||
@@ -254,7 +251,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.dashboard.totalCost') }}
|
||||
Total Cost
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }}
|
||||
@@ -270,38 +267,30 @@
|
||||
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
||||
{{ t('user.dashboard.accountInformation') }}
|
||||
Account Information
|
||||
</h3>
|
||||
<div class="mt-5 border-t border-gray-200 dark:border-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">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.dashboard.username') }}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Username</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||
{{ userProfile?.username }}
|
||||
</dd>
|
||||
</div>
|
||||
<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">
|
||||
{{ t('user.dashboard.displayName') }}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Display Name</dt>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{{ t('user.dashboard.email') }}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{{ t('user.dashboard.role') }}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||
<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"
|
||||
@@ -311,19 +300,15 @@
|
||||
</dd>
|
||||
</div>
|
||||
<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">
|
||||
{{ t('user.dashboard.memberSince') }}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Member Since</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||
{{ formatDate(userProfile?.createdAt) }}
|
||||
</dd>
|
||||
</div>
|
||||
<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">
|
||||
{{ t('user.dashboard.lastLogin') }}
|
||||
</dt>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Login</dt>
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -353,7 +338,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { showToast } from '@/utils/toast'
|
||||
@@ -363,7 +347,6 @@ import UserUsageStats from '@/components/user/UserUsageStats.vue'
|
||||
import TutorialView from '@/views/TutorialView.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
@@ -402,11 +385,11 @@ const handleTabChange = (tab) => {
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
showToast(t('user.dashboard.logoutSuccess'), 'success')
|
||||
showToast('Logged out successfully', 'success')
|
||||
router.push('/user-login')
|
||||
} catch (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()
|
||||
} catch (error) {
|
||||
console.error('Failed to load user profile:', error)
|
||||
showToast(t('user.dashboard.loadProfileFailed'), 'error')
|
||||
showToast('Failed to load user profile', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
<span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span>
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{{ t('user.login.title') }}
|
||||
User Sign In
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
for="username"
|
||||
>
|
||||
{{ t('user.login.username') }}
|
||||
Username
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<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"
|
||||
:disabled="loading"
|
||||
name="username"
|
||||
:placeholder="t('user.login.usernamePlaceholder')"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
@@ -61,7 +61,7 @@
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
for="password"
|
||||
>
|
||||
{{ t('user.login.password') }}
|
||||
Password
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<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"
|
||||
:disabled="loading"
|
||||
name="password"
|
||||
:placeholder="t('user.login.passwordPlaceholder')"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
@@ -125,7 +125,7 @@
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
{{ loading ? t('user.login.signingIn') : t('user.login.signIn') }}
|
||||
{{ loading ? 'Signing In...' : 'Sign In' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
to="/admin-login"
|
||||
>
|
||||
{{ t('user.login.adminLogin') }}
|
||||
Admin Login
|
||||
</router-link>
|
||||
</div>
|
||||
</form>
|
||||
@@ -146,14 +146,12 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
@@ -167,7 +165,7 @@ const form = reactive({
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
error.value = t('user.login.requiredFields')
|
||||
error.value = 'Please enter both username and password'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,11 +178,11 @@ const handleLogin = async () => {
|
||||
password: form.password
|
||||
})
|
||||
|
||||
showToast(t('user.login.loginSuccess'), 'success')
|
||||
showToast('Login successful!', 'success')
|
||||
router.push('/user-dashboard')
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
<!-- Header -->
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('user.management.title') }}
|
||||
</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
@@ -24,7 +22,7 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('user.management.refresh') }}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +50,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.management.totalUsers') }}
|
||||
Total Users
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ userStats?.totalUsers || 0 }}
|
||||
@@ -84,7 +82,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.management.activeUsers') }}
|
||||
Active Users
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ userStats?.activeUsers || 0 }}
|
||||
@@ -116,7 +114,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.management.totalApiKeys') }}
|
||||
Total API Keys
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ userStats?.totalApiKeys || 0 }}
|
||||
@@ -148,7 +146,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.management.totalCost') }}
|
||||
Total Cost
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
|
||||
@@ -186,7 +184,7 @@
|
||||
<input
|
||||
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"
|
||||
:placeholder="t('user.management.searchPlaceholder')"
|
||||
placeholder="Search users..."
|
||||
type="search"
|
||||
@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"
|
||||
@change="loadUsers"
|
||||
>
|
||||
<option value="">{{ t('user.management.allRoles') }}</option>
|
||||
<option value="user">{{ t('user.management.user') }}</option>
|
||||
<option value="admin">{{ t('user.management.admin') }}</option>
|
||||
<option value="">All Roles</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</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"
|
||||
@change="loadUsers"
|
||||
>
|
||||
<option value="">{{ t('user.management.allStatus') }}</option>
|
||||
<option value="true">{{ t('user.management.active') }}</option>
|
||||
<option value="false">{{ t('user.management.disabled') }}</option>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +225,7 @@
|
||||
<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">
|
||||
<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"
|
||||
>({{ filteredUsers.length }} of {{ users.length }})</span
|
||||
>
|
||||
@@ -256,9 +254,7 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('user.management.loadingUsers') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
|
||||
<!-- Users List -->
|
||||
@@ -303,9 +299,7 @@
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
user.isActive ? t('user.management.active') : t('user.management.disabled')
|
||||
}}
|
||||
{{ user.isActive ? 'Active' : 'Disabled' }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
@@ -324,24 +318,18 @@
|
||||
>
|
||||
<span>@{{ user.username }}</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"
|
||||
>{{ 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
|
||||
v-if="user.totalUsage"
|
||||
class="mt-1 flex items-center space-x-4 text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<span
|
||||
>{{ formatNumber(user.totalUsage.requests || 0) }}
|
||||
{{ t('user.management.requests') }}</span
|
||||
>
|
||||
<span
|
||||
>${{ (user.totalUsage.totalCost || 0).toFixed(4) }}
|
||||
{{ t('user.management.totalCostLabel') }}</span
|
||||
>
|
||||
<span>{{ formatNumber(user.totalUsage.requests || 0) }} requests</span>
|
||||
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,7 +337,7 @@
|
||||
<!-- View Usage Stats -->
|
||||
<button
|
||||
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)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -366,7 +354,7 @@
|
||||
<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"
|
||||
:disabled="user.apiKeyCount === 0"
|
||||
:title="t('user.management.disableAllApiKeys')"
|
||||
title="Disable All API Keys"
|
||||
@click="disableUserApiKeys(user)"
|
||||
>
|
||||
<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-green-600'
|
||||
]"
|
||||
:title="
|
||||
user.isActive ? t('user.management.disableUser') : t('user.management.enableUser')
|
||||
"
|
||||
:title="user.isActive ? 'Disable User' : 'Enable User'"
|
||||
@click="toggleUserStatus(user)"
|
||||
>
|
||||
<svg
|
||||
@@ -419,7 +405,7 @@
|
||||
<!-- Change Role -->
|
||||
<button
|
||||
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)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -451,12 +437,10 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('user.management.noUsersFound') }}
|
||||
</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No users found</h3>
|
||||
<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>
|
||||
</div>
|
||||
@@ -492,7 +476,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { debounce } from 'lodash-es'
|
||||
@@ -500,7 +483,6 @@ import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
|
||||
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(true)
|
||||
const users = ref([])
|
||||
const userStats = ref(null)
|
||||
@@ -595,7 +577,7 @@ const loadUsers = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
showToast(t('user.management.loadUsersError'), 'error')
|
||||
showToast('Failed to load users', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -613,13 +595,11 @@ const viewUserStats = (user) => {
|
||||
const toggleUserStatus = (user) => {
|
||||
selectedUser.value = user
|
||||
confirmAction.value = {
|
||||
title: user.isActive
|
||||
? t('user.management.disableUserTitle')
|
||||
: t('user.management.enableUserTitle'),
|
||||
title: user.isActive ? 'Disable User' : 'Enable User',
|
||||
message: user.isActive
|
||||
? t('user.management.disableUserMessage', { username: user.username })
|
||||
: t('user.management.enableUserMessage', { username: user.username }),
|
||||
confirmText: user.isActive ? t('user.management.disable') : t('user.management.enable'),
|
||||
? `Are you sure you want to disable user "${user.username}"? This will prevent them from logging in.`
|
||||
: `Are you sure you want to enable user "${user.username}"?`,
|
||||
confirmText: user.isActive ? 'Disable' : 'Enable',
|
||||
confirmClass: user.isActive ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700',
|
||||
action: 'toggleStatus'
|
||||
}
|
||||
@@ -631,12 +611,9 @@ const disableUserApiKeys = (user) => {
|
||||
|
||||
selectedUser.value = user
|
||||
confirmAction.value = {
|
||||
title: t('user.management.disableAllKeysTitle'),
|
||||
message: t('user.management.disableAllKeysMessage', {
|
||||
count: user.apiKeyCount,
|
||||
username: user.username
|
||||
}),
|
||||
confirmText: t('user.management.disableKeys'),
|
||||
title: 'Disable All API Keys',
|
||||
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.`,
|
||||
confirmText: 'Disable Keys',
|
||||
confirmClass: 'bg-red-600 hover:bg-red-700',
|
||||
action: 'disableKeys'
|
||||
}
|
||||
@@ -663,27 +640,19 @@ const handleConfirmAction = async () => {
|
||||
if (userIndex !== -1) {
|
||||
users.value[userIndex].isActive = !user.isActive
|
||||
}
|
||||
showToast(
|
||||
user.isActive
|
||||
? t('user.management.userDisabledSuccess')
|
||||
: t('user.management.userEnabledSuccess'),
|
||||
'success'
|
||||
)
|
||||
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
|
||||
}
|
||||
} else if (action === 'disableKeys') {
|
||||
const response = await apiClient.post(`/users/${user.id}/disable-keys`)
|
||||
|
||||
if (response.success) {
|
||||
showToast(
|
||||
t('user.management.keysDisabledSuccess', { count: response.disabledCount }),
|
||||
'success'
|
||||
)
|
||||
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
|
||||
await loadUsers() // Refresh to get updated counts
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action}:`, error)
|
||||
showToast(t(`user.management.${action}Error`), 'error')
|
||||
showToast(`Failed to ${action}`, 'error')
|
||||
} finally {
|
||||
showConfirmModal.value = false
|
||||
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
@@ -51,9 +51,7 @@ export default defineConfig(({ mode }) => {
|
||||
imports: ['vue', 'vue-router', 'pinia']
|
||||
}),
|
||||
Components({
|
||||
// 仅自动注册 Element Plus 组件;本地组件显式导入
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dirs: []
|
||||
resolvers: [ElementPlusResolver()]
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
@@ -105,8 +103,6 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
// 提升 chunk 大小限制以消除 UI 库的大量警告
|
||||
chunkSizeWarningLimit: 1024,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
|
||||
Reference in New Issue
Block a user