feat(accounts): 账户列表显示实时并发数

- 在账户列表 API 返回中添加 current_concurrency 字段
- 合并平台和类型列为 PlatformTypeBadge 组件,节省表格空间
- 新增并发状态列,显示 当前/最大 并发数,支持颜色编码
This commit is contained in:
shaw
2025-12-24 15:44:45 +08:00
parent b071511676
commit 0abe322cca
8 changed files with 157 additions and 29 deletions

View File

@@ -84,7 +84,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
httpUpstream := repository.NewHTTPUpstream(configConfig)
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)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
proxyHandler := admin.NewProxyHandler(adminService)
@@ -108,8 +110,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
identityCache := repository.NewIdentityCache(client)
identityService := service.NewIdentityService(identityCache)
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)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)

View File

@@ -3,6 +3,7 @@ package admin
import (
"strconv"
"sub2api/internal/model"
"sub2api/internal/pkg/claude"
"sub2api/internal/pkg/openai"
"sub2api/internal/pkg/response"
@@ -32,10 +33,11 @@ type AccountHandler struct {
rateLimitService *service.RateLimitService
accountUsageService *service.AccountUsageService
accountTestService *service.AccountTestService
concurrencyService *service.ConcurrencyService
}
// 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{
adminService: adminService,
oauthService: oauthService,
@@ -43,6 +45,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
rateLimitService: rateLimitService,
accountUsageService: accountUsageService,
accountTestService: accountTestService,
concurrencyService: concurrencyService,
}
}
@@ -73,6 +76,12 @@ type UpdateAccountRequest struct {
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
// GET /api/v1/admin/accounts
func (h *AccountHandler) List(c *gin.Context) {
@@ -88,7 +97,28 @@ func (h *AccountHandler) List(c *gin.Context) {
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

View File

@@ -147,3 +147,20 @@ func CalculateMaxWait(userConcurrency int) int {
}
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
}

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

View File

@@ -702,8 +702,10 @@ export default {
},
columns: {
name: 'Name',
platformType: 'Platform/Type',
platform: 'Platform',
type: 'Type',
concurrencyStatus: 'Concurrency',
status: 'Status',
schedulable: 'Schedule',
todayStats: "Today's Stats",

View File

@@ -796,8 +796,10 @@ export default {
failedToToggleSchedulable: '切换调度状态失败',
columns: {
name: '名称',
platformType: '平台/类型',
platform: '平台',
type: '类型',
concurrencyStatus: '并发',
priority: '优先级',
weight: '权重',
status: '状态',

View File

@@ -319,6 +319,7 @@ export interface Account {
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
proxy_id: number | null;
concurrency: number;
current_concurrency?: number; // Real-time concurrency count from Redis
priority: number;
status: 'active' | 'inactive' | 'error';
error_message: string | null;

View File

@@ -73,29 +73,30 @@
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-platform="{ value }">
<span
: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 #cell-platform_type="{ row }">
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
</template>
<template #cell-type="{ value }">
<span
:class="[
'badge',
value === 'oauth' ? 'badge-primary' : value === 'setup-token' ? 'badge-info' : 'badge-purple'
]"
>
{{ value === 'oauth' ? 'Oauth' : value === 'setup-token' ? t('admin.accounts.setupToken') : t('admin.accounts.apiKey') }}
</span>
<template #cell-concurrency="{ row }">
<div class="flex items-center gap-1.5">
<span
:class="[
'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'
: (row.current_concurrency || 0) > 0
? '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 #cell-status="{ row }">
@@ -336,7 +337,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import AccountTestModal from '@/components/account/AccountTestModal.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'
const { t } = useI18n()
@@ -345,8 +346,8 @@ const appStore = useAppStore()
// Table columns
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
{ key: 'platform', label: t('admin.accounts.columns.platform'), sortable: true },
{ key: 'type', label: t('admin.accounts.columns.type'), sortable: true },
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },