mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 实现账户分组管理功能和优化响应式设计
主要更新: - 实现账户分组管理功能,支持创建、编辑、删除分组 - 支持将账户添加到分组进行统一调度 - 优化 API Keys 页面响应式设计,解决操作栏被隐藏的问题 - 优化账户管理页面布局,合并平台/类型列,改进操作按钮布局 - 修复代理信息显示溢出问题 - 改进表格列宽分配,充分利用屏幕空间 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -159,12 +159,50 @@
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.accountType"
|
||||
type="radio"
|
||||
value="group"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">分组调度</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用;分组调度:加入分组供分组内调度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 分组选择器 -->
|
||||
<div v-if="form.accountType === 'group'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">选择分组 *</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="form.groupId"
|
||||
class="form-input flex-1"
|
||||
required
|
||||
>
|
||||
<option value="">请选择分组</option>
|
||||
<option
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
<option value="__new__">+ 新建分组</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
@click="refreshGroups"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
@@ -555,12 +593,50 @@
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.accountType"
|
||||
type="radio"
|
||||
value="group"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">分组调度</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用;分组调度:加入分组供分组内调度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 分组选择器 -->
|
||||
<div v-if="form.accountType === 'group'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">选择分组 *</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="form.groupId"
|
||||
class="form-input flex-1"
|
||||
required
|
||||
>
|
||||
<option value="">请选择分组</option>
|
||||
<option
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
<option value="__new__">+ 新建分组</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
@click="refreshGroups"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
@@ -813,17 +889,26 @@
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
<!-- 分组管理模态框 -->
|
||||
<GroupManagementModal
|
||||
v-if="showGroupManagement"
|
||||
@close="showGroupManagement = false"
|
||||
@refresh="handleGroupRefresh"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
import OAuthFlow from './OAuthFlow.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import GroupManagementModal from './GroupManagementModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
account: {
|
||||
@@ -874,6 +959,7 @@ const form = ref({
|
||||
name: props.account?.name || '',
|
||||
description: props.account?.description || '',
|
||||
accountType: props.account?.accountType || 'shared',
|
||||
groupId: '',
|
||||
projectId: props.account?.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
@@ -941,6 +1027,12 @@ const nextStep = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 分组类型验证
|
||||
if (form.value.accountType === 'group' && (!form.value.groupId || form.value.groupId.trim() === '')) {
|
||||
showToast('请选择一个分组', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
@@ -968,6 +1060,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||
proxy: form.value.proxy.enabled ? {
|
||||
type: form.value.proxy.type,
|
||||
host: form.value.proxy.host,
|
||||
@@ -1034,6 +1127,12 @@ const createAccount = async () => {
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// 分组类型验证
|
||||
if (form.value.accountType === 'group' && (!form.value.groupId || form.value.groupId.trim() === '')) {
|
||||
showToast('请选择一个分组', 'error')
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return
|
||||
}
|
||||
@@ -1044,6 +1143,7 @@ const createAccount = async () => {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||
proxy: form.value.proxy.enabled ? {
|
||||
type: form.value.proxy.type,
|
||||
host: form.value.proxy.host,
|
||||
@@ -1121,6 +1221,12 @@ const updateAccount = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 分组类型验证
|
||||
if (form.value.accountType === 'group' && (!form.value.groupId || form.value.groupId.trim() === '')) {
|
||||
showToast('请选择一个分组', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
@@ -1143,6 +1249,7 @@ const updateAccount = async () => {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||
proxy: form.value.proxy.enabled ? {
|
||||
type: form.value.proxy.type,
|
||||
host: form.value.proxy.host,
|
||||
@@ -1247,11 +1354,71 @@ watch(() => form.value.apiKey, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 分组相关数据
|
||||
const groups = ref([])
|
||||
const loadingGroups = ref(false)
|
||||
const showGroupManagement = ref(false)
|
||||
|
||||
// 根据平台筛选分组
|
||||
const filteredGroups = computed(() => {
|
||||
const platformFilter = form.value.platform === 'claude-console' ? 'claude' : form.value.platform
|
||||
return groups.value.filter(g => g.platform === platformFilter)
|
||||
})
|
||||
|
||||
// 加载分组列表
|
||||
const loadGroups = async () => {
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast('加载分组列表失败', 'error')
|
||||
groups.value = []
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新分组列表
|
||||
const refreshGroups = async () => {
|
||||
await loadGroups()
|
||||
showToast('分组列表已刷新', 'success')
|
||||
}
|
||||
|
||||
// 处理分组管理模态框刷新
|
||||
const handleGroupRefresh = async () => {
|
||||
await loadGroups()
|
||||
}
|
||||
|
||||
// 监听平台变化,重置表单
|
||||
watch(() => form.value.platform, (newPlatform) => {
|
||||
if (newPlatform === 'claude-console') {
|
||||
form.value.addType = 'manual' // Claude Console 只支持手动模式
|
||||
}
|
||||
|
||||
// 平台变化时,清空分组选择
|
||||
if (form.value.accountType === 'group') {
|
||||
form.value.groupId = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听账户类型变化
|
||||
watch(() => form.value.accountType, (newType) => {
|
||||
if (newType === 'group') {
|
||||
// 如果选择分组类型,加载分组列表
|
||||
if (groups.value.length === 0) {
|
||||
loadGroups()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听分组选择
|
||||
watch(() => form.value.groupId, (newGroupId) => {
|
||||
if (newGroupId === '__new__') {
|
||||
// 触发创建新分组
|
||||
form.value.groupId = ''
|
||||
showGroupManagement.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// 添加模型映射
|
||||
@@ -1317,6 +1484,7 @@ watch(() => props.account, (newAccount) => {
|
||||
name: newAccount.name,
|
||||
description: newAccount.description || '',
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
groupId: '',
|
||||
projectId: newAccount.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
@@ -1328,6 +1496,22 @@ watch(() => props.account, (newAccount) => {
|
||||
userAgent: newAccount.userAgent || '',
|
||||
rateLimitDuration: newAccount.rateLimitDuration || 60
|
||||
}
|
||||
|
||||
// 如果是分组类型,加载分组ID
|
||||
if (newAccount.accountType === 'group') {
|
||||
// 先加载分组列表
|
||||
loadGroups().then(() => {
|
||||
// 查找账户所属的分组
|
||||
groups.value.forEach(group => {
|
||||
apiClient.get(`/admin/account-groups/${group.id}/members`).then(response => {
|
||||
const members = response.data || []
|
||||
if (members.some(m => m.id === newAccount.id)) {
|
||||
form.value.groupId = group.id
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
418
web/admin-spa/src/components/accounts/GroupManagementModal.vue
Normal file
418
web/admin-spa/src/components/accounts/GroupManagementModal.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-4xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg sm:rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-white text-sm sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
|
||||
账户分组管理
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 添加分组按钮 -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2"
|
||||
@click="showCreateForm = true"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" />
|
||||
创建新分组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建分组表单 -->
|
||||
<div
|
||||
v-if="showCreateForm"
|
||||
class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200"
|
||||
>
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-4">创建新分组</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型 *</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="createForm.platform"
|
||||
type="radio"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="createForm.platform"
|
||||
type="radio"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
rows="2"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2"
|
||||
:disabled="!createForm.name || !createForm.platform || creating"
|
||||
@click="createGroup"
|
||||
>
|
||||
<div
|
||||
v-if="creating"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary px-4 py-2"
|
||||
@click="cancelCreate"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组列表 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
class="text-center py-8 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<i class="fas fa-layer-group text-4xl text-gray-300 mb-4" />
|
||||
<p class="text-gray-500">暂无分组</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-4 grid-cols-1 md:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-white rounded-lg border p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ group.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
group.platform === 'claude'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
]"
|
||||
>
|
||||
{{ group.platform === 'claude' ? 'Claude' : 'Gemini' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>
|
||||
<i class="fas fa-users mr-1" />
|
||||
{{ group.memberCount || 0 }} 个成员
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" />
|
||||
{{ formatDate(group.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
title="编辑"
|
||||
@click="editGroup(group)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="删除"
|
||||
:disabled="group.memberCount > 0"
|
||||
@click="deleteGroup(group)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑分组模态框 -->
|
||||
<div
|
||||
v-if="showEditForm"
|
||||
class="fixed inset-0 modal z-60 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型</label>
|
||||
<div class="px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-600">
|
||||
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
|
||||
<span class="text-xs text-gray-500 ml-2">(不可修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="2"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2 flex-1"
|
||||
:disabled="!editForm.name || updating"
|
||||
@click="updateGroup"
|
||||
>
|
||||
<div
|
||||
v-if="updating"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
{{ updating ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary px-4 py-2 flex-1"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const emit = defineEmits(['close', 'refresh'])
|
||||
|
||||
const show = ref(true)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
|
||||
// 创建表单
|
||||
const showCreateForm = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
platform: 'claude',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 编辑表单
|
||||
const showEditForm = ref(false)
|
||||
const updating = ref(false)
|
||||
const editingGroup = ref(null)
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
platform: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载分组列表
|
||||
const loadGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast('加载分组列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分组
|
||||
const createGroup = async () => {
|
||||
if (!createForm.value.name || !createForm.value.platform) {
|
||||
showToast('请填写必填项', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await apiClient.post('/admin/account-groups', {
|
||||
name: createForm.value.name,
|
||||
platform: createForm.value.platform,
|
||||
description: createForm.value.description
|
||||
})
|
||||
|
||||
showToast('分组创建成功', 'success')
|
||||
cancelCreate()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '创建分组失败', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消创建
|
||||
const cancelCreate = () => {
|
||||
showCreateForm.value = false
|
||||
createForm.value = {
|
||||
name: '',
|
||||
platform: 'claude',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑分组
|
||||
const editGroup = (group) => {
|
||||
editingGroup.value = group
|
||||
editForm.value = {
|
||||
name: group.name,
|
||||
platform: group.platform,
|
||||
description: group.description || ''
|
||||
}
|
||||
showEditForm.value = true
|
||||
}
|
||||
|
||||
// 更新分组
|
||||
const updateGroup = async () => {
|
||||
if (!editForm.value.name) {
|
||||
showToast('请填写分组名称', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
updating.value = true
|
||||
try {
|
||||
await apiClient.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description
|
||||
})
|
||||
|
||||
showToast('分组更新成功', 'success')
|
||||
cancelEdit()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '更新分组失败', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
const cancelEdit = () => {
|
||||
showEditForm.value = false
|
||||
editingGroup.value = null
|
||||
editForm.value = {
|
||||
name: '',
|
||||
platform: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
const deleteGroup = async (group) => {
|
||||
if (group.memberCount > 0) {
|
||||
showToast('分组内还有成员,无法删除', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
||||
showToast('分组删除成功', 'success')
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '删除分组失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
@@ -433,6 +433,18 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0"
|
||||
label="Claude OAuth 账号"
|
||||
@@ -469,13 +481,30 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.filter(a => a.isDedicated).length > 0"
|
||||
label="Gemini 账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -650,7 +679,7 @@ const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
const localAccounts = ref({ claude: [], gemini: [] })
|
||||
const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
|
||||
|
||||
// 表单验证状态
|
||||
const errors = ref({
|
||||
@@ -702,7 +731,9 @@ onMounted(async () => {
|
||||
if (props.accounts) {
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || []
|
||||
gemini: props.accounts.gemini || [],
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || []
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -711,10 +742,11 @@ onMounted(async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
|
||||
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -749,6 +781,13 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
|
||||
}
|
||||
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
|
||||
@@ -302,6 +302,18 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0"
|
||||
label="Claude OAuth 账号"
|
||||
@@ -338,13 +350,30 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.filter(a => a.isDedicated).length > 0"
|
||||
label="Gemini 账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,7 +555,7 @@ const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
const localAccounts = ref({ claude: [], gemini: [] })
|
||||
const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
|
||||
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
@@ -656,10 +685,11 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
|
||||
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -694,6 +724,13 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
|
||||
}
|
||||
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
@@ -712,7 +749,9 @@ onMounted(async () => {
|
||||
if (props.accounts) {
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || []
|
||||
gemini: props.accounts.gemini || [],
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,19 @@ const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/api-stats'
|
||||
redirect: () => {
|
||||
// 智能重定向:避免循环
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = APP_CONFIG.basePath.replace(/\/$/, '') // 移除末尾斜杠
|
||||
|
||||
// 如果当前路径已经是 basePath 或 basePath/,重定向到 api-stats
|
||||
if (currentPath === basePath || currentPath === basePath + '/') {
|
||||
return '/api-stats'
|
||||
}
|
||||
|
||||
// 否则保持默认重定向
|
||||
return '/api-stats'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -88,6 +100,11 @@ const routes = [
|
||||
component: SettingsView
|
||||
}
|
||||
]
|
||||
},
|
||||
// 捕获所有未匹配的路由
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/api-stats'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -103,10 +120,16 @@ router.beforeEach((to, from, next) => {
|
||||
console.log('路由导航:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
fullPath: to.fullPath,
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: authStore.isAuthenticated
|
||||
})
|
||||
|
||||
// 防止重定向循环:如果已经在目标路径,直接放行
|
||||
if (to.path === from.path && to.fullPath === from.fullPath) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// API Stats 页面不需要认证,直接放行
|
||||
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
|
||||
next()
|
||||
|
||||
@@ -11,27 +11,44 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center sm:justify-between">
|
||||
<select
|
||||
v-model="accountSortBy"
|
||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||
@change="sortAccounts()"
|
||||
>
|
||||
<option value="name">
|
||||
按名称排序
|
||||
</option>
|
||||
<option value="dailyTokens">
|
||||
按今日Token排序
|
||||
</option>
|
||||
<option value="dailyRequests">
|
||||
按今日请求数排序
|
||||
</option>
|
||||
<option value="totalTokens">
|
||||
按总Token排序
|
||||
</option>
|
||||
<option value="lastUsed">
|
||||
按最后使用排序
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
v-model="accountSortBy"
|
||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||
@change="sortAccounts()"
|
||||
>
|
||||
<option value="name">
|
||||
按名称排序
|
||||
</option>
|
||||
<option value="dailyTokens">
|
||||
按今日Token排序
|
||||
</option>
|
||||
<option value="dailyRequests">
|
||||
按今日请求数排序
|
||||
</option>
|
||||
<option value="totalTokens">
|
||||
按总Token排序
|
||||
</option>
|
||||
<option value="lastUsed">
|
||||
按最后使用排序
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="groupFilter"
|
||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||
@change="filterByGroup"
|
||||
>
|
||||
<option value="all">所有账户</option>
|
||||
<option value="ungrouped">未分组账户</option>
|
||||
<option
|
||||
v-for="group in accountGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }} ({{ group.platform === 'claude' ? 'Claude' : 'Gemini' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-success px-4 sm:px-6 py-2 sm:py-3 flex items-center gap-2 w-full sm:w-auto justify-center"
|
||||
@click.stop="openCreateAccountModal"
|
||||
@@ -69,13 +86,13 @@
|
||||
<!-- 桌面端表格视图 -->
|
||||
<div
|
||||
v-else
|
||||
class="hidden lg:block table-container"
|
||||
class="hidden md:block table-container"
|
||||
>
|
||||
<table class="min-w-full">
|
||||
<table class="w-full table-fixed">
|
||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[22%] min-w-[180px]"
|
||||
@click="sortAccounts('name')"
|
||||
>
|
||||
名称
|
||||
@@ -89,10 +106,10 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[15%] min-w-[120px]"
|
||||
@click="sortAccounts('platform')"
|
||||
>
|
||||
平台
|
||||
平台/类型
|
||||
<i
|
||||
v-if="accountsSortBy === 'platform'"
|
||||
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
|
||||
@@ -103,21 +120,7 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
@click="sortAccounts('accountType')"
|
||||
>
|
||||
类型
|
||||
<i
|
||||
v-if="accountsSortBy === 'accountType'"
|
||||
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[12%] min-w-[100px]"
|
||||
@click="sortAccounts('status')"
|
||||
>
|
||||
状态
|
||||
@@ -131,7 +134,7 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[8%] min-w-[80px]"
|
||||
@click="sortAccounts('priority')"
|
||||
>
|
||||
优先级
|
||||
@@ -144,19 +147,19 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[100px]">
|
||||
代理
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[90px]">
|
||||
今日使用
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[100px]">
|
||||
会话窗口
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[8%] min-w-[80px]">
|
||||
最后使用
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[15%] min-w-[180px]">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
@@ -167,14 +170,14 @@
|
||||
:key="account.id"
|
||||
class="table-row"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fas fa-user-circle text-white text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-sm font-semibold text-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 truncate" :title="account.name">
|
||||
{{ account.name }}
|
||||
</div>
|
||||
<span
|
||||
@@ -183,60 +186,69 @@
|
||||
>
|
||||
<i class="fas fa-lock mr-1" />专属
|
||||
</span>
|
||||
<span
|
||||
v-else-if="account.accountType === 'group'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
<i class="fas fa-layer-group mr-1" />分组调度
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
<i class="fas fa-share-alt mr-1" />共享
|
||||
</span>
|
||||
<span
|
||||
v-if="account.groupInfo"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 ml-1"
|
||||
:title="`所属分组: ${account.groupInfo.name}`"
|
||||
>
|
||||
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-gray-500 truncate" :title="account.id">
|
||||
{{ account.id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
v-if="account.platform === 'gemini'"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
<i class="fas fa-robot mr-1" />Gemini
|
||||
</span>
|
||||
<span
|
||||
v-else-if="account.platform === 'claude-console'"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800"
|
||||
>
|
||||
<i class="fas fa-terminal mr-1" />Claude Console
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800"
|
||||
>
|
||||
<i class="fas fa-brain mr-1" />Claude
|
||||
</span>
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 平台图标和名称 -->
|
||||
<div
|
||||
v-if="account.platform === 'gemini'"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-yellow-100 to-amber-100 rounded-lg border border-yellow-200"
|
||||
>
|
||||
<i class="fas fa-robot text-yellow-700 text-xs" />
|
||||
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
|
||||
<span class="w-px h-4 bg-yellow-300 mx-1"></span>
|
||||
<span class="text-xs font-medium text-yellow-700">
|
||||
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="account.platform === 'claude-console'"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-purple-100 to-pink-100 rounded-lg border border-purple-200"
|
||||
>
|
||||
<i class="fas fa-terminal text-purple-700 text-xs" />
|
||||
<span class="text-xs font-semibold text-purple-800">Console</span>
|
||||
<span class="w-px h-4 bg-purple-300 mx-1"></span>
|
||||
<span class="text-xs font-medium text-purple-700">API Key</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-indigo-100 to-blue-100 rounded-lg border border-indigo-200"
|
||||
>
|
||||
<i class="fas fa-brain text-indigo-700 text-xs" />
|
||||
<span class="text-xs font-semibold text-indigo-800">Claude</span>
|
||||
<span class="w-px h-4 bg-indigo-300 mx-1"></span>
|
||||
<span class="text-xs font-medium text-indigo-700">
|
||||
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
v-if="account.platform === 'claude-console'"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
>
|
||||
<i class="fas fa-key mr-1" />API Key
|
||||
</span>
|
||||
<span
|
||||
v-else-if="account.scopes && account.scopes.length > 0"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||
>
|
||||
<i class="fas fa-lock mr-1" />OAuth
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800"
|
||||
>
|
||||
<i class="fas fa-key mr-1" />传统
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
@@ -279,7 +291,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<div
|
||||
v-if="account.platform === 'claude' || account.platform === 'claude-console'"
|
||||
class="flex items-center gap-2"
|
||||
@@ -301,10 +313,11 @@
|
||||
<span class="text-xs">N/A</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<td class="px-3 py-4 text-sm text-gray-600">
|
||||
<div
|
||||
v-if="formatProxyDisplay(account.proxy)"
|
||||
class="text-xs bg-blue-50 px-2 py-1 rounded font-mono"
|
||||
class="text-xs bg-blue-50 px-2 py-1 rounded font-mono break-all"
|
||||
:title="formatProxyDisplay(account.proxy)"
|
||||
>
|
||||
{{ formatProxyDisplay(account.proxy) }}
|
||||
</div>
|
||||
@@ -315,7 +328,7 @@
|
||||
无代理
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||
<div
|
||||
v-if="account.usage && account.usage.daily"
|
||||
class="space-y-1"
|
||||
@@ -342,7 +355,7 @@
|
||||
暂无数据
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<div
|
||||
v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow"
|
||||
class="space-y-2"
|
||||
@@ -381,16 +394,16 @@
|
||||
<span class="text-xs">N/A</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{{ formatLastUsed(account.lastUsedAt) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
v-if="account.platform === 'claude' && account.scopes"
|
||||
:disabled="account.isRefreshing"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
|
||||
account.isRefreshing
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
@@ -404,11 +417,12 @@
|
||||
account.isRefreshing ? 'animate-spin' : ''
|
||||
]"
|
||||
/>
|
||||
<span class="ml-1">刷新</span>
|
||||
</button>
|
||||
<button
|
||||
:disabled="account.isTogglingSchedulable"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
|
||||
account.isTogglingSchedulable
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: account.schedulable
|
||||
@@ -424,18 +438,23 @@
|
||||
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
|
||||
]"
|
||||
/>
|
||||
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
|
||||
class="px-2.5 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium hover:bg-blue-200 transition-colors"
|
||||
:title="'编辑账户'"
|
||||
@click="editAccount(account)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
<span class="ml-1">编辑</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-red-100 text-red-700 rounded-lg text-xs font-medium hover:bg-red-200 transition-colors"
|
||||
class="px-2.5 py-1 bg-red-100 text-red-700 rounded text-xs font-medium hover:bg-red-200 transition-colors"
|
||||
:title="'删除账户'"
|
||||
@click="deleteAccount(account)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
<span class="ml-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -447,7 +466,7 @@
|
||||
<!-- 移动端卡片视图 -->
|
||||
<div
|
||||
v-if="!accountsLoading && sortedAccounts.length > 0"
|
||||
class="lg:hidden space-y-3"
|
||||
class="md:hidden space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="account in sortedAccounts"
|
||||
@@ -670,6 +689,9 @@ const accountsSortBy = ref('')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([])
|
||||
const refreshingTokens = ref({})
|
||||
const accountGroups = ref([])
|
||||
const groupFilter = ref('all')
|
||||
const filteredAccounts = ref([])
|
||||
|
||||
// 模态框状态
|
||||
const showCreateAccountModal = ref(false)
|
||||
@@ -678,9 +700,10 @@ const editingAccount = ref(null)
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
if (!accountsSortBy.value) return accounts.value
|
||||
const sourceAccounts = filteredAccounts.value.length > 0 ? filteredAccounts.value : accounts.value
|
||||
if (!accountsSortBy.value) return sourceAccounts
|
||||
|
||||
const sorted = [...accounts.value].sort((a, b) => {
|
||||
const sorted = [...sourceAccounts].sort((a, b) => {
|
||||
let aVal = a[accountsSortBy.value]
|
||||
let bVal = b[accountsSortBy.value]
|
||||
|
||||
@@ -720,11 +743,12 @@ const sortedAccounts = computed(() => {
|
||||
const loadAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, apiKeysData] = await Promise.all([
|
||||
const [claudeData, claudeConsoleData, geminiData, apiKeysData, groupsData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/api-keys')
|
||||
apiClient.get('/admin/api-keys'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 更新API Keys列表
|
||||
@@ -732,22 +756,49 @@ const loadAccounts = async () => {
|
||||
apiKeys.value = apiKeysData.data || []
|
||||
}
|
||||
|
||||
// 更新分组列表
|
||||
if (groupsData.success) {
|
||||
accountGroups.value = groupsData.data || []
|
||||
}
|
||||
|
||||
// 创建分组ID到分组信息的映射
|
||||
const groupMap = new Map()
|
||||
const accountGroupMap = new Map()
|
||||
|
||||
// 获取所有分组的成员信息
|
||||
for (const group of accountGroups.value) {
|
||||
groupMap.set(group.id, group)
|
||||
try {
|
||||
const membersResponse = await apiClient.get(`/admin/account-groups/${group.id}/members`)
|
||||
if (membersResponse.success) {
|
||||
const members = membersResponse.data || []
|
||||
members.forEach(member => {
|
||||
accountGroupMap.set(member.id, group)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load members for group ${group.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const allAccounts = []
|
||||
|
||||
if (claudeData.success) {
|
||||
const claudeAccounts = (claudeData.data || []).map(acc => {
|
||||
// 计算每个Claude账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(key => key.claudeAccountId === acc.id).length
|
||||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||||
// 检查是否属于某个分组
|
||||
const groupInfo = accountGroupMap.get(acc.id) || null
|
||||
return { ...acc, platform: 'claude', boundApiKeysCount, groupInfo }
|
||||
})
|
||||
allAccounts.push(...claudeAccounts)
|
||||
}
|
||||
|
||||
if (claudeConsoleData.success) {
|
||||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map(acc => {
|
||||
// 计算每个Claude Console账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(key => key.claudeConsoleAccountId === acc.id).length
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||
// Claude Console账户暂时不支持直接绑定
|
||||
const groupInfo = accountGroupMap.get(acc.id) || null
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0, groupInfo }
|
||||
})
|
||||
allAccounts.push(...claudeConsoleAccounts)
|
||||
}
|
||||
@@ -756,12 +807,15 @@ const loadAccounts = async () => {
|
||||
const geminiAccounts = (geminiData.data || []).map(acc => {
|
||||
// 计算每个Gemini账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(key => key.geminiAccountId === acc.id).length
|
||||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||||
const groupInfo = accountGroupMap.get(acc.id) || null
|
||||
return { ...acc, platform: 'gemini', boundApiKeysCount, groupInfo }
|
||||
})
|
||||
allAccounts.push(...geminiAccounts)
|
||||
}
|
||||
|
||||
accounts.value = allAccounts
|
||||
// 初始化过滤后的账户列表
|
||||
filterByGroup()
|
||||
} catch (error) {
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
@@ -819,19 +873,38 @@ const loadApiKeys = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 按分组筛选账户
|
||||
const filterByGroup = () => {
|
||||
if (groupFilter.value === 'all') {
|
||||
filteredAccounts.value = accounts.value
|
||||
} else if (groupFilter.value === 'ungrouped') {
|
||||
filteredAccounts.value = accounts.value.filter(acc => !acc.groupInfo)
|
||||
} else {
|
||||
// 按特定分组筛选
|
||||
filteredAccounts.value = accounts.value.filter(acc =>
|
||||
acc.groupInfo && acc.groupInfo.id === groupFilter.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化代理信息显示
|
||||
const formatProxyDisplay = (proxy) => {
|
||||
if (!proxy || !proxy.host || !proxy.port) return null
|
||||
|
||||
let display = `${proxy.type}://${proxy.host}:${proxy.port}`
|
||||
// 缩短类型名称
|
||||
const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase()
|
||||
|
||||
// 缩短主机名(如果太长)
|
||||
let host = proxy.host
|
||||
if (host.length > 15) {
|
||||
host = host.substring(0, 12) + '...'
|
||||
}
|
||||
|
||||
let display = `${typeShort}://${host}:${proxy.port}`
|
||||
|
||||
// 如果有用户名密码,添加认证信息(部分隐藏)
|
||||
if (proxy.username) {
|
||||
const maskedUsername = proxy.username.length > 2
|
||||
? proxy.username[0] + '***' + proxy.username[proxy.username.length - 1]
|
||||
: '***'
|
||||
const maskedPassword = proxy.password ? '****' : ''
|
||||
display = `${proxy.type}://${maskedUsername}:${maskedPassword}@${proxy.host}:${proxy.port}`
|
||||
display = `${typeShort}://***@${host}:${proxy.port}`
|
||||
}
|
||||
|
||||
return display
|
||||
@@ -1104,6 +1177,33 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.accounts-container {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@
|
||||
v-else
|
||||
class="hidden md:block table-container"
|
||||
>
|
||||
<table class="min-w-full">
|
||||
<table class="w-full table-fixed">
|
||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[25%] min-w-[200px]"
|
||||
@click="sortApiKeys('name')"
|
||||
>
|
||||
名称
|
||||
@@ -105,11 +105,11 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[80px]">
|
||||
标签
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[8%] min-w-[70px]"
|
||||
@click="sortApiKeys('status')"
|
||||
>
|
||||
状态
|
||||
@@ -122,7 +122,7 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[17%] min-w-[140px]">
|
||||
使用统计
|
||||
<span
|
||||
class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
|
||||
@@ -140,7 +140,7 @@
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[10%] min-w-[90px]"
|
||||
@click="sortApiKeys('createdAt')"
|
||||
>
|
||||
创建时间
|
||||
@@ -154,7 +154,7 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[10%] min-w-[90px]"
|
||||
@click="sortApiKeys('expiresAt')"
|
||||
>
|
||||
过期时间
|
||||
@@ -167,7 +167,7 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[20%] min-w-[180px]">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
@@ -179,32 +179,32 @@
|
||||
>
|
||||
<!-- API Key 主行 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fas fa-key text-white text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-900 truncate" :title="key.name">
|
||||
{{ key.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-gray-500 truncate" :title="key.id">
|
||||
{{ key.id }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="key.claudeAccountId || key.claudeConsoleAccountId">
|
||||
<div class="text-xs text-gray-500 mt-1 truncate">
|
||||
<span v-if="key.claudeAccountId" :title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`">
|
||||
<i class="fas fa-link mr-1" />
|
||||
绑定: {{ getBoundAccountName(key.claudeAccountId, key.claudeConsoleAccountId) }}
|
||||
{{ getBoundAccountName(key.claudeAccountId) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-share-alt mr-1" />
|
||||
使用共享池
|
||||
共享池
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in (key.tags || [])"
|
||||
@@ -219,7 +219,7 @@
|
||||
>无标签</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']"
|
||||
@@ -231,7 +231,7 @@
|
||||
{{ key.isActive ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<td class="px-3 py-4">
|
||||
<div class="space-y-1">
|
||||
<!-- 请求统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
@@ -328,10 +328,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||
<div class="inline-flex items-center gap-1 group">
|
||||
<span v-if="key.expiresAt">
|
||||
<span
|
||||
@@ -371,33 +371,40 @@
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-2">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="复制统计页面链接"
|
||||
@click="copyApiStatsLink(key)"
|
||||
>
|
||||
<i class="fas fa-chart-bar mr-1" />统计
|
||||
<i class="fas fa-chart-bar" />
|
||||
<span class="hidden xl:inline ml-1">统计</span>
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="编辑"
|
||||
@click="openEditApiKeyModal(key)"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" />编辑
|
||||
<i class="fas fa-edit" />
|
||||
<span class="hidden xl:inline ml-1">编辑</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
|
||||
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="续期"
|
||||
@click="openRenewApiKeyModal(key)"
|
||||
>
|
||||
<i class="fas fa-clock mr-1" />续期
|
||||
<i class="fas fa-clock" />
|
||||
<span class="hidden xl:inline ml-1">续期</span>
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="删除"
|
||||
@click="deleteApiKey(key.id)"
|
||||
>
|
||||
<i class="fas fa-trash mr-1" />删除
|
||||
<i class="fas fa-trash" />
|
||||
<span class="hidden xl:inline ml-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -406,8 +413,8 @@
|
||||
<!-- 模型统计展开区域 -->
|
||||
<tr v-if="key && key.id && expandedApiKeys[key.id]">
|
||||
<td
|
||||
colspan="6"
|
||||
class="px-6 py-4 bg-gray-50"
|
||||
colspan="7"
|
||||
class="px-3 py-4 bg-gray-50"
|
||||
>
|
||||
<div
|
||||
v-if="!apiKeyModelStats[key.id]"
|
||||
|
||||
Reference in New Issue
Block a user