feat(admin-spa): 同步PR #106的会话窗口管理功能到新版管理后台

- 在账户管理表格中添加'会话窗口'列
- 显示Claude账户的会话窗口状态(进度条、时间范围、剩余时间)
- 添加formatSessionWindow和formatRemainingTime格式化函数
- 完整同步原版PR #106的前端功能到重构后的admin-spa
This commit is contained in:
shaw
2025-07-29 11:11:25 +08:00
parent f6948a04f6
commit c98de2aca5

View File

@@ -0,0 +1,513 @@
<template>
<div class="accounts-container">
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
<p class="text-gray-600">管理您的 Claude Gemini 账户及代理配置</p>
</div>
<div class="flex gap-2">
<select v-model="accountSortBy" @change="sortAccounts()" class="form-input px-3 py-2 text-sm">
<option value="name">按名称排序</option>
<option value="dailyTokens">按今日Token排序</option>
<option value="dailyRequests">按今日请求数排序</option>
<option value="totalTokens">按总Token排序</option>
<option value="lastUsed">按最后使用排序</option>
</select>
<button
@click.stop="openCreateAccountModal"
class="btn btn-success px-6 py-3 flex items-center gap-2"
>
<i class="fas fa-plus"></i>添加账户
</button>
</div>
</div>
<div v-if="accountsLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载账户...</p>
</div>
<div v-else-if="sortedAccounts.length === 0" class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<i class="fas fa-user-circle text-gray-400 text-xl"></i>
</div>
<p class="text-gray-500 text-lg">暂无账户</p>
<p class="text-gray-400 text-sm mt-2">点击上方按钮添加您的第一个账户</p>
</div>
<div v-else class="table-container">
<table class="min-w-full">
<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" @click="sortAccounts('name')">
名称
<i v-if="accountsSortBy === 'name'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</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('platform')">
平台
<i v-if="accountsSortBy === 'platform'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</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>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</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('status')">
状态
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">会话窗口</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<tr v-for="account in sortedAccounts" :key="account.id" class="table-row">
<td class="px-6 py-4 whitespace-nowrap">
<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">
<i class="fas fa-user-circle text-white text-xs"></i>
</div>
<div>
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-gray-900">{{ account.name }}</div>
<span v-if="account.accountType === 'dedicated'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-lock mr-1"></i>专属
</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"></i>共享
</span>
</div>
<div class="text-xs text-gray-500">{{ 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"></i>Gemini
</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"></i>Claude
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-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"></i>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"></i>传统
</span>
</td>
<td class="px-6 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',
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
{{ account.isActive ? '正常' : '异常' }}
</span>
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
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-exclamation-triangle mr-1"></i>
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
</span>
<span v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500">
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div v-if="account.proxy" class="text-xs bg-blue-50 px-2 py-1 rounded">
{{ account.proxy.type }}://{{ account.proxy.host }}:{{ account.proxy.port }}
</div>
<div v-else class="text-gray-400">无代理</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-sm font-medium text-gray-900">{{ account.usage.daily.requests || 0 }} </span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<span class="text-xs text-gray-600">{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span>
</div>
<div v-if="account.usage.averages && account.usage.averages.rpm > 0" class="text-xs text-gray-500">
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div>
</div>
<div v-else class="text-gray-400 text-xs">暂无数据</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow" class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 rounded-full h-2">
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 h-2 rounded-full transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"></div>
</div>
<span class="text-xs text-gray-700 font-medium min-w-[32px]">
{{ account.sessionWindow.progress }}%
</span>
</div>
<div class="text-xs text-gray-600">
<div>{{ formatSessionWindow(account.sessionWindow.windowStart, account.sessionWindow.windowEnd) }}</div>
<div v-if="account.sessionWindow.remainingTime > 0" class="text-indigo-600 font-medium">
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</div>
</div>
</div>
<div v-else-if="account.platform === 'claude'" class="text-gray-400 text-sm">
<i class="fas fa-minus"></i>
</div>
<div v-else class="text-gray-400 text-sm">
<span class="text-xs">N/A</span>
</div>
</td>
<td class="px-6 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">
<button
v-if="account.platform === 'claude' && account.scopes"
@click="refreshToken(account)"
:disabled="account.isRefreshing"
:class="[
'px-3 py-1.5 rounded-lg 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'
]"
:title="account.isRefreshing ? '刷新中...' : '刷新Token'"
>
<i :class="[
'fas fa-sync-alt',
account.isRefreshing ? 'animate-spin' : ''
]"></i>
</button>
<button
@click="editAccount(account)"
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
>
<i class="fas fa-edit"></i>
</button>
<button
@click="deleteAccount(account)"
class="px-3 py-1.5 bg-red-100 text-red-700 rounded-lg text-xs font-medium hover:bg-red-200 transition-colors"
>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 添加账户模态框 -->
<AccountForm
v-if="showCreateAccountModal"
@close="showCreateAccountModal = false"
@success="handleCreateSuccess"
/>
<!-- 编辑账户模态框 -->
<AccountForm
v-if="showEditAccountModal"
:account="editingAccount"
@close="showEditAccountModal = false"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import AccountForm from '@/components/accounts/AccountForm.vue'
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const accountSortBy = ref('name')
const accountsSortBy = ref('')
const accountsSortOrder = ref('asc')
const apiKeys = ref([])
// 模态框状态
const showCreateAccountModal = ref(false)
const showEditAccountModal = ref(false)
const editingAccount = ref(null)
// 计算排序后的账户列表
const sortedAccounts = computed(() => {
if (!accountsSortBy.value) return accounts.value
const sorted = [...accounts.value].sort((a, b) => {
let aVal = a[accountsSortBy.value]
let bVal = b[accountsSortBy.value]
// 处理统计数据
if (accountsSortBy.value === 'dailyTokens') {
aVal = a.usage?.daily?.allTokens || 0
bVal = b.usage?.daily?.allTokens || 0
} else if (accountsSortBy.value === 'dailyRequests') {
aVal = a.usage?.daily?.requests || 0
bVal = b.usage?.daily?.requests || 0
} else if (accountsSortBy.value === 'totalTokens') {
aVal = a.usage?.total?.allTokens || 0
bVal = b.usage?.total?.allTokens || 0
}
// 处理最后使用时间
if (accountsSortBy.value === 'lastUsed') {
aVal = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
bVal = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
}
// 处理状态
if (accountsSortBy.value === 'status') {
aVal = a.isActive ? 1 : 0
bVal = b.isActive ? 1 : 0
}
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
return 0
})
return sorted
})
// 加载账户列表
const loadAccounts = async () => {
accountsLoading.value = true
try {
const [claudeData, geminiData, apiKeysData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/api-keys')
])
// 更新API Keys列表
if (apiKeysData.success) {
apiKeys.value = apiKeysData.data || []
}
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 }
})
allAccounts.push(...claudeAccounts)
}
if (geminiData.success) {
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 }
})
allAccounts.push(...geminiAccounts)
}
accounts.value = allAccounts
} catch (error) {
showToast('加载账户失败', 'error')
} finally {
accountsLoading.value = false
}
}
// 排序账户
const sortAccounts = (field) => {
if (field) {
if (accountsSortBy.value === field) {
accountsSortOrder.value = accountsSortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
accountsSortBy.value = field
accountsSortOrder.value = 'asc'
}
}
}
// 格式化数字(与原版保持一致)
const formatNumber = (num) => {
if (num === null || num === undefined) return '0'
const number = Number(num)
if (number >= 1000000) {
return Math.floor(number / 1000000).toLocaleString() + 'M'
}
return number.toLocaleString()
}
// 格式化最后使用时间
const formatLastUsed = (dateString) => {
if (!dateString) return '从未使用'
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
return date.toLocaleDateString('zh-CN')
}
// 格式化会话窗口时间
const formatSessionWindow = (windowStart, windowEnd) => {
if (!windowStart || !windowEnd) return '--'
const start = new Date(windowStart)
const end = new Date(windowEnd)
const startHour = start.getHours().toString().padStart(2, '0')
const startMin = start.getMinutes().toString().padStart(2, '0')
const endHour = end.getHours().toString().padStart(2, '0')
const endMin = end.getMinutes().toString().padStart(2, '0')
return `${startHour}:${startMin} - ${endHour}:${endMin}`
}
// 格式化剩余时间
const formatRemainingTime = (minutes) => {
if (!minutes || minutes <= 0) return '已结束'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}小时${mins}分钟`
}
return `${mins}分钟`
}
// 打开创建账户模态框
const openCreateAccountModal = () => {
showCreateAccountModal.value = true
}
// 编辑账户
const editAccount = (account) => {
editingAccount.value = account
showEditAccountModal.value = true
}
// 删除账户
const deleteAccount = async (account) => {
if (!confirm(`确定要删除账户 "${account.name}" 吗?此操作不可恢复。`)) return
try {
const endpoint = account.platform === 'claude'
? `/admin/claude-accounts/${account.id}`
: `/admin/gemini-accounts/${account.id}`
const data = await apiClient.delete(endpoint)
if (data.success) {
showToast('账户已删除', 'success')
loadAccounts()
} else {
showToast(data.message || '删除失败', 'error')
}
} catch (error) {
showToast('删除失败', 'error')
}
}
// 刷新Token
const refreshToken = async (account) => {
if (account.isRefreshing) return
try {
account.isRefreshing = true
const data = await apiClient.post(`/admin/claude-accounts/${account.id}/refresh`)
if (data.success) {
showToast('Token刷新成功', 'success')
loadAccounts()
} else {
showToast(data.message || 'Token刷新失败', 'error')
}
} catch (error) {
showToast('Token刷新失败', 'error')
} finally {
account.isRefreshing = false
}
}
// 处理创建成功
const handleCreateSuccess = () => {
showCreateAccountModal.value = false
showToast('账户创建成功', 'success')
loadAccounts()
}
// 处理编辑成功
const handleEditSuccess = () => {
showEditAccountModal.value = false
showToast('账户更新成功', 'success')
loadAccounts()
}
// 监听排序选择变化
watch(accountSortBy, (newVal) => {
const fieldMap = {
name: 'name',
dailyTokens: 'dailyTokens',
dailyRequests: 'dailyRequests',
totalTokens: 'totalTokens',
lastUsed: 'lastUsed'
}
if (fieldMap[newVal]) {
sortAccounts(fieldMap[newVal])
}
})
onMounted(() => {
loadAccounts()
})
</script>
<style scoped>
.accounts-container {
min-height: calc(100vh - 300px);
}
.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);
}
</style>