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

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

View File

@@ -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') }}
管理您的 ClaudeGeminiOpenAIAzure OpenAIOpenAI-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

View File

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

View File

@@ -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 () => {
// 加载所有数据

View File

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

View File

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

View File

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

View File

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