mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 完成布局/仪表板/用户相关组件国际化与语言切换优化(TabBar/MainLayout/AppHeader、UsageTrend/ModelDistribution、User*、Common 组件、i18n/locale 增强)
This commit is contained in:
1
web/admin-spa/components.d.ts
vendored
1
web/admin-spa/components.d.ts
vendored
@@ -20,6 +20,7 @@ declare module 'vue' {
|
|||||||
CreateApiKeyModal: typeof import('./src/components/apikeys/CreateApiKeyModal.vue')['default']
|
CreateApiKeyModal: typeof import('./src/components/apikeys/CreateApiKeyModal.vue')['default']
|
||||||
CustomDropdown: typeof import('./src/components/common/CustomDropdown.vue')['default']
|
CustomDropdown: typeof import('./src/components/common/CustomDropdown.vue')['default']
|
||||||
EditApiKeyModal: typeof import('./src/components/apikeys/EditApiKeyModal.vue')['default']
|
EditApiKeyModal: typeof import('./src/components/apikeys/EditApiKeyModal.vue')['default']
|
||||||
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ExpiryEditModal: typeof import('./src/components/apikeys/ExpiryEditModal.vue')['default']
|
ExpiryEditModal: typeof import('./src/components/apikeys/ExpiryEditModal.vue')['default']
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
placeholder="搜索账号名称..."
|
:placeholder="$t('common.accountSelector.searchPlaceholder')"
|
||||||
style="padding-left: 40px; padding-right: 36px"
|
style="padding-left: 40px; padding-right: 36px"
|
||||||
type="text"
|
type="text"
|
||||||
@input="handleSearch"
|
@input="handleSearch"
|
||||||
@@ -68,7 +68,9 @@
|
|||||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
|
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
|
||||||
@click="selectAccount(null)"
|
@click="selectAccount(null)"
|
||||||
>
|
>
|
||||||
<span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
|
<span class="text-gray-700 dark:text-gray-300">{{
|
||||||
|
props.defaultOptionText || $t('common.accountSelector.useSharedPool')
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分组选项 -->
|
<!-- 分组选项 -->
|
||||||
@@ -76,7 +78,7 @@
|
|||||||
<div
|
<div
|
||||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
调度分组
|
{{ $t('common.accountSelector.schedulingGroups') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="group in filteredGroups"
|
v-for="group in filteredGroups"
|
||||||
@@ -88,7 +90,8 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
|
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
>{{ group.memberCount || 0 }} 个成员</span
|
>{{ group.memberCount || 0
|
||||||
|
}}{{ $t('common.accountSelector.membersUnit') }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,10 +104,8 @@
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
platform === 'claude'
|
platform === 'claude'
|
||||||
? 'Claude OAuth 专属账号'
|
? $t('common.accountSelector.claudeOAuthAccounts')
|
||||||
: platform === 'openai'
|
: $t('common.accountSelector.oauthAccounts')
|
||||||
? 'OpenAI 专属账号'
|
|
||||||
: 'OAuth 专属账号'
|
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -142,7 +143,7 @@
|
|||||||
<div
|
<div
|
||||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
Claude Console 专属账号
|
{{ $t('common.accountSelector.claudeConsoleAccounts') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="account in filteredConsoleAccounts"
|
v-for="account in filteredConsoleAccounts"
|
||||||
@@ -176,52 +177,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 账号(仅 OpenAI) -->
|
|
||||||
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
|
|
||||||
<div
|
|
||||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
OpenAI-Responses 专属账号
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="account in filteredOpenAIResponsesAccounts"
|
|
||||||
:key="account.id"
|
|
||||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
||||||
:class="{
|
|
||||||
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
|
|
||||||
}"
|
|
||||||
@click="selectAccount(`responses:${account.id}`)"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
|
|
||||||
<span
|
|
||||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
|
||||||
:class="
|
|
||||||
account.isActive === 'true' || account.isActive === true
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
||||||
: account.status === 'rate_limited'
|
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
|
||||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ getAccountStatusText(account) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{{ formatDate(account.createdAt) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 无搜索结果 -->
|
<!-- 无搜索结果 -->
|
||||||
<div
|
<div
|
||||||
v-if="searchQuery && !hasResults"
|
v-if="searchQuery && !hasResults"
|
||||||
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
|
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
<i class="fas fa-search mb-2 text-2xl" />
|
<i class="fas fa-search mb-2 text-2xl" />
|
||||||
<p class="text-sm">没有找到匹配的账号</p>
|
<p class="text-sm">{{ $t('common.accountSelector.noResultsFound') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,6 +194,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t: $t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -241,7 +206,7 @@ const props = defineProps({
|
|||||||
platform: {
|
platform: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
|
validator: (value) => ['claude', 'gemini'].includes(value)
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -257,11 +222,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '请选择账号'
|
default: null
|
||||||
},
|
},
|
||||||
defaultOptionText: {
|
defaultOptionText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '使用共享账号池'
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -278,13 +243,16 @@ const lastDirection = ref('') // 记住上次的显示方向
|
|||||||
// 获取选中的标签
|
// 获取选中的标签
|
||||||
const selectedLabel = computed(() => {
|
const selectedLabel = computed(() => {
|
||||||
// 如果没有选中值,显示默认选项文本
|
// 如果没有选中值,显示默认选项文本
|
||||||
if (!props.modelValue) return props.defaultOptionText
|
if (!props.modelValue)
|
||||||
|
return props.defaultOptionText || $t('common.accountSelector.useSharedPool')
|
||||||
|
|
||||||
// 分组
|
// 分组
|
||||||
if (props.modelValue.startsWith('group:')) {
|
if (props.modelValue.startsWith('group:')) {
|
||||||
const groupId = props.modelValue.substring(6)
|
const groupId = props.modelValue.substring(6)
|
||||||
const group = props.groups.find((g) => g.id === groupId)
|
const group = props.groups.find((g) => g.id === groupId)
|
||||||
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
|
return group
|
||||||
|
? `${group.name} (${group.memberCount || 0}${$t('common.accountSelector.membersUnit')})`
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Console 账号
|
// Console 账号
|
||||||
@@ -296,15 +264,6 @@ const selectedLabel = computed(() => {
|
|||||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI-Responses 账号
|
|
||||||
if (props.modelValue.startsWith('responses:')) {
|
|
||||||
const accountId = props.modelValue.substring(10)
|
|
||||||
const account = props.accounts.find(
|
|
||||||
(a) => a.id === accountId && a.platform === 'openai-responses'
|
|
||||||
)
|
|
||||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuth 账号
|
// OAuth 账号
|
||||||
const account = props.accounts.find((a) => a.id === props.modelValue)
|
const account = props.accounts.find((a) => a.id === props.modelValue)
|
||||||
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
|
||||||
@@ -312,36 +271,26 @@ const selectedLabel = computed(() => {
|
|||||||
|
|
||||||
// 获取账户状态文本
|
// 获取账户状态文本
|
||||||
const getAccountStatusText = (account) => {
|
const getAccountStatusText = (account) => {
|
||||||
if (!account) return '未知'
|
if (!account) return $t('common.accountSelector.accountStatus.unknown')
|
||||||
|
|
||||||
// 处理 OpenAI-Responses 账号(isActive 可能是字符串)
|
|
||||||
const isActive = account.isActive === 'true' || account.isActive === true
|
|
||||||
|
|
||||||
// 优先使用 isActive 判断
|
// 优先使用 isActive 判断
|
||||||
if (!isActive) {
|
if (account.isActive === false) {
|
||||||
// 根据 status 提供更详细的状态信息
|
// 根据 status 提供更详细的状态信息
|
||||||
switch (account.status) {
|
switch (account.status) {
|
||||||
case 'unauthorized':
|
case 'unauthorized':
|
||||||
return '未授权'
|
return $t('common.accountSelector.accountStatus.unauthorized')
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Token错误'
|
return $t('common.accountSelector.accountStatus.tokenError')
|
||||||
case 'created':
|
case 'created':
|
||||||
return '待验证'
|
return $t('common.accountSelector.accountStatus.pending')
|
||||||
case 'rate_limited':
|
case 'rate_limited':
|
||||||
return '限流中'
|
return $t('common.accountSelector.accountStatus.rateLimited')
|
||||||
case 'quota_exceeded':
|
|
||||||
return '额度超限'
|
|
||||||
default:
|
default:
|
||||||
return '异常'
|
return $t('common.accountSelector.accountStatus.error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对于激活的账号,如果是限流状态也要显示
|
return $t('common.accountSelector.accountStatus.active')
|
||||||
if (account.status === 'rate_limited') {
|
|
||||||
return '限流中'
|
|
||||||
}
|
|
||||||
|
|
||||||
return '正常'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按创建时间倒序排序账号
|
// 按创建时间倒序排序账号
|
||||||
@@ -353,42 +302,18 @@ const sortedAccounts = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 过滤的分组(根据平台类型过滤)
|
// 过滤的分组
|
||||||
const filteredGroups = computed(() => {
|
const filteredGroups = computed(() => {
|
||||||
// 只显示与当前平台匹配的分组
|
if (!searchQuery.value) return props.groups
|
||||||
let groups = props.groups.filter((group) => {
|
const query = searchQuery.value.toLowerCase()
|
||||||
// 如果分组有platform属性,则必须匹配当前平台
|
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||||
// 如果没有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 账号
|
// 过滤的 OAuth 账号
|
||||||
const filteredOAuthAccounts = computed(() => {
|
const filteredOAuthAccounts = computed(() => {
|
||||||
let accounts = []
|
let accounts = sortedAccounts.value.filter((a) =>
|
||||||
|
props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console'
|
||||||
if (props.platform === 'claude') {
|
)
|
||||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
|
|
||||||
} else if (props.platform === 'openai') {
|
|
||||||
// 对于 OpenAI,只显示 openai 类型的账号
|
|
||||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
|
||||||
} else {
|
|
||||||
// 其他平台显示所有非特殊类型的账号
|
|
||||||
accounts = sortedAccounts.value.filter(
|
|
||||||
(a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
@@ -412,27 +337,12 @@ const filteredConsoleAccounts = computed(() => {
|
|||||||
return accounts
|
return accounts
|
||||||
})
|
})
|
||||||
|
|
||||||
// 过滤的 OpenAI-Responses 账号
|
|
||||||
const filteredOpenAIResponsesAccounts = computed(() => {
|
|
||||||
if (props.platform !== 'openai') return []
|
|
||||||
|
|
||||||
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
|
|
||||||
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
return accounts
|
|
||||||
})
|
|
||||||
|
|
||||||
// 是否有搜索结果
|
// 是否有搜索结果
|
||||||
const hasResults = computed(() => {
|
const hasResults = computed(() => {
|
||||||
return (
|
return (
|
||||||
filteredGroups.value.length > 0 ||
|
filteredGroups.value.length > 0 ||
|
||||||
filteredOAuthAccounts.value.length > 0 ||
|
filteredOAuthAccounts.value.length > 0 ||
|
||||||
filteredConsoleAccounts.value.length > 0 ||
|
filteredConsoleAccounts.value.length > 0
|
||||||
filteredOpenAIResponsesAccounts.value.length > 0
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -444,12 +354,12 @@ const formatDate = (dateString) => {
|
|||||||
const diffInHours = (now - date) / (1000 * 60 * 60)
|
const diffInHours = (now - date) / (1000 * 60 * 60)
|
||||||
|
|
||||||
if (diffInHours < 24) {
|
if (diffInHours < 24) {
|
||||||
return '今天创建'
|
return $t('common.accountSelector.dateFormat.today')
|
||||||
} else if (diffInHours < 48) {
|
} else if (diffInHours < 48) {
|
||||||
return '昨天创建'
|
return $t('common.accountSelector.dateFormat.yesterday')
|
||||||
} else if (diffInHours < 168) {
|
} else if (diffInHours < 168) {
|
||||||
// 7天内
|
// 7天内
|
||||||
return `${Math.floor(diffInHours / 24)} 天前`
|
return `${Math.floor(diffInHours / 24)}${$t('common.accountSelector.dateFormat.daysAgo')}`
|
||||||
} else {
|
} else {
|
||||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,22 +49,26 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 国际化
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const isVisible = ref(false)
|
const isVisible = ref(false)
|
||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const confirmText = ref('确认')
|
const confirmText = ref(t('common.confirmDialog.confirm'))
|
||||||
const cancelText = ref('取消')
|
const cancelText = ref(t('common.confirmDialog.cancel'))
|
||||||
let resolvePromise = null
|
let resolvePromise = null
|
||||||
|
|
||||||
// 显示确认对话框
|
// 显示确认对话框
|
||||||
const showConfirm = (
|
const showConfirm = (
|
||||||
titleText,
|
titleText,
|
||||||
messageText,
|
messageText,
|
||||||
confirmTextParam = '确认',
|
confirmTextParam = t('common.confirmDialog.confirm'),
|
||||||
cancelTextParam = '取消'
|
cancelTextParam = t('common.confirmDialog.cancel')
|
||||||
) => {
|
) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
title.value = titleText
|
title.value = titleText
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -55,11 +59,11 @@ defineProps({
|
|||||||
},
|
},
|
||||||
confirmText: {
|
confirmText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '继续'
|
default: () => t('common.confirmModal.continue')
|
||||||
},
|
},
|
||||||
cancelText: {
|
cancelText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '取消'
|
default: () => t('common.confirmModal.cancel')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -77,7 +80,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '请选择'
|
default: () => t('common.customDropdown.placeholder')
|
||||||
},
|
},
|
||||||
icon: {
|
icon: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useLocaleStore } from '@/stores/locale'
|
import { useLocaleStore } from '@/stores/locale'
|
||||||
|
|
||||||
// 定义组件属性
|
// 定义组件属性
|
||||||
@@ -130,13 +131,14 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['change'])
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
// 存储和响应式数据
|
// 存储和响应式数据
|
||||||
|
const { t } = useI18n()
|
||||||
const localeStore = useLocaleStore()
|
const localeStore = useLocaleStore()
|
||||||
const showDropdown = ref(false)
|
const showDropdown = ref(false)
|
||||||
const dropdownTrigger = ref(null)
|
const dropdownTrigger = ref(null)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const currentLocaleInfo = computed(() => localeStore.getCurrentLocaleInfo())
|
const currentLocaleInfo = computed(() => localeStore.getCurrentLocaleInfo(t))
|
||||||
const supportedLocales = computed(() => localeStore.getSupportedLocales())
|
const supportedLocales = computed(() => localeStore.getSupportedLocales(t))
|
||||||
|
|
||||||
const containerClass = computed(() => {
|
const containerClass = computed(() => {
|
||||||
const classes = []
|
const classes = []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<img
|
<img
|
||||||
v-if="logoSrc"
|
v-if="logoSrc"
|
||||||
alt="Logo"
|
:alt="$t('common.logoTitle.logoAlt')"
|
||||||
class="h-8 w-8 object-contain"
|
class="h-8 w-8 object-contain"
|
||||||
:src="logoSrc"
|
:src="logoSrc"
|
||||||
@error="handleLogoError"
|
@error="handleLogoError"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
defineProps({
|
defineProps({
|
||||||
@@ -88,32 +89,37 @@ defineProps({
|
|||||||
// Store
|
// Store
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
// i18n
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 主题选项配置
|
// 主题选项配置
|
||||||
const themeOptions = [
|
const themeOptions = computed(() => [
|
||||||
{
|
{
|
||||||
value: 'light',
|
value: 'light',
|
||||||
label: '浅色模式',
|
label: t('common.themeToggle.light.label'),
|
||||||
shortLabel: '浅色',
|
shortLabel: t('common.themeToggle.light.shortLabel'),
|
||||||
icon: 'fas fa-sun'
|
icon: 'fas fa-sun'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'dark',
|
value: 'dark',
|
||||||
label: '深色模式',
|
label: t('common.themeToggle.dark.label'),
|
||||||
shortLabel: '深色',
|
shortLabel: t('common.themeToggle.dark.shortLabel'),
|
||||||
icon: 'fas fa-moon'
|
icon: 'fas fa-moon'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: '跟随系统',
|
label: t('common.themeToggle.auto.label'),
|
||||||
shortLabel: '自动',
|
shortLabel: t('common.themeToggle.auto.shortLabel'),
|
||||||
icon: 'fas fa-circle-half-stroke'
|
icon: 'fas fa-circle-half-stroke'
|
||||||
}
|
}
|
||||||
]
|
])
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const themeTooltip = computed(() => {
|
const themeTooltip = computed(() => {
|
||||||
const current = themeOptions.find((opt) => opt.value === themeStore.themeMode)
|
const current = themeOptions.value.find((opt) => opt.value === themeStore.themeMode)
|
||||||
return current ? `点击切换主题 - ${current.label}` : '切换主题'
|
return current
|
||||||
|
? `${t('common.themeToggle.clickToSwitch')} - ${current.label}`
|
||||||
|
: t('common.themeToggle.toggleTheme')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
<i :class="getIconClass(toast.type)" />
|
<i :class="getIconClass(toast.type)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="toast-body">
|
<div class="toast-body">
|
||||||
<div v-if="toast.title" class="toast-title">
|
<div v-if="toast.title || getDefaultTitle(toast.type)" class="toast-title">
|
||||||
{{ toast.title }}
|
{{ toast.title || getDefaultTitle(toast.type) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="toast-message">
|
<div class="toast-message">
|
||||||
{{ toast.message }}
|
{{ toast.message }}
|
||||||
@@ -35,6 +35,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const toasts = ref([])
|
const toasts = ref([])
|
||||||
@@ -51,6 +54,11 @@ const getIconClass = (type) => {
|
|||||||
return iconMap[type] || iconMap.info
|
return iconMap[type] || iconMap.info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取默认标题
|
||||||
|
const getDefaultTitle = (type) => {
|
||||||
|
return t(`common.toastNotification.defaultTitles.${type}`)
|
||||||
|
}
|
||||||
|
|
||||||
// 添加Toast
|
// 添加Toast
|
||||||
const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||||
const id = ++toastIdCounter
|
const id = ++toastIdCounter
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||||
<i class="fas fa-robot mr-2 text-purple-500" />
|
<i class="fas fa-robot mr-2 text-purple-500" />
|
||||||
模型使用分布
|
{{ $t('dashboard.modelDistribution.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
||||||
<el-radio-button label="daily"> 今日 </el-radio-button>
|
<el-radio-button label="daily">
|
||||||
<el-radio-button label="total"> 累计 </el-radio-button>
|
{{ $t('dashboard.modelDistribution.periods.daily') }}
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button label="total">
|
||||||
|
{{ $t('dashboard.modelDistribution.periods.total') }}
|
||||||
|
</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,16 +21,16 @@
|
|||||||
class="py-12 text-center text-gray-500"
|
class="py-12 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
|
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
|
||||||
<p>暂无模型使用数据</p>
|
<p>{{ $t('dashboard.modelDistribution.noData') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<!-- 饼图 -->
|
<!-- Pie Chart -->
|
||||||
<div class="relative" style="height: 300px">
|
<div class="relative" style="height: 300px">
|
||||||
<canvas ref="chartCanvas" />
|
<canvas ref="chartCanvas" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 数据列表 -->
|
<!-- Data List -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="(stat, index) in sortedStats"
|
v-for="(stat, index) in sortedStats"
|
||||||
@@ -38,8 +42,14 @@
|
|||||||
<span class="font-medium text-gray-700">{{ stat.model }}</span>
|
<span class="font-medium text-gray-700">{{ stat.model }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
|
<p class="font-semibold text-gray-800">
|
||||||
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,10 +60,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { Chart } from 'chart.js/auto'
|
import { Chart } from 'chart.js/auto'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
import { useChartConfig } from '@/composables/useChartConfig'
|
import { useChartConfig } from '@/composables/useChartConfig'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
const chartCanvas = ref(null)
|
const chartCanvas = ref(null)
|
||||||
let chart = null
|
let chart = null
|
||||||
@@ -110,8 +123,8 @@ const createChart = () => {
|
|||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
return [
|
return [
|
||||||
`${stat.model}: ${percentage}%`,
|
`${stat.model}: ${percentage}%`,
|
||||||
`请求: ${formatNumber(stat.requests)}`,
|
`${t('dashboard.modelDistribution.chart.tooltip.requests')}: ${formatNumber(stat.requests)}`,
|
||||||
`Tokens: ${formatNumber(stat.totalTokens)}`
|
`${t('dashboard.modelDistribution.chart.tooltip.tokens')}: ${formatNumber(stat.totalTokens)}`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||||
<i class="fas fa-chart-area mr-2 text-blue-500" />
|
<i class="fas fa-chart-area mr-2 text-blue-500" />
|
||||||
使用趋势
|
{{ $t('dashboard.usageTrend.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
||||||
<el-radio-button label="day"> 按天 </el-radio-button>
|
<el-radio-button label="day">
|
||||||
<el-radio-button label="hour"> 按小时 </el-radio-button>
|
{{ $t('dashboard.usageTrend.granularity.byDay') }}
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button label="hour">
|
||||||
|
{{ $t('dashboard.usageTrend.granularity.byHour') }}
|
||||||
|
</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
|
||||||
<el-select
|
<el-select
|
||||||
@@ -21,7 +25,7 @@
|
|||||||
<el-option
|
<el-option
|
||||||
v-for="period in periodOptions"
|
v-for="period in periodOptions"
|
||||||
:key="period.days"
|
:key="period.days"
|
||||||
:label="`最近${period.days}天`"
|
:label="$t('dashboard.usageTrend.periodOptions.recentDays', { days: period.days })"
|
||||||
:value="period.days"
|
:value="period.days"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -35,23 +39,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
import { Chart } from 'chart.js/auto'
|
import { Chart } from 'chart.js/auto'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
import { useChartConfig } from '@/composables/useChartConfig'
|
import { useChartConfig } from '@/composables/useChartConfig'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
const chartCanvas = ref(null)
|
const chartCanvas = ref(null)
|
||||||
let chart = null
|
let chart = null
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const trendPeriod = ref(7)
|
const trendPeriod = ref(7)
|
||||||
const granularity = ref('day')
|
const granularity = ref('day')
|
||||||
|
|
||||||
const periodOptions = [
|
const periodOptions = computed(() => [
|
||||||
{ days: 1, label: '24小时' },
|
{ days: 1, label: t('dashboard.usageTrend.periodOptions.last24Hours') },
|
||||||
{ days: 7, label: '7天' },
|
{ days: 7, label: t('dashboard.usageTrend.periodOptions.last7Days') },
|
||||||
{ days: 30, label: '30天' }
|
{ days: 30, label: t('dashboard.usageTrend.periodOptions.last30Days') }
|
||||||
]
|
])
|
||||||
|
|
||||||
const createChart = () => {
|
const createChart = () => {
|
||||||
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
||||||
@@ -81,7 +87,7 @@ const createChart = () => {
|
|||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: '请求次数',
|
label: t('dashboard.usageTrend.chartLabels.requests'),
|
||||||
data: dashboardStore.trendData.map((item) => item.requests),
|
data: dashboardStore.trendData.map((item) => item.requests),
|
||||||
borderColor: '#667eea',
|
borderColor: '#667eea',
|
||||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||||
@@ -89,7 +95,7 @@ const createChart = () => {
|
|||||||
tension: 0.4
|
tension: 0.4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Token使用量',
|
label: t('dashboard.usageTrend.chartLabels.tokens'),
|
||||||
data: dashboardStore.trendData.map((item) => item.tokens),
|
data: dashboardStore.trendData.map((item) => item.tokens),
|
||||||
borderColor: '#f093fb',
|
borderColor: '#f093fb',
|
||||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||||
@@ -127,7 +133,7 @@ const createChart = () => {
|
|||||||
position: 'left',
|
position: 'left',
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: '请求次数'
|
text: t('dashboard.usageTrend.chartLabels.requestsAxis')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
@@ -136,7 +142,7 @@ const createChart = () => {
|
|||||||
position: 'right',
|
position: 'right',
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Token使用量'
|
text: t('dashboard.usageTrend.chartLabels.tokensAxis')
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
drawOnChartArea: false
|
drawOnChartArea: false
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
@click="userMenuOpen = !userMenuOpen"
|
@click="userMenuOpen = !userMenuOpen"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user-circle text-sm sm:text-base" />
|
<i class="fas fa-user-circle text-sm sm:text-base" />
|
||||||
<span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span>
|
<span class="hidden sm:inline">{{ currentUser.username || t('common.admin') }}</span>
|
||||||
<i
|
<i
|
||||||
class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
|
class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
|
||||||
:class="{ 'rotate-180': userMenuOpen }"
|
:class="{ 'rotate-180': userMenuOpen }"
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
|
class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
|
||||||
disabled
|
disabled
|
||||||
type="text"
|
type="text"
|
||||||
:value="currentUser.username || 'Admin'"
|
:value="currentUser.username || t('common.admin')"
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('header.changePasswordModal.currentUsernameHint') }}
|
{{ t('header.changePasswordModal.currentUsernameHint') }}
|
||||||
@@ -298,7 +298,7 @@ const authStore = useAuthStore()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 当前用户信息
|
// 当前用户信息
|
||||||
const currentUser = computed(() => authStore.user || { username: 'Admin' })
|
const currentUser = computed(() => authStore.user || {})
|
||||||
|
|
||||||
// OEM设置
|
// OEM设置
|
||||||
const oemSettings = computed(() => authStore.oemSettings || {})
|
const oemSettings = computed(() => authStore.oemSettings || {})
|
||||||
|
|||||||
@@ -22,10 +22,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick, computed } from 'vue'
|
import { ref, watch, nextTick, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
import TabBar from './TabBar.vue'
|
import TabBar from './TabBar.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -124,7 +127,7 @@ const handleTabChange = async (tabKey) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 如果路由切换失败,恢复activeTab状态
|
// 如果路由切换失败,恢复activeTab状态
|
||||||
if (err.name !== 'NavigationDuplicated') {
|
if (err.name !== 'NavigationDuplicated') {
|
||||||
console.error('路由切换失败:', err)
|
console.error(t('layout.mainLayout.routing.routeChangeError'), err)
|
||||||
// 恢复到当前路由对应的tab
|
// 恢复到当前路由对应的tab
|
||||||
initActiveTab()
|
initActiveTab()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,11 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
activeTab: {
|
activeTab: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -54,24 +57,49 @@ const authStore = useAuthStore()
|
|||||||
// 根据 LDAP 配置动态生成 tabs
|
// 根据 LDAP 配置动态生成 tabs
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
const baseTabs = [
|
const baseTabs = [
|
||||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
{
|
||||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
key: 'dashboard',
|
||||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
|
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'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// 只有在 LDAP 启用时才显示用户管理
|
// 只有在 LDAP 启用时才显示用户管理
|
||||||
if (authStore.oemSettings?.ldapEnabled) {
|
if (authStore.oemSettings?.ldapEnabled) {
|
||||||
baseTabs.push({
|
baseTabs.push({
|
||||||
key: 'userManagement',
|
key: 'userManagement',
|
||||||
name: '用户管理',
|
name: t('layout.tabBar.tabs.userManagement.name'),
|
||||||
shortName: '用户',
|
shortName: t('layout.tabBar.tabs.userManagement.shortName'),
|
||||||
icon: 'fas fa-users'
|
icon: 'fas fa-users'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
baseTabs.push(
|
baseTabs.push(
|
||||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
{
|
||||||
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
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'
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return baseTabs
|
return baseTabs
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
>
|
>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
|
<h3 class="text-lg font-medium text-gray-900">{{ t('user.createApiKeyModal.title') }}</h3>
|
||||||
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -23,13 +23,16 @@
|
|||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
|
<label class="block text-sm font-medium text-gray-700" for="name">
|
||||||
|
{{ t('user.createApiKeyModal.form.nameLabel') }}
|
||||||
|
{{ t('user.createApiKeyModal.form.nameRequired') }}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
placeholder="Enter API key name"
|
:placeholder="t('user.createApiKeyModal.form.namePlaceholder')"
|
||||||
required
|
required
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
@@ -37,14 +40,14 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700" for="description">
|
<label class="block text-sm font-medium text-gray-700" for="description">
|
||||||
Description
|
{{ t('user.createApiKeyModal.form.descriptionLabel') }}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
placeholder="Optional description"
|
:placeholder="t('user.createApiKeyModal.form.descriptionPlaceholder')"
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ t('user.createApiKeyModal.buttons.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
@@ -101,9 +104,9 @@
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
Creating...
|
{{ t('user.createApiKeyModal.buttons.creating') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Create API Key</span>
|
<span v-else>{{ t('user.createApiKeyModal.buttons.createApiKey') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -121,11 +124,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1">
|
<div class="ml-3 flex-1">
|
||||||
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
|
<h4 class="text-sm font-medium text-green-800">
|
||||||
|
{{ t('user.createApiKeyModal.success.title') }}
|
||||||
|
</h4>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<p class="mb-2 text-sm text-green-700">
|
<p class="mb-2 text-sm text-green-700">
|
||||||
<strong>Important:</strong> Copy your API key now. You won't be able to see it
|
<strong>{{ t('user.createApiKeyModal.success.warning.important') }}</strong>
|
||||||
again!
|
{{ t('user.createApiKeyModal.success.warning.message') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="rounded-md border border-green-300 bg-white p-3">
|
<div class="rounded-md border border-green-300 bg-white p-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -149,7 +154,7 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Copy
|
{{ t('user.createApiKeyModal.buttons.copy') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +164,7 @@
|
|||||||
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
class="rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
>
|
>
|
||||||
Done
|
{{ t('user.createApiKeyModal.buttons.done') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,6 +177,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
@@ -184,6 +190,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close', 'created'])
|
const emit = defineEmits(['close', 'created'])
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -204,7 +211,7 @@ const resetForm = () => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
error.value = 'API key name is required'
|
error.value = t('user.createApiKeyModal.validation.nameRequired')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,13 +228,14 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
newApiKey.value = result.apiKey
|
newApiKey.value = result.apiKey
|
||||||
showToast('API key created successfully!', 'success')
|
showToast(t('user.createApiKeyModal.messages.createSuccess'), 'success')
|
||||||
} else {
|
} else {
|
||||||
error.value = result.message || 'Failed to create API key'
|
error.value = result.message || t('user.createApiKeyModal.errors.createFailed')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create API key error:', err)
|
console.error('Create API key error:', err)
|
||||||
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
|
error.value =
|
||||||
|
err.response?.data?.message || err.message || t('user.createApiKeyModal.errors.createFailed')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -236,10 +244,10 @@ const handleSubmit = async () => {
|
|||||||
const copyToClipboard = async (text) => {
|
const copyToClipboard = async (text) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
showToast('API key copied to clipboard!', 'success')
|
showToast(t('user.createApiKeyModal.messages.copySuccess'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy:', err)
|
console.error('Failed to copy:', err)
|
||||||
showToast('Failed to copy to clipboard', 'error')
|
showToast(t('user.createApiKeyModal.messages.copyFailed'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ t('user.userApiKeysManager.title') }}
|
||||||
|
</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">
|
<p class="mt-2 text-sm text-gray-700">
|
||||||
Manage your API keys to access Claude Relay services
|
{{ t('user.userApiKeysManager.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Create API Key
|
{{ t('user.userApiKeysManager.buttons.createApiKey') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,8 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-yellow-700">
|
<p class="text-sm text-yellow-700">
|
||||||
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
|
{{ t('user.userApiKeysManager.warnings.maxKeysReached', { maxApiKeys }) }}
|
||||||
existing key to create a new one.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
|
<p class="mt-2 text-sm text-gray-500">{{ t('user.userApiKeysManager.loading') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys List -->
|
<!-- API Keys List -->
|
||||||
@@ -100,29 +101,37 @@
|
|||||||
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||||
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
|
class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
|
||||||
>
|
>
|
||||||
Deleted
|
{{ t('user.userApiKeysManager.status.deleted') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="!apiKey.isActive"
|
v-else-if="!apiKey.isActive"
|
||||||
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
|
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
|
||||||
>
|
>
|
||||||
Deleted
|
{{ t('user.userApiKeysManager.status.deleted') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ apiKey.description || t('user.userApiKeysManager.status.noDescription') }}
|
||||||
|
</p>
|
||||||
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
|
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
|
||||||
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
|
<span
|
||||||
|
>{{ t('user.userApiKeysManager.dateLabels.created') }}:
|
||||||
|
{{ formatDate(apiKey.createdAt) }}</span
|
||||||
|
>
|
||||||
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
|
||||||
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
|
>{{ t('user.userApiKeysManager.dateLabels.deleted') }}:
|
||||||
|
{{ formatDate(apiKey.deletedAt) }}</span
|
||||||
>
|
>
|
||||||
<span v-else-if="apiKey.lastUsedAt"
|
<span v-else-if="apiKey.lastUsedAt"
|
||||||
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
|
>{{ t('user.userApiKeysManager.dateLabels.lastUsed') }}:
|
||||||
|
{{ formatDate(apiKey.lastUsedAt) }}</span
|
||||||
>
|
>
|
||||||
<span v-else>Never used</span>
|
<span v-else>{{ t('user.userApiKeysManager.status.neverUsed') }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
|
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
|
||||||
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
|
>{{ t('user.userApiKeysManager.dateLabels.expires') }}:
|
||||||
|
{{ formatDate(apiKey.expiresAt) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +140,10 @@
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<!-- Usage Stats -->
|
<!-- Usage Stats -->
|
||||||
<div class="text-right text-xs text-gray-500">
|
<div class="text-right text-xs text-gray-500">
|
||||||
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
|
<div>
|
||||||
|
{{ formatNumber(apiKey.usage?.requests || 0) }}
|
||||||
|
{{ t('user.userApiKeysManager.usage.requests') }}
|
||||||
|
</div>
|
||||||
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
|
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,7 +151,7 @@
|
|||||||
<div class="flex items-center space-x-1">
|
<div class="flex items-center space-x-1">
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
|
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
|
||||||
title="View API Key"
|
:title="t('user.userApiKeysManager.actions.viewApiKey')"
|
||||||
@click="showApiKey(apiKey)"
|
@click="showApiKey(apiKey)"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -165,7 +177,7 @@
|
|||||||
allowUserDeleteApiKeys
|
allowUserDeleteApiKeys
|
||||||
"
|
"
|
||||||
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
||||||
title="Delete API Key"
|
:title="t('user.userApiKeysManager.actions.deleteApiKey')"
|
||||||
@click="deleteApiKey(apiKey)"
|
@click="deleteApiKey(apiKey)"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -199,8 +211,12 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||||
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
|
{{ t('user.userApiKeysManager.emptyState.title') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ t('user.userApiKeysManager.emptyState.description') }}
|
||||||
|
</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
@@ -214,7 +230,7 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Create API Key
|
{{ t('user.userApiKeysManager.buttons.createApiKey') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,10 +252,10 @@
|
|||||||
<!-- Confirm Delete Modal -->
|
<!-- Confirm Delete Modal -->
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
confirm-class="bg-red-600 hover:bg-red-700"
|
confirm-class="bg-red-600 hover:bg-red-700"
|
||||||
confirm-text="Delete"
|
:confirm-text="t('user.userApiKeysManager.buttons.delete')"
|
||||||
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
|
:message="t('user.userApiKeysManager.confirmDelete.message', { name: selectedApiKey?.name })"
|
||||||
:show="showDeleteModal"
|
:show="showDeleteModal"
|
||||||
title="Delete API Key"
|
:title="t('user.userApiKeysManager.confirmDelete.title')"
|
||||||
@cancel="showDeleteModal = false"
|
@cancel="showDeleteModal = false"
|
||||||
@confirm="handleDeleteConfirm"
|
@confirm="handleDeleteConfirm"
|
||||||
/>
|
/>
|
||||||
@@ -248,12 +264,15 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
||||||
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -291,7 +310,12 @@ const formatNumber = (num) => {
|
|||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return null
|
if (!dateString) return null
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
const localeMap = {
|
||||||
|
'zh-cn': 'zh-CN',
|
||||||
|
'zh-tw': 'zh-TW',
|
||||||
|
en: 'en-US'
|
||||||
|
}
|
||||||
|
return new Date(dateString).toLocaleDateString(localeMap[locale.value] || 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -306,7 +330,7 @@ const loadApiKeys = async () => {
|
|||||||
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
|
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load API keys:', error)
|
console.error('Failed to load API keys:', error)
|
||||||
showToast('Failed to load API keys', 'error')
|
showToast(t('user.userApiKeysManager.messages.loadFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -327,12 +351,12 @@ const handleDeleteConfirm = async () => {
|
|||||||
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
|
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast('API key deleted successfully', 'success')
|
showToast(t('user.userApiKeysManager.messages.deleteSuccess'), 'success')
|
||||||
await loadApiKeys()
|
await loadApiKeys()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete API key:', error)
|
console.error('Failed to delete API key:', error)
|
||||||
showToast('Failed to delete API key', 'error')
|
showToast(t('user.userApiKeysManager.messages.deleteFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
showDeleteModal.value = false
|
showDeleteModal.value = false
|
||||||
selectedApiKey.value = null
|
selectedApiKey.value = null
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">{{ t('user.userUsageStats.title') }}</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
|
<p class="mt-2 text-sm text-gray-700">{{ t('user.userUsageStats.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
<select
|
<select
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||||
@change="loadUsageStats"
|
@change="loadUsageStats"
|
||||||
>
|
>
|
||||||
<option value="day">Last 24 Hours</option>
|
<option value="day">{{ t('user.userUsageStats.periodSelection.day') }}</option>
|
||||||
<option value="week">Last 7 Days</option>
|
<option value="week">{{ t('user.userUsageStats.periodSelection.week') }}</option>
|
||||||
<option value="month">Last 30 Days</option>
|
<option value="month">{{ t('user.userUsageStats.periodSelection.month') }}</option>
|
||||||
<option value="quarter">Last 90 Days</option>
|
<option value="quarter">{{ t('user.userUsageStats.periodSelection.quarter') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
|
<p class="mt-2 text-sm text-gray-500">{{ t('user.userUsageStats.loadingStats') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
@@ -66,7 +66,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
|
<dt class="truncate text-sm font-medium text-gray-500">
|
||||||
|
{{ t('user.userUsageStats.statsCards.totalRequests') }}
|
||||||
|
</dt>
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
{{ formatNumber(usageStats?.totalRequests || 0) }}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -96,7 +98,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
|
<dt class="truncate text-sm font-medium text-gray-500">
|
||||||
|
{{ t('user.userUsageStats.statsCards.inputTokens') }}
|
||||||
|
</dt>
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -126,7 +130,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
|
<dt class="truncate text-sm font-medium text-gray-500">
|
||||||
|
{{ t('user.userUsageStats.statsCards.outputTokens') }}
|
||||||
|
</dt>
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -156,7 +162,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
|
<dt class="truncate text-sm font-medium text-gray-500">
|
||||||
|
{{ t('user.userUsageStats.statsCards.totalCost') }}
|
||||||
|
</dt>
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
${{ (usageStats?.totalCost || 0).toFixed(4) }}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -170,7 +178,9 @@
|
|||||||
<!-- Daily Usage Chart -->
|
<!-- Daily Usage Chart -->
|
||||||
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
|
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
|
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{{ t('user.userUsageStats.usageTrend.title') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
|
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
|
||||||
<div
|
<div
|
||||||
@@ -190,10 +200,14 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||||
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
|
{{ t('user.userUsageStats.usageTrend.chartTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ t('user.userUsageStats.usageTrend.dailyTrendsDescription') }}
|
||||||
|
</p>
|
||||||
<p class="mt-2 text-xs text-gray-400">
|
<p class="mt-2 text-xs text-gray-400">
|
||||||
(Chart integration can be added with Chart.js, D3.js, or similar library)
|
{{ t('user.userUsageStats.usageTrend.chartIntegrationNote') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +220,9 @@
|
|||||||
class="rounded-lg bg-white shadow"
|
class="rounded-lg bg-white shadow"
|
||||||
>
|
>
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
|
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{{ t('user.userUsageStats.modelUsage.title') }}
|
||||||
|
</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="model in usageStats.modelStats"
|
v-for="model in usageStats.modelStats"
|
||||||
@@ -222,7 +238,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
|
<p class="text-sm text-gray-900">
|
||||||
|
{{
|
||||||
|
t('user.userUsageStats.modelUsage.requestsCount', {
|
||||||
|
count: formatNumber(model.requests)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
|
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +255,9 @@
|
|||||||
<!-- Detailed Usage Table -->
|
<!-- Detailed Usage Table -->
|
||||||
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
|
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
|
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{{ t('user.userUsageStats.apiKeyUsage.title') }}
|
||||||
|
</h3>
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
@@ -242,37 +266,37 @@
|
|||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
API Key
|
{{ t('user.userUsageStats.apiKeyUsage.headers.apiKey') }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
Requests
|
{{ t('user.userUsageStats.apiKeyUsage.headers.requests') }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
Input Tokens
|
{{ t('user.userUsageStats.apiKeyUsage.headers.inputTokens') }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
Output Tokens
|
{{ t('user.userUsageStats.apiKeyUsage.headers.outputTokens') }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
Cost
|
{{ t('user.userUsageStats.apiKeyUsage.headers.cost') }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
Status
|
{{ t('user.userUsageStats.apiKeyUsage.headers.status') }}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -307,10 +331,10 @@
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
apiKey.isDeleted === 'true' || apiKey.deletedAt
|
||||||
? 'Deleted'
|
? t('user.userUsageStats.apiKeyUsage.status.deleted')
|
||||||
: apiKey.isActive
|
: apiKey.isActive
|
||||||
? 'Active'
|
? t('user.userUsageStats.apiKeyUsage.status.active')
|
||||||
: 'Disabled'
|
: t('user.userUsageStats.apiKeyUsage.status.disabled')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -339,10 +363,11 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||||
|
{{ t('user.userUsageStats.noData.title') }}
|
||||||
|
</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
You haven't made any API requests yet. Create an API key and start using the service to see
|
{{ t('user.userUsageStats.noData.description') }}
|
||||||
usage statistics.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,9 +375,12 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -381,7 +409,7 @@ const loadUsageStats = async () => {
|
|||||||
userApiKeys.value = apiKeys
|
userApiKeys.value = apiKeys
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load usage stats:', error)
|
console.error('Failed to load usage stats:', error)
|
||||||
showToast('Failed to load usage statistics', 'error')
|
showToast(t('user.userUsageStats.loadFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
>
|
>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
|
<h3 class="text-lg font-medium text-gray-900">{{ t('user.viewApiKeyModal.title') }}</h3>
|
||||||
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
|
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -24,29 +24,35 @@
|
|||||||
<div v-if="apiKey" class="space-y-4">
|
<div v-if="apiKey" class="space-y-4">
|
||||||
<!-- API Key Name -->
|
<!-- API Key Name -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
<label class="block text-sm font-medium text-gray-700">{{
|
||||||
|
t('user.viewApiKeyModal.fields.name')
|
||||||
|
}}</label>
|
||||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
|
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div v-if="apiKey.description">
|
<div v-if="apiKey.description">
|
||||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
<label class="block text-sm font-medium text-gray-700">{{
|
||||||
|
t('user.viewApiKeyModal.fields.description')
|
||||||
|
}}</label>
|
||||||
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
|
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Key -->
|
<!-- API Key -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">API Key</label>
|
<label class="block text-sm font-medium text-gray-700">{{
|
||||||
|
t('user.viewApiKeyModal.fields.apiKey')
|
||||||
|
}}</label>
|
||||||
<div class="mt-1 flex items-center space-x-2">
|
<div class="mt-1 flex items-center space-x-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||||
<code class="break-all font-mono text-sm text-gray-900">{{
|
<code class="break-all font-mono text-sm text-gray-900">{{
|
||||||
apiKey.key || 'Not available'
|
apiKey.key || t('user.viewApiKeyModal.apiKeyDisplay.notAvailable')
|
||||||
}}</code>
|
}}</code>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
|
||||||
<code class="font-mono text-sm text-gray-900">{{
|
<code class="font-mono text-sm text-gray-900">{{
|
||||||
apiKey.keyPreview || 'cr_****'
|
apiKey.keyPreview || t('user.viewApiKeyModal.apiKeyDisplay.keyPreview')
|
||||||
}}</code>
|
}}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +96,11 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ showFullKey ? 'Hide' : 'Show' }}
|
{{
|
||||||
|
showFullKey
|
||||||
|
? t('user.viewApiKeyModal.buttons.hide')
|
||||||
|
: t('user.viewApiKeyModal.buttons.show')
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="showFullKey && apiKey.key"
|
v-if="showFullKey && apiKey.key"
|
||||||
@@ -105,18 +115,20 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Copy
|
{{ t('user.viewApiKeyModal.buttons.copy') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
|
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
|
||||||
Full API key is only shown when first created or regenerated
|
{{ t('user.viewApiKeyModal.apiKeyDisplay.fullKeyNotice') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">Status</label>
|
<label class="block text-sm font-medium text-gray-700">{{
|
||||||
|
t('user.viewApiKeyModal.fields.status')
|
||||||
|
}}</label>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
@@ -124,33 +136,47 @@
|
|||||||
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
|
{{
|
||||||
|
apiKey.isActive
|
||||||
|
? t('user.viewApiKeyModal.status.active')
|
||||||
|
: t('user.viewApiKeyModal.status.disabled')
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Usage Stats -->
|
<!-- Usage Stats -->
|
||||||
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
|
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
|
<label class="mb-2 block text-sm font-medium text-gray-700">{{
|
||||||
|
t('user.viewApiKeyModal.fields.usageStatistics')
|
||||||
|
}}</label>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Requests:</span>
|
<span class="text-gray-500"
|
||||||
|
>{{ t('user.viewApiKeyModal.usageStats.requests') }}:</span
|
||||||
|
>
|
||||||
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
|
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Input Tokens:</span>
|
<span class="text-gray-500"
|
||||||
|
>{{ t('user.viewApiKeyModal.usageStats.inputTokens') }}:</span
|
||||||
|
>
|
||||||
<span class="ml-2 font-medium">{{
|
<span class="ml-2 font-medium">{{
|
||||||
formatNumber(apiKey.usage.inputTokens || 0)
|
formatNumber(apiKey.usage.inputTokens || 0)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Output Tokens:</span>
|
<span class="text-gray-500"
|
||||||
|
>{{ t('user.viewApiKeyModal.usageStats.outputTokens') }}:</span
|
||||||
|
>
|
||||||
<span class="ml-2 font-medium">{{
|
<span class="ml-2 font-medium">{{
|
||||||
formatNumber(apiKey.usage.outputTokens || 0)
|
formatNumber(apiKey.usage.outputTokens || 0)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Total Cost:</span>
|
<span class="text-gray-500"
|
||||||
|
>{{ t('user.viewApiKeyModal.usageStats.totalCost') }}:</span
|
||||||
|
>
|
||||||
<span class="ml-2 font-medium"
|
<span class="ml-2 font-medium"
|
||||||
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
|
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
|
||||||
>
|
>
|
||||||
@@ -161,15 +187,17 @@
|
|||||||
<!-- Timestamps -->
|
<!-- Timestamps -->
|
||||||
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
|
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-gray-500">Created:</span>
|
<span class="text-gray-500">{{ t('user.viewApiKeyModal.timestamps.created') }}:</span>
|
||||||
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
|
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
|
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
|
||||||
<span class="text-gray-500">Last Used:</span>
|
<span class="text-gray-500"
|
||||||
|
>{{ t('user.viewApiKeyModal.timestamps.lastUsed') }}:</span
|
||||||
|
>
|
||||||
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
|
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="apiKey.expiresAt" class="flex justify-between">
|
<div v-if="apiKey.expiresAt" class="flex justify-between">
|
||||||
<span class="text-gray-500">Expires:</span>
|
<span class="text-gray-500">{{ t('user.viewApiKeyModal.timestamps.expires') }}:</span>
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'font-medium',
|
'font-medium',
|
||||||
@@ -186,7 +214,7 @@
|
|||||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
Close
|
{{ t('user.viewApiKeyModal.buttons.close') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +225,11 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -225,22 +256,26 @@ const formatNumber = (num) => {
|
|||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return null
|
if (!dateString) return null
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
const { locale } = useI18n()
|
||||||
year: 'numeric',
|
return new Date(dateString).toLocaleDateString(
|
||||||
month: 'short',
|
locale.value === 'zh-cn' ? 'zh-CN' : locale.value === 'zh-tw' ? 'zh-TW' : 'en-US',
|
||||||
day: 'numeric',
|
{
|
||||||
hour: '2-digit',
|
year: 'numeric',
|
||||||
minute: '2-digit'
|
month: 'short',
|
||||||
})
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyToClipboard = async (text) => {
|
const copyToClipboard = async (text) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
showToast('Copied to clipboard!', 'success')
|
showToast(t('user.viewApiKeyModal.messages.copySuccess'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy:', err)
|
console.error('Failed to copy:', err)
|
||||||
showToast('Failed to copy to clipboard', 'error')
|
showToast(t('user.viewApiKeyModal.messages.copyFailed'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -35,6 +35,28 @@ function getBrowserLocale() {
|
|||||||
const savedLocale = localStorage.getItem('app-locale')
|
const savedLocale = localStorage.getItem('app-locale')
|
||||||
const defaultLocale = savedLocale || getBrowserLocale()
|
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 = {
|
export const SUPPORTED_LOCALES = {
|
||||||
'zh-cn': {
|
'zh-cn': {
|
||||||
name: '简体中文',
|
name: '简体中文',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { i18n, SUPPORTED_LOCALES } from '@/i18n'
|
import { i18n, SUPPORTED_LOCALES, getSupportedLocalesWithI18n } from '@/i18n'
|
||||||
|
|
||||||
export const useLocaleStore = defineStore('locale', () => {
|
export const useLocaleStore = defineStore('locale', () => {
|
||||||
const currentLocale = ref(i18n.global.locale.value)
|
const currentLocale = ref(i18n.global.locale.value)
|
||||||
@@ -20,14 +20,19 @@ export const useLocaleStore = defineStore('locale', () => {
|
|||||||
document.documentElement.setAttribute('lang', locale)
|
document.documentElement.setAttribute('lang', locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前语言信息
|
// 获取当前语言信息(兼容i18n)
|
||||||
const getCurrentLocaleInfo = () => {
|
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']
|
return SUPPORTED_LOCALES[currentLocale.value] || SUPPORTED_LOCALES['zh-cn']
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有支持的语言
|
// 获取所有支持的语言(兼容i18n)
|
||||||
const getSupportedLocales = () => {
|
const getSupportedLocales = (t = null) => {
|
||||||
return Object.entries(SUPPORTED_LOCALES).map(([key, value]) => ({
|
const supportedLocales = t ? getSupportedLocalesWithI18n(t) : SUPPORTED_LOCALES
|
||||||
|
return Object.entries(supportedLocales).map(([key, value]) => ({
|
||||||
code: key,
|
code: key,
|
||||||
...value
|
...value
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user