resolve: 解决与upstream/dev的合并冲突

- 合并admin.js中的groupIds和autoStopOnWarning参数
- 统一AccountForm.vue中的错误提示文案和平台判断逻辑
- 保留AccountsView.vue中的分组过滤和ungrouped功能
- 确保Azure OpenAI账户创建和更新逻辑完整性

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sczheng189
2025-09-02 20:32:42 +08:00
66 changed files with 11527 additions and 1581 deletions

View File

@@ -556,7 +556,17 @@
>
<div class="flex flex-wrap gap-2">
<label
v-for="model in ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']"
v-for="model in [
'gpt-4',
'gpt-4-turbo',
'gpt-4o',
'gpt-4o-mini',
'gpt-5',
'gpt-5-mini',
'gpt-35-turbo',
'gpt-35-turbo-16k',
'codex-mini'
]"
:key="model"
class="flex cursor-pointer items-center"
>
@@ -825,6 +835,25 @@
</p>
</div>
<!-- Claude 5小时限制自动停止调度选项 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.autoStopOnWarning"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
5小时使用量接近限制时自动停止调度
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当系统检测到账户接近5小时使用限制时自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。
</p>
</div>
</label>
</div>
<!-- 所有平台的优先级设置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -1364,6 +1393,25 @@
</p>
</div>
<!-- Claude 5小时限制自动停止调度选项编辑模式 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.autoStopOnWarning"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
5小时使用量接近限制时自动停止调度
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当系统检测到账户接近5小时使用限制时自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。
</p>
</div>
</label>
</div>
<!-- 所有平台的优先级设置(编辑模式) -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -1660,9 +1708,112 @@
</div>
</div>
<!-- Azure OpenAI 特定字段(编辑模式)-->
<div v-if="form.platform === 'azure_openai'" class="space-y-4">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Azure Endpoint</label
>
<input
v-model="form.azureEndpoint"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.azureEndpoint }"
placeholder="https://your-resource.openai.azure.com"
type="url"
/>
<p v-if="errors.azureEndpoint" class="mt-1 text-xs text-red-500">
{{ errors.azureEndpoint }}
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API 版本</label
>
<input
v-model="form.apiVersion"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="2024-02-01"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Azure OpenAI API 版本,默认使用最新稳定版本 2024-02-01
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>部署名称</label
>
<input
v-model="form.deploymentName"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.deploymentName }"
placeholder="gpt-4"
type="text"
/>
<p v-if="errors.deploymentName" class="mt-1 text-xs text-red-500">
{{ errors.deploymentName }}
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API Key</label
>
<input
v-model="form.apiKey"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.apiKey }"
placeholder="留空表示不更新"
type="password"
/>
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">
{{ errors.apiKey }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">留空表示不更新 API Key</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>支持的模型</label
>
<div class="flex flex-wrap gap-2">
<label
v-for="model in [
'gpt-4',
'gpt-4-turbo',
'gpt-4o',
'gpt-4o-mini',
'gpt-5',
'gpt-5-mini',
'gpt-35-turbo',
'gpt-35-turbo-16k',
'codex-mini'
]"
:key="model"
class="flex cursor-pointer items-center"
>
<input
v-model="form.supportedModels"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
:value="model"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model }}</span>
</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">选择此部署支持的模型类型</p>
</div>
</div>
<!-- Token 更新 -->
<div
v-if="form.platform !== 'claude-console' && form.platform !== 'bedrock'"
v-if="
form.platform !== 'claude-console' &&
form.platform !== 'bedrock' &&
form.platform !== 'azure_openai'
"
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
>
<div class="mb-4 flex items-start gap-3">
@@ -1826,6 +1977,7 @@ const form = ref({
description: props.account?.description || '',
accountType: props.account?.accountType || 'shared',
subscriptionType: 'claude_max', // 默认为 Claude Max兼容旧数据
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
groupId: '',
groupIds: [],
projectId: props.account?.projectId || '',
@@ -1839,16 +1991,16 @@ const form = ref({
priority: props.account?.priority || 50,
supportedModels: (() => {
const models = props.account?.supportedModels
if (!models) return ''
if (!models) return []
// 处理对象格式Claude Console 的新格式)
if (typeof models === 'object' && !Array.isArray(models)) {
return Object.keys(models).join('\n')
return Object.keys(models)
}
// 处理数组格式(向后兼容)
if (Array.isArray(models)) {
return models.join('\n')
return models
}
return ''
return []
})(),
userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
@@ -1859,7 +2011,11 @@ const form = ref({
region: props.account?.region || '',
sessionToken: props.account?.sessionToken || '',
defaultModel: props.account?.defaultModel || '',
smallFastModel: props.account?.smallFastModel || ''
smallFastModel: props.account?.smallFastModel || '',
// Azure OpenAI 特定字段
azureEndpoint: props.account?.azureEndpoint || '',
apiVersion: props.account?.apiVersion || '',
deploymentName: props.account?.deploymentName || ''
})
// 模型映射表数据
@@ -1896,7 +2052,9 @@ const errors = ref({
apiKey: '',
accessKeyId: '',
secretAccessKey: '',
region: ''
region: '',
azureEndpoint: '',
deploymentName: ''
})
// 计算是否可以进入下一步
@@ -2096,6 +2254,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
// Claude使用claudeAiOauth字段
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2176,15 +2335,15 @@ const createAccount = async () => {
} else if (form.value.platform === 'azure_openai') {
// Azure OpenAI 验证
if (!form.value.azureEndpoint || form.value.azureEndpoint.trim() === '') {
errors.value.azureEndpoint = 'Azure Endpoint 是必填项'
errors.value.azureEndpoint = '请填写 Azure Endpoint'
hasError = true
}
if (!form.value.deploymentName || form.value.deploymentName.trim() === '') {
errors.value.deploymentName = 'Deployment Name 是必填项'
errors.value.deploymentName = '请填写部署名称'
hasError = true
}
if (!form.value.apiKey || form.value.apiKey.trim() === '') {
errors.value.apiKey = 'API Key 是必填项'
errors.value.apiKey = '请填写 API Key'
hasError = true
}
} else if (form.value.addType === 'manual') {
@@ -2257,6 +2416,7 @@ const createAccount = async () => {
scopes: [] // 手动添加没有 scopes
}
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2360,10 +2520,12 @@ const createAccount = async () => {
} else if (form.value.platform === 'azure_openai') {
// Azure OpenAI 账户特定数据
data.azureEndpoint = form.value.azureEndpoint
data.apiKey = form.value.apiKey
data.apiVersion = form.value.apiVersion || '2024-02-01'
data.deploymentName = form.value.deploymentName
data.apiKey = form.value.apiKey
data.supportedModels = form.value.supportedModels || []
data.supportedModels = Array.isArray(form.value.supportedModels)
? form.value.supportedModels
: []
data.priority = form.value.priority || 50
data.isActive = form.value.isActive !== false
data.schedulable = form.value.schedulable !== false
@@ -2507,6 +2669,7 @@ const updateAccount = async () => {
// Claude 官方账号优先级和订阅类型更新
if (props.account.platform === 'claude') {
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2565,6 +2728,21 @@ const updateAccount = async () => {
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
}
// Azure OpenAI 特定更新
if (props.account.platform === 'azure_openai') {
data.azureEndpoint = form.value.azureEndpoint
data.apiVersion = form.value.apiVersion || '2024-02-01'
data.deploymentName = form.value.deploymentName
data.supportedModels = Array.isArray(form.value.supportedModels)
? form.value.supportedModels
: []
data.priority = form.value.priority || 50
// 只有当有新的 API Key 时才更新
if (form.value.apiKey && form.value.apiKey.trim()) {
data.apiKey = form.value.apiKey
}
}
if (props.account.platform === 'claude') {
await accountsStore.updateClaudeAccount(props.account.id, data)
} else if (props.account.platform === 'claude-console') {
@@ -2629,6 +2807,26 @@ watch(
}
)
// 监听Azure Endpoint变化清除错误
watch(
() => form.value.azureEndpoint,
() => {
if (errors.value.azureEndpoint && form.value.azureEndpoint?.trim()) {
errors.value.azureEndpoint = ''
}
}
)
// 监听Deployment Name变化清除错误
watch(
() => form.value.deploymentName,
() => {
if (errors.value.deploymentName && form.value.deploymentName?.trim()) {
errors.value.deploymentName = ''
}
}
)
// 分组相关数据
const groups = ref([])
const loadingGroups = ref(false)
@@ -2872,6 +3070,7 @@ watch(
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false,
groupId: groupId,
groupIds: [],
projectId: newAccount.projectId || '',
@@ -2884,16 +3083,16 @@ watch(
priority: newAccount.priority || 50,
supportedModels: (() => {
const models = newAccount.supportedModels
if (!models) return ''
if (!models) return []
// 处理对象格式Claude Console 的新格式)
if (typeof models === 'object' && !Array.isArray(models)) {
return Object.keys(models).join('\n')
return Object.keys(models)
}
// 处理数组格式(向后兼容)
if (Array.isArray(models)) {
return models.join('\n')
return models
}
return ''
return []
})(),
userAgent: newAccount.userAgent || '',
enableRateLimit:
@@ -2905,7 +3104,11 @@ watch(
region: newAccount.region || '',
sessionToken: '', // 编辑模式不显示现有的会话令牌
defaultModel: newAccount.defaultModel || '',
smallFastModel: newAccount.smallFastModel || ''
smallFastModel: newAccount.smallFastModel || '',
// Azure OpenAI 特定字段
azureEndpoint: newAccount.azureEndpoint || '',
apiVersion: newAccount.apiVersion || '',
deploymentName: newAccount.deploymentName || ''
}
// 如果是分组类型加载分组ID

View File

@@ -0,0 +1,254 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div class="relative top-20 mx-auto w-96 rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Change User Role</h3>
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div v-if="user" class="space-y-4">
<!-- User Info -->
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300">
<svg
class="h-6 w-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">
{{ user.displayName || user.username }}
</p>
<p class="text-sm text-gray-500">@{{ user.username }}</p>
<div class="mt-1">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
]"
>
Current: {{ user.role }}
</span>
</div>
</div>
</div>
</div>
<!-- Role Selection -->
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700"> New Role </label>
<div class="space-y-2">
<label class="flex items-center">
<input
v-model="selectedRole"
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
:disabled="loading"
type="radio"
value="user"
/>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900">User</div>
<div class="text-xs text-gray-500">Regular user with basic permissions</div>
</div>
</label>
<label class="flex items-center">
<input
v-model="selectedRole"
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
:disabled="loading"
type="radio"
value="admin"
/>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900">Administrator</div>
<div class="text-xs text-gray-500">Full access to manage users and system</div>
</div>
</label>
</div>
</div>
<!-- Warning for role changes -->
<div
v-if="selectedRole !== user.role"
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Change Warning</h3>
<div class="mt-2 text-sm text-yellow-700">
<p v-if="selectedRole === 'admin'">
Granting admin privileges will give this user full access to the system,
including the ability to manage other users and their API keys.
</p>
<p v-else>
Removing admin privileges will restrict this user to only managing their own
API keys and viewing their own usage statistics.
</p>
</div>
</div>
</div>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
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 disabled:opacity-50"
:disabled="loading"
type="button"
@click="$emit('close')"
>
Cancel
</button>
<button
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading || selectedRole === user.role"
type="submit"
>
<span v-if="loading" class="flex items-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
Updating...
</span>
<span v-else>Update Role</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
user: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'updated'])
const loading = ref(false)
const error = ref('')
const selectedRole = ref('')
const handleSubmit = async () => {
if (!props.user || selectedRole.value === props.user.role) {
return
}
loading.value = true
error.value = ''
try {
const response = await apiClient.patch(`/users/${props.user.id}/role`, {
role: selectedRole.value
})
if (response.success) {
showToast(`User role updated to ${selectedRole.value}`, 'success')
emit('updated')
} else {
error.value = response.message || 'Failed to update user role'
}
} catch (err) {
console.error('Update user role error:', err)
error.value = err.response?.data?.message || err.message || 'Failed to update user role'
} finally {
loading.value = false
}
}
// Reset form when modal is shown
watch([() => props.show, () => props.user], ([show, user]) => {
if (show && user) {
selectedRole.value = user.role
error.value = ''
loading.value = false
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,428 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div class="relative top-10 mx-auto w-4/5 max-w-4xl rounded-md border bg-white p-5 shadow-lg">
<div class="mt-3">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">
Usage Statistics - {{ user?.displayName || user?.username }}
</h3>
<p class="text-sm text-gray-500">@{{ user?.username }} {{ user?.role }}</p>
</div>
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<!-- Period Selector -->
<div class="mb-6">
<select
v-model="selectedPeriod"
class="block w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats"
>
<option value="day">Last 24 Hours</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="quarter">Last 90 Days</option>
</select>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
</div>
<!-- Stats Content -->
<div v-else class="space-y-6">
<!-- Summary Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-blue-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-blue-600">Requests</dt>
<dd class="text-lg font-medium text-blue-900">
{{ formatNumber(usageStats?.totalRequests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-green-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-green-600">Input Tokens</dt>
<dd class="text-lg font-medium text-green-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-purple-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-purple-600">Output Tokens</dt>
<dd class="text-lg font-medium text-purple-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-yellow-50 shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-yellow-600">Total Cost</dt>
<dd class="text-lg font-medium text-yellow-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- User API Keys Table -->
<div
v-if="userDetails?.apiKeys?.length > 0"
class="rounded-lg border border-gray-200 bg-white"
>
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h4 class="text-lg font-medium leading-6 text-gray-900">API Keys Usage</h4>
</div>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
API Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Requests
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Cost
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Last Used
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="apiKey in userDetails.apiKeys" :key="apiKey.id">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
</span>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
<div>In: {{ formatNumber(apiKey.usage?.inputTokens || 0) }}</div>
<div>Out: {{ formatNumber(apiKey.usage?.outputTokens || 0) }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{{ apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : 'Never' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Chart Placeholder -->
<div class="rounded-lg border border-gray-200 bg-white">
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h4 class="text-lg font-medium leading-6 text-gray-900">Usage Trend</h4>
</div>
<div class="p-6">
<div
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
<p class="mt-1 text-sm text-gray-500">
Daily usage trends for {{ selectedPeriod }} period
</p>
<p class="mt-2 text-xs text-gray-400">
(Chart integration can be added with Chart.js, D3.js, or similar library)
</p>
</div>
</div>
</div>
</div>
<!-- No Data State -->
<div v-if="usageStats && usageStats.totalRequests === 0" class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
<p class="mt-1 text-sm text-gray-500">
This user hasn't made any API requests in the selected period.
</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
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')"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
user: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
const loading = ref(false)
const selectedPeriod = ref('week')
const usageStats = ref(null)
const userDetails = ref(null)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageStats = async () => {
if (!props.user) return
loading.value = true
try {
const [statsResponse, userResponse] = await Promise.all([
apiClient.get(`/users/${props.user.id}/usage-stats`, {
params: { period: selectedPeriod.value }
}),
apiClient.get(`/users/${props.user.id}`)
])
if (statsResponse.success) {
usageStats.value = statsResponse.stats
}
if (userResponse.success) {
userDetails.value = userResponse.user
}
} catch (error) {
console.error('Failed to load user usage stats:', error)
showToast('Failed to load usage statistics', 'error')
} finally {
loading.value = false
}
}
// Watch for when modal is shown and user changes
watch([() => props.show, () => props.user], ([show, user]) => {
if (show && user) {
loadUsageStats()
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -252,17 +252,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -275,12 +275,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -336,6 +333,55 @@
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
@@ -739,11 +785,12 @@ const form = reactive({
batchCount: 10,
name: '',
description: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null,
@@ -985,14 +1032,32 @@ const createApiKey = async () => {
}
}
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续创建',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const baseData = {
description: form.description || undefined,
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
tokenLimit: 0, // 设置为0清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -1001,6 +1066,10 @@ const createApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: null,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: null,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -1009,6 +1078,10 @@ const createApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
expiresAt: form.expiresAt || undefined,
permissions: form.permissions,
tags: form.tags.length > 0 ? form.tags : undefined,

View File

@@ -166,17 +166,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -189,12 +189,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -250,6 +247,55 @@
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
@@ -632,11 +678,13 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
name: '',
tokenLimit: '',
tokenLimit: '', // 保留用于检测历史数据
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
@@ -702,13 +750,31 @@ const removeTag = (index) => {
// 更新 API Key
const updateApiKey = async () => {
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续保存',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const data = {
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
tokenLimit: 0, // 清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -717,6 +783,10 @@ const updateApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: 0,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: 0,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -725,6 +795,10 @@ const updateApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
permissions: form.permissions,
tags: form.tags
}
@@ -893,11 +967,22 @@ onMounted(async () => {
}
form.name = props.apiKey.name
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitCost = props.apiKey.rateLimitCost || ''
// 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
console.log('检测到历史Token限制请考虑设置费用限制')
}
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {

View File

@@ -196,6 +196,8 @@
时间窗口限制
</h5>
<WindowCountdown
:cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens"
label="窗口状态"

View File

@@ -33,6 +33,7 @@
</div>
</div>
<!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">Token</span>
@@ -48,6 +49,23 @@
/>
</div>
</div>
<!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">费用</span>
<span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full rounded-full bg-gray-200">
<div
class="h-1 rounded-full transition-all duration-300"
:class="getCostProgressColor()"
:style="{ width: getCostProgress() + '%' }"
/>
</div>
</div>
</div>
<!-- 额外提示信息 -->
@@ -102,6 +120,14 @@ const props = defineProps({
type: Number,
default: 0
},
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
showProgress: {
type: Boolean,
default: true
@@ -132,6 +158,7 @@ const windowState = computed(() => {
const hasRequestLimit = computed(() => props.requestLimit > 0)
const hasTokenLimit = computed(() => props.tokenLimit > 0)
const hasCostLimit = computed(() => props.costLimit > 0)
// 方法
const formatTime = (seconds) => {
@@ -196,6 +223,19 @@ const getTokenProgressColor = () => {
return 'bg-purple-500'
}
const getCostProgress = () => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = ((props.currentCost || 0) / props.costLimit) * 100
return Math.min(percentage, 100)
}
const getCostProgressColor = () => {
const progress = getCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 更新倒计时
const updateCountdown = () => {
if (props.windowEndTime && remainingSeconds.value > 0) {

View File

@@ -45,10 +45,14 @@
<div
v-if="
statsData.limits.rateLimitWindow > 0 &&
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
(statsData.limits.rateLimitRequests > 0 ||
statsData.limits.tokenLimit > 0 ||
statsData.limits.rateLimitCost > 0)
"
>
<WindowCountdown
:cost-limit="statsData.limits.rateLimitCost"
:current-cost="statsData.limits.currentWindowCost"
:current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens"
label="时间窗口限制"
@@ -64,7 +68,13 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
<span v-if="statsData.limits.rateLimitCost > 0">
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
</span>
<span v-else-if="statsData.limits.tokenLimit > 0">
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</span>
<span v-else> 仅限制请求次数 </span>
</div>
</div>

View File

@@ -20,29 +20,43 @@
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, watch, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// 根据路由设置当前激活的标签
const activeTab = ref('dashboard')
const tabRouteMap = {
dashboard: '/dashboard',
apiKeys: '/api-keys',
accounts: '/accounts',
tutorial: '/tutorial',
settings: '/settings'
}
// 根据 LDAP 配置动态生成路由映射
const tabRouteMap = computed(() => {
const baseMap = {
dashboard: '/dashboard',
apiKeys: '/api-keys',
accounts: '/accounts',
tutorial: '/tutorial',
settings: '/settings'
}
// 只有在 LDAP 启用时才包含用户管理路由
if (authStore.oemSettings?.ldapEnabled) {
baseMap.userManagement = '/user-management'
}
return baseMap
})
// 初始化当前激活的标签
const initActiveTab = () => {
const currentPath = route.path
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
const tabKey = Object.keys(tabRouteMap.value).find(
(key) => tabRouteMap.value[key] === currentPath
)
if (tabKey) {
activeTab.value = tabKey
@@ -72,7 +86,7 @@ initActiveTab()
watch(
() => route.path,
(newPath) => {
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
const tabKey = Object.keys(tabRouteMap.value).find((key) => tabRouteMap.value[key] === newPath)
if (tabKey) {
activeTab.value = tabKey
} else {
@@ -95,7 +109,7 @@ watch(
// 处理标签切换
const handleTabChange = async (tabKey) => {
// 如果已经在目标路由,不需要做任何事
if (tabRouteMap[tabKey] === route.path) {
if (tabRouteMap.value[tabKey] === route.path) {
return
}
@@ -104,7 +118,7 @@ const handleTabChange = async (tabKey) => {
// 使用 await 确保路由切换完成
try {
await router.push(tabRouteMap[tabKey])
await router.push(tabRouteMap.value[tabKey])
// 等待下一个DOM更新周期确保组件正确渲染
await nextTick()
} catch (err) {

View File

@@ -37,6 +37,9 @@
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
defineProps({
activeTab: {
type: String,
@@ -46,13 +49,33 @@ defineProps({
defineEmits(['tab-change'])
const tabs = [
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
]
const authStore = useAuthStore()
// 根据 LDAP 配置动态生成 tabs
const tabs = computed(() => {
const baseTabs = [
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
]
// 只有在 LDAP 启用时才显示用户管理
if (authStore.oemSettings?.ldapEnabled) {
baseTabs.push({
key: 'userManagement',
name: '用户管理',
shortName: '用户',
icon: 'fas fa-users'
})
}
baseTabs.push(
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
)
return baseTabs
})
</script>
<style scoped>

View File

@@ -0,0 +1,265 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
>
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Create New API Key</h3>
<button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="block text-sm font-medium text-gray-700" for="name"> Name * </label>
<input
id="name"
v-model="form.name"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
:disabled="loading"
placeholder="Enter API key name"
required
type="text"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="description">
Description
</label>
<textarea
id="description"
v-model="form.description"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
:disabled="loading"
placeholder="Optional description"
rows="3"
></textarea>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
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 disabled:opacity-50"
:disabled="loading"
type="button"
@click="$emit('close')"
>
Cancel
</button>
<button
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading || !form.name.trim()"
type="submit"
>
<span v-if="loading" class="flex items-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
Creating...
</span>
<span v-else>Create API Key</span>
</button>
</div>
</form>
<!-- Success Modal for showing the new API key -->
<div v-if="newApiKey" class="mt-6 rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3 flex-1">
<h4 class="text-sm font-medium text-green-800">API Key Created Successfully!</h4>
<div class="mt-3">
<p class="mb-2 text-sm text-green-700">
<strong>Important:</strong> Copy your API key now. You won't be able to see it
again!
</p>
<div class="rounded-md border border-green-300 bg-white p-3">
<div class="flex items-center justify-between">
<code class="break-all font-mono text-sm text-gray-900">{{
newApiKey.key
}}</code>
<button
class="ml-3 inline-flex flex-shrink-0 items-center rounded border border-transparent bg-green-100 px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
@click="copyToClipboard(newApiKey.key)"
>
<svg
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Copy
</button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
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"
>
Done
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'created'])
const userStore = useUserStore()
const loading = ref(false)
const error = ref('')
const newApiKey = ref(null)
const form = reactive({
name: '',
description: ''
})
const resetForm = () => {
form.name = ''
form.description = ''
error.value = ''
newApiKey.value = null
}
const handleSubmit = async () => {
if (!form.name.trim()) {
error.value = 'API key name is required'
return
}
loading.value = true
error.value = ''
try {
const apiKeyData = {
name: form.name.trim(),
description: form.description.trim() || undefined
}
const result = await userStore.createApiKey(apiKeyData)
if (result.success) {
newApiKey.value = result.apiKey
showToast('API key created successfully!', 'success')
} else {
error.value = result.message || 'Failed to create API key'
}
} catch (err) {
console.error('Create API key error:', err)
error.value = err.response?.data?.message || err.message || 'Failed to create API key'
} finally {
loading.value = false
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast('API key copied to clipboard!', 'success')
} catch (err) {
console.error('Failed to copy:', err)
showToast('Failed to copy to clipboard', 'error')
}
}
const handleClose = () => {
resetForm()
emit('created')
emit('close')
}
// Reset form when modal is shown
watch(
() => props.show,
(newValue) => {
if (newValue) {
resetForm()
}
}
)
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">My API Keys</h1>
<p class="mt-2 text-sm text-gray-700">
Manage your API keys to access Claude Relay services
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="inline-flex items-center justify-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 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
:disabled="activeApiKeysCount >= maxApiKeys"
@click="showCreateModal = true"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Create API Key
</button>
</div>
</div>
<!-- API Keys 数量限制提示 -->
<div
v-if="activeApiKeysCount >= maxApiKeys"
class="rounded-md border border-yellow-200 bg-yellow-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path
clip-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
You have reached the maximum number of API keys ({{ maxApiKeys }}). Please delete an
existing key to create a new one.
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading API keys...</p>
</div>
<!-- API Keys List -->
<div v-else-if="sortedApiKeys.length > 0" class="overflow-hidden bg-white shadow sm:rounded-md">
<ul class="divide-y divide-gray-200" role="list">
<li v-for="apiKey in sortedApiKeys" :key="apiKey.id" class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div
:class="[
'h-2 w-2 rounded-full',
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'bg-gray-400'
: apiKey.isActive
? 'bg-green-400'
: 'bg-red-400'
]"
></div>
</div>
<div class="ml-4">
<div class="flex items-center">
<p class="text-sm font-medium text-gray-900">{{ apiKey.name }}</p>
<span
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"
>
Deleted
</span>
<span
v-else-if="!apiKey.isActive"
class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800"
>
Deleted
</span>
</div>
<div class="mt-1">
<p class="text-sm text-gray-500">{{ apiKey.description || 'No description' }}</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-gray-400">
<span>Created: {{ formatDate(apiKey.createdAt) }}</span>
<span v-if="apiKey.isDeleted === 'true' || apiKey.deletedAt"
>Deleted: {{ formatDate(apiKey.deletedAt) }}</span
>
<span v-else-if="apiKey.lastUsedAt"
>Last used: {{ formatDate(apiKey.lastUsedAt) }}</span
>
<span v-else>Never used</span>
<span
v-if="apiKey.expiresAt && !(apiKey.isDeleted === 'true' || apiKey.deletedAt)"
>Expires: {{ formatDate(apiKey.expiresAt) }}</span
>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Usage Stats -->
<div class="text-right text-xs text-gray-500">
<div>{{ formatNumber(apiKey.usage?.requests || 0) }} requests</div>
<div v-if="apiKey.usage?.totalCost">${{ apiKey.usage.totalCost.toFixed(4) }}</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-1">
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-gray-600"
title="View API Key"
@click="showApiKey(apiKey)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<button
v-if="!(apiKey.isDeleted === 'true' || apiKey.deletedAt) && apiKey.isActive"
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
title="Delete API Key"
@click="deleteApiKey(apiKey)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Empty State -->
<div v-else class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating your first API key.</p>
<div class="mt-6">
<button
class="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="showCreateModal = true"
>
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Create API Key
</button>
</div>
</div>
<!-- Create API Key Modal -->
<CreateApiKeyModal
:show="showCreateModal"
@close="showCreateModal = false"
@created="handleApiKeyCreated"
/>
<!-- View API Key Modal -->
<ViewApiKeyModal
:api-key="selectedApiKey"
:show="showViewModal"
@close="showViewModal = false"
/>
<!-- Confirm Delete Modal -->
<ConfirmModal
confirm-class="bg-red-600 hover:bg-red-700"
confirm-text="Delete"
:message="`Are you sure you want to delete '${selectedApiKey?.name}'? This action cannot be undone.`"
:show="showDeleteModal"
title="Delete API Key"
@cancel="showDeleteModal = false"
@confirm="handleDeleteConfirm"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
import CreateApiKeyModal from './CreateApiKeyModal.vue'
import ViewApiKeyModal from './ViewApiKeyModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const userStore = useUserStore()
const loading = ref(true)
const apiKeys = ref([])
const maxApiKeys = computed(() => userStore.config?.maxApiKeysPerUser || 5)
const showCreateModal = ref(false)
const showViewModal = ref(false)
const showDeleteModal = ref(false)
const selectedApiKey = ref(null)
// Computed property to sort API keys by creation time (descending - newest first)
const sortedApiKeys = computed(() => {
return [...apiKeys.value].sort((a, b) => {
const dateA = new Date(a.createdAt)
const dateB = new Date(b.createdAt)
return dateB - dateA // Descending order
})
})
// Computed property to count only active (non-deleted) API keys
const activeApiKeysCount = computed(() => {
return apiKeys.value.filter((key) => !(key.isDeleted === 'true' || key.deletedAt)).length
})
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadApiKeys = async () => {
loading.value = true
try {
apiKeys.value = await userStore.getUserApiKeys(true) // Include deleted keys
} catch (error) {
console.error('Failed to load API keys:', error)
showToast('Failed to load API keys', 'error')
} finally {
loading.value = false
}
}
const showApiKey = (apiKey) => {
selectedApiKey.value = apiKey
showViewModal.value = true
}
const deleteApiKey = (apiKey) => {
selectedApiKey.value = apiKey
showDeleteModal.value = true
}
const handleDeleteConfirm = async () => {
try {
const result = await userStore.deleteApiKey(selectedApiKey.value.id)
if (result.success) {
showToast('API key deleted successfully', 'success')
await loadApiKeys()
}
} catch (error) {
console.error('Failed to delete API key:', error)
showToast('Failed to delete API key', 'error')
} finally {
showDeleteModal.value = false
selectedApiKey.value = null
}
}
const handleApiKeyCreated = async () => {
showCreateModal.value = false
await loadApiKeys()
}
onMounted(() => {
loadApiKeys()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,397 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Usage Statistics</h1>
<p class="mt-2 text-sm text-gray-700">View your API usage statistics and costs</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<select
v-model="selectedPeriod"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
@change="loadUsageStats"
>
<option value="day">Last 24 Hours</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="quarter">Last 90 Days</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-12 text-center">
<svg
class="mx-auto h-8 w-8 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading usage statistics...</p>
</div>
<!-- Stats Cards -->
<div v-else class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalRequests || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalInputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Output Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
{{ formatNumber(usageStats?.totalOutputTokens || 0) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
<dd class="text-lg font-medium text-gray-900">
${{ (usageStats?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Daily Usage Chart -->
<div v-if="!loading && usageStats" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Daily Usage Trend</h3>
<!-- Placeholder for chart - you can integrate Chart.js or similar -->
<div
class="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Usage Chart</h3>
<p class="mt-1 text-sm text-gray-500">Daily usage trends would be displayed here</p>
<p class="mt-2 text-xs text-gray-400">
(Chart integration can be added with Chart.js, D3.js, or similar library)
</p>
</div>
</div>
</div>
</div>
<!-- Model Usage Breakdown -->
<div
v-if="!loading && usageStats && usageStats.modelStats?.length > 0"
class="rounded-lg bg-white shadow"
>
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by Model</h3>
<div class="space-y-3">
<div
v-for="model in usageStats.modelStats"
:key="model.name"
class="flex items-center justify-between"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ model.name }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm text-gray-900">{{ formatNumber(model.requests) }} requests</p>
<p class="text-xs text-gray-500">${{ model.cost.toFixed(4) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Detailed Usage Table -->
<div v-if="!loading && userApiKeys.length > 0" class="rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<h3 class="mb-4 text-lg font-medium leading-6 text-gray-900">Usage by API Key</h3>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
API Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Requests
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Input Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Output Tokens
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Cost
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
scope="col"
>
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="apiKey in userApiKeys" :key="apiKey.id">
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ apiKey.name }}</div>
<div class="text-sm text-gray-500">{{ apiKey.keyPreview }}</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.requests || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.inputTokens || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{{ formatNumber(apiKey.usage?.outputTokens || 0) }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
${{ (apiKey.usage?.totalCost || 0).toFixed(4) }}
</td>
<td class="whitespace-nowrap px-6 py-4">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'bg-gray-100 text-gray-800'
: apiKey.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{
apiKey.isDeleted === 'true' || apiKey.deletedAt
? 'Deleted'
: apiKey.isActive
? 'Active'
: 'Disabled'
}}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- No Data State -->
<div
v-if="!loading && (!usageStats || usageStats.totalRequests === 0)"
class="py-12 text-center"
>
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No usage data</h3>
<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
usage statistics.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/toast'
const userStore = useUserStore()
const loading = ref(true)
const selectedPeriod = ref('week')
const usageStats = ref(null)
const userApiKeys = ref([])
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const loadUsageStats = async () => {
loading.value = true
try {
const [stats, apiKeys] = await Promise.all([
userStore.getUserUsageStats({ period: selectedPeriod.value }),
userStore.getUserApiKeys(true) // Include deleted keys
])
usageStats.value = stats
userApiKeys.value = apiKeys
} catch (error) {
console.error('Failed to load usage stats:', error)
showToast('Failed to load usage statistics', 'error')
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsageStats()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 h-full w-full overflow-y-auto bg-gray-600 bg-opacity-50"
>
<div
class="relative top-20 mx-auto w-[768px] max-w-4xl rounded-md border bg-white p-5 shadow-lg"
>
<div class="mt-3">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">API Key Details</h3>
<button class="text-gray-400 hover:text-gray-600" @click="emit('close')">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div v-if="apiKey" class="space-y-4">
<!-- API Key Name -->
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.name }}</p>
</div>
<!-- Description -->
<div v-if="apiKey.description">
<label class="block text-sm font-medium text-gray-700">Description</label>
<p class="mt-1 text-sm text-gray-900">{{ apiKey.description }}</p>
</div>
<!-- API Key -->
<div>
<label class="block text-sm font-medium text-gray-700">API Key</label>
<div class="mt-1 flex items-center space-x-2">
<div class="flex-1">
<div v-if="showFullKey" class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="break-all font-mono text-sm text-gray-900">{{
apiKey.key || 'Not available'
}}</code>
</div>
<div v-else class="rounded-md border border-gray-300 bg-gray-50 p-3">
<code class="font-mono text-sm text-gray-900">{{
apiKey.keyPreview || 'cr_****'
}}</code>
</div>
</div>
<div class="flex flex-col space-y-1">
<button
v-if="apiKey.key"
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="showFullKey = !showFullKey"
>
<svg
v-if="showFullKey"
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L12 12m-1.122-2.122L12 12m-1.122-2.122l-4.243-4.242m6.879 6.878L15 15"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<svg
v-else
class="mr-1 h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
{{ showFullKey ? 'Hide' : 'Show' }}
</button>
<button
v-if="showFullKey && apiKey.key"
class="inline-flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="copyToClipboard(apiKey.key)"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
Copy
</button>
</div>
</div>
<p v-if="!apiKey.key" class="mt-1 text-xs text-gray-500">
Full API key is only shown when first created or regenerated
</p>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700">Status</label>
<div class="mt-1">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
apiKey.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]"
>
{{ apiKey.isActive ? 'Active' : 'Disabled' }}
</span>
</div>
</div>
<!-- Usage Stats -->
<div v-if="apiKey.usage" class="border-t border-gray-200 pt-4">
<label class="mb-2 block text-sm font-medium text-gray-700">Usage Statistics</label>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Requests:</span>
<span class="ml-2 font-medium">{{ formatNumber(apiKey.usage.requests || 0) }}</span>
</div>
<div>
<span class="text-gray-500">Input Tokens:</span>
<span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.inputTokens || 0)
}}</span>
</div>
<div>
<span class="text-gray-500">Output Tokens:</span>
<span class="ml-2 font-medium">{{
formatNumber(apiKey.usage.outputTokens || 0)
}}</span>
</div>
<div>
<span class="text-gray-500">Total Cost:</span>
<span class="ml-2 font-medium"
>${{ (apiKey.usage.totalCost || 0).toFixed(4) }}</span
>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="space-y-2 border-t border-gray-200 pt-4 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Created:</span>
<span class="text-gray-900">{{ formatDate(apiKey.createdAt) }}</span>
</div>
<div v-if="apiKey.lastUsedAt" class="flex justify-between">
<span class="text-gray-500">Last Used:</span>
<span class="text-gray-900">{{ formatDate(apiKey.lastUsedAt) }}</span>
</div>
<div v-if="apiKey.expiresAt" class="flex justify-between">
<span class="text-gray-500">Expires:</span>
<span
:class="[
'font-medium',
new Date(apiKey.expiresAt) < new Date() ? 'text-red-600' : 'text-gray-900'
]"
>
{{ formatDate(apiKey.expiresAt) }}
</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button
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')"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from '@/utils/toast'
defineProps({
show: {
type: Boolean,
default: false
},
apiKey: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
const showFullKey = ref(false)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast('Copied to clipboard!', 'success')
} catch (err) {
console.error('Failed to copy:', err)
showToast('Failed to copy to clipboard', 'error')
}
}
</script>
<style scoped>
/* 组件特定样式 */
</style>