feat(accounts): 账户列表显示实时并发数
- 在账户列表 API 返回中添加 current_concurrency 字段 - 合并平台和类型列为 PlatformTypeBadge 组件,节省表格空间 - 新增并发状态列,显示 当前/最大 并发数,支持颜色编码
This commit is contained in:
@@ -84,7 +84,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
||||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService)
|
concurrencyCache := repository.NewConcurrencyCache(client)
|
||||||
|
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
||||||
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService)
|
||||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
@@ -108,8 +110,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
identityCache := repository.NewIdentityCache(client)
|
identityCache := repository.NewIdentityCache(client)
|
||||||
identityService := service.NewIdentityService(identityCache)
|
identityService := service.NewIdentityService(identityCache)
|
||||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
||||||
concurrencyCache := repository.NewConcurrencyCache(client)
|
|
||||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
|
||||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/claude"
|
"sub2api/internal/pkg/claude"
|
||||||
"sub2api/internal/pkg/openai"
|
"sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/pkg/response"
|
"sub2api/internal/pkg/response"
|
||||||
@@ -32,10 +33,11 @@ type AccountHandler struct {
|
|||||||
rateLimitService *service.RateLimitService
|
rateLimitService *service.RateLimitService
|
||||||
accountUsageService *service.AccountUsageService
|
accountUsageService *service.AccountUsageService
|
||||||
accountTestService *service.AccountTestService
|
accountTestService *service.AccountTestService
|
||||||
|
concurrencyService *service.ConcurrencyService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAccountHandler creates a new admin account handler
|
// NewAccountHandler creates a new admin account handler
|
||||||
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService) *AccountHandler {
|
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler {
|
||||||
return &AccountHandler{
|
return &AccountHandler{
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
oauthService: oauthService,
|
oauthService: oauthService,
|
||||||
@@ -43,6 +45,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
|
|||||||
rateLimitService: rateLimitService,
|
rateLimitService: rateLimitService,
|
||||||
accountUsageService: accountUsageService,
|
accountUsageService: accountUsageService,
|
||||||
accountTestService: accountTestService,
|
accountTestService: accountTestService,
|
||||||
|
concurrencyService: concurrencyService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +76,12 @@ type UpdateAccountRequest struct {
|
|||||||
GroupIDs *[]int64 `json:"group_ids"`
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||||
|
type AccountWithConcurrency struct {
|
||||||
|
*model.Account
|
||||||
|
CurrentConcurrency int `json:"current_concurrency"`
|
||||||
|
}
|
||||||
|
|
||||||
// List handles listing all accounts with pagination
|
// List handles listing all accounts with pagination
|
||||||
// GET /api/v1/admin/accounts
|
// GET /api/v1/admin/accounts
|
||||||
func (h *AccountHandler) List(c *gin.Context) {
|
func (h *AccountHandler) List(c *gin.Context) {
|
||||||
@@ -88,7 +97,28 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Paginated(c, accounts, total, page, pageSize)
|
// Get current concurrency counts for all accounts
|
||||||
|
accountIDs := make([]int64, len(accounts))
|
||||||
|
for i, acc := range accounts {
|
||||||
|
accountIDs[i] = acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrencyCounts, err := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), accountIDs)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail the request, just use 0 for all
|
||||||
|
concurrencyCounts = make(map[int64]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with concurrency info
|
||||||
|
result := make([]AccountWithConcurrency, len(accounts))
|
||||||
|
for i := range accounts {
|
||||||
|
result[i] = AccountWithConcurrency{
|
||||||
|
Account: &accounts[i],
|
||||||
|
CurrentConcurrency: concurrencyCounts[accounts[i].ID],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID handles getting an account by ID
|
// GetByID handles getting an account by ID
|
||||||
|
|||||||
@@ -147,3 +147,20 @@ func CalculateMaxWait(userConcurrency int) int {
|
|||||||
}
|
}
|
||||||
return userConcurrency + defaultExtraWaitSlots
|
return userConcurrency + defaultExtraWaitSlots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts
|
||||||
|
// Returns a map of accountID -> current concurrency count
|
||||||
|
func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||||
|
result := make(map[int64]int)
|
||||||
|
|
||||||
|
for _, accountID := range accountIDs {
|
||||||
|
count, err := s.cache.GetAccountConcurrency(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
// If key doesn't exist in Redis, count is 0
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
result[accountID] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
75
frontend/src/components/common/PlatformTypeBadge.vue
Normal file
75
frontend/src/components/common/PlatformTypeBadge.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex items-center rounded-md overflow-hidden text-xs font-medium">
|
||||||
|
<!-- Platform part -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1',
|
||||||
|
platformClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlatformIcon :platform="platform" size="xs" />
|
||||||
|
<span>{{ platformLabel }}</span>
|
||||||
|
</span>
|
||||||
|
<!-- Type part -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-1.5 py-1',
|
||||||
|
typeClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- OAuth icon -->
|
||||||
|
<svg v-if="type === 'oauth'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Setup Token icon -->
|
||||||
|
<svg v-else-if="type === 'setup-token'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
|
</svg>
|
||||||
|
<!-- API Key icon -->
|
||||||
|
<svg v-else class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ typeLabel }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AccountPlatform, AccountType } from '@/types'
|
||||||
|
import PlatformIcon from './PlatformIcon.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
platform: AccountPlatform
|
||||||
|
type: AccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const platformLabel = computed(() => {
|
||||||
|
return props.platform === 'anthropic' ? 'Anthropic' : 'OpenAI'
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
switch (props.type) {
|
||||||
|
case 'oauth': return 'OAuth'
|
||||||
|
case 'setup-token': return 'Token'
|
||||||
|
case 'apikey': return 'Key'
|
||||||
|
default: return props.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const platformClass = computed(() => {
|
||||||
|
if (props.platform === 'anthropic') {
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeClass = computed(() => {
|
||||||
|
if (props.platform === 'anthropic') {
|
||||||
|
return 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
|
return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -702,8 +702,10 @@ export default {
|
|||||||
},
|
},
|
||||||
columns: {
|
columns: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
platformType: 'Platform/Type',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
|
concurrencyStatus: 'Concurrency',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
schedulable: 'Schedule',
|
schedulable: 'Schedule',
|
||||||
todayStats: "Today's Stats",
|
todayStats: "Today's Stats",
|
||||||
|
|||||||
@@ -796,8 +796,10 @@ export default {
|
|||||||
failedToToggleSchedulable: '切换调度状态失败',
|
failedToToggleSchedulable: '切换调度状态失败',
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
|
platformType: '平台/类型',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
|
concurrencyStatus: '并发',
|
||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
weight: '权重',
|
weight: '权重',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export interface Account {
|
|||||||
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
|
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
|
||||||
proxy_id: number | null;
|
proxy_id: number | null;
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
|
current_concurrency?: number; // Real-time concurrency count from Redis
|
||||||
priority: number;
|
priority: number;
|
||||||
status: 'active' | 'inactive' | 'error';
|
status: 'active' | 'inactive' | 'error';
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
|
|||||||
@@ -73,29 +73,30 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-platform="{ value }">
|
<template #cell-platform_type="{ row }">
|
||||||
<span
|
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
||||||
:class="[
|
|
||||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
value === 'anthropic'
|
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
|
||||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<PlatformIcon :platform="value" size="xs" />
|
|
||||||
{{ value === 'anthropic' ? 'Anthropic' : 'OpenAI' }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-type="{ value }">
|
<template #cell-concurrency="{ row }">
|
||||||
<span
|
<div class="flex items-center gap-1.5">
|
||||||
:class="[
|
<span
|
||||||
'badge',
|
:class="[
|
||||||
value === 'oauth' ? 'badge-primary' : value === 'setup-token' ? 'badge-info' : 'badge-purple'
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
|
||||||
]"
|
(row.current_concurrency || 0) >= row.concurrency
|
||||||
>
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
{{ value === 'oauth' ? 'Oauth' : value === 'setup-token' ? t('admin.accounts.setupToken') : t('admin.accounts.apiKey') }}
|
: (row.current_concurrency || 0) > 0
|
||||||
</span>
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">{{ row.current_concurrency || 0 }}</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||||
|
<span class="font-mono">{{ row.concurrency }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-status="{ row }">
|
<template #cell-status="{ row }">
|
||||||
@@ -336,7 +337,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
|||||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||||
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||||
import { formatRelativeTime } from '@/utils/format'
|
import { formatRelativeTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -345,8 +346,8 @@ const appStore = useAppStore()
|
|||||||
// Table columns
|
// Table columns
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||||
{ key: 'platform', label: t('admin.accounts.columns.platform'), sortable: true },
|
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
||||||
{ key: 'type', label: t('admin.accounts.columns.type'), sortable: true },
|
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
|
||||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
||||||
|
|||||||
Reference in New Issue
Block a user