mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-18 05:44:46 +08:00
feat(admin): 分组管理新增容量列(并发/会话/RPM 实时聚合)
复用 GroupCapacityService,在 admin 分组列表中添加容量列, 显示每个分组的实时并发/会话/RPM 使用量和上限。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,7 +110,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||||
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
||||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService)
|
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService)
|
||||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService)
|
|
||||||
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
||||||
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
|
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
|
||||||
openAIOAuthClient := repository.NewOpenAIOAuthClient()
|
openAIOAuthClient := repository.NewOpenAIOAuthClient()
|
||||||
@@ -143,6 +142,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||||
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
|
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
|
||||||
rpmCache := repository.NewRPMCache(redisClient)
|
rpmCache := repository.NewRPMCache(redisClient)
|
||||||
|
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||||
|
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||||
dataManagementService := service.NewDataManagementService()
|
dataManagementService := service.NewDataManagementService()
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
|||||||
adminSvc := newStubAdminService()
|
adminSvc := newStubAdminService()
|
||||||
|
|
||||||
userHandler := NewUserHandler(adminSvc, nil)
|
userHandler := NewUserHandler(adminSvc, nil)
|
||||||
groupHandler := NewGroupHandler(adminSvc, nil)
|
groupHandler := NewGroupHandler(adminSvc, nil, nil)
|
||||||
proxyHandler := NewProxyHandler(adminSvc, nil, nil)
|
proxyHandler := NewProxyHandler(adminSvc)
|
||||||
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
||||||
|
|
||||||
router.GET("/api/v1/admin/users", userHandler.List)
|
router.GET("/api/v1/admin/users", userHandler.List)
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import (
|
|||||||
|
|
||||||
// GroupHandler handles admin group management
|
// GroupHandler handles admin group management
|
||||||
type GroupHandler struct {
|
type GroupHandler struct {
|
||||||
adminService service.AdminService
|
adminService service.AdminService
|
||||||
dashboardService *service.DashboardService
|
dashboardService *service.DashboardService
|
||||||
|
groupCapacityService *service.GroupCapacityService
|
||||||
}
|
}
|
||||||
|
|
||||||
type optionalLimitField struct {
|
type optionalLimitField struct {
|
||||||
@@ -71,10 +72,11 @@ func (f optionalLimitField) ToServiceInput() *float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewGroupHandler creates a new admin group handler
|
// NewGroupHandler creates a new admin group handler
|
||||||
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService) *GroupHandler {
|
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
|
||||||
return &GroupHandler{
|
return &GroupHandler{
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
dashboardService: dashboardService,
|
dashboardService: dashboardService,
|
||||||
|
groupCapacityService: groupCapacityService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +384,17 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
|||||||
response.Success(c, results)
|
response.Success(c, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
|
||||||
|
// GET /api/v1/admin/groups/capacity-summary
|
||||||
|
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||||
|
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, 500, "Failed to get group capacity summary")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, results)
|
||||||
|
}
|
||||||
|
|
||||||
// GetGroupAPIKeys handles getting API keys in a group
|
// GetGroupAPIKeys handles getting API keys in a group
|
||||||
// GET /api/v1/admin/groups/:id/api-keys
|
// GET /api/v1/admin/groups/:id/api-keys
|
||||||
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||||
|
|||||||
@@ -135,16 +135,16 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := &AdminGroup{
|
out := &AdminGroup{
|
||||||
Group: groupFromServiceBase(g),
|
Group: groupFromServiceBase(g),
|
||||||
ModelRouting: g.ModelRouting,
|
ModelRouting: g.ModelRouting,
|
||||||
ModelRoutingEnabled: g.ModelRoutingEnabled,
|
ModelRoutingEnabled: g.ModelRoutingEnabled,
|
||||||
MCPXMLInject: g.MCPXMLInject,
|
MCPXMLInject: g.MCPXMLInject,
|
||||||
DefaultMappedModel: g.DefaultMappedModel,
|
DefaultMappedModel: g.DefaultMappedModel,
|
||||||
SupportedModelScopes: g.SupportedModelScopes,
|
SupportedModelScopes: g.SupportedModelScopes,
|
||||||
AccountCount: g.AccountCount,
|
AccountCount: g.AccountCount,
|
||||||
ActiveAccountCount: g.ActiveAccountCount,
|
ActiveAccountCount: g.ActiveAccountCount,
|
||||||
RateLimitedAccountCount: g.RateLimitedAccountCount,
|
RateLimitedAccountCount: g.RateLimitedAccountCount,
|
||||||
SortOrder: g.SortOrder,
|
SortOrder: g.SortOrder,
|
||||||
}
|
}
|
||||||
if len(g.AccountGroups) > 0 {
|
if len(g.AccountGroups) > 0 {
|
||||||
out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups))
|
out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups))
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ type AdminGroup struct {
|
|||||||
DefaultMappedModel string `json:"default_mapped_model"`
|
DefaultMappedModel string `json:"default_mapped_model"`
|
||||||
|
|
||||||
// 支持的模型系列(仅 antigravity 平台使用)
|
// 支持的模型系列(仅 antigravity 平台使用)
|
||||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||||
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
||||||
AccountCount int64 `json:"account_count,omitempty"`
|
AccountCount int64 `json:"account_count,omitempty"`
|
||||||
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
|
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
|
||||||
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
||||||
|
|||||||
@@ -348,6 +348,9 @@ func (s *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi
|
|||||||
func (s *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
|
func (s *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (s *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
|
func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3000,7 +3000,6 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension.
|
// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension.
|
||||||
func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) {
|
func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) {
|
||||||
query := `
|
query := `
|
||||||
@@ -3088,13 +3087,11 @@ func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var results []usagestats.GroupUsageSummary
|
var results []usagestats.GroupUsageSummary
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var row usagestats.GroupUsageSummary
|
var row usagestats.GroupUsageSummary
|
||||||
if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil {
|
if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil {
|
||||||
>>>>>>> c8c1b4d4 (feat(admin): 分组管理列表新增用量列与账号数分类)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
results = append(results, row)
|
results = append(results, row)
|
||||||
|
|||||||
@@ -924,8 +924,8 @@ func (stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error
|
|||||||
return false, errors.New("not implemented")
|
return false, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
func (stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
|
||||||
return 0, errors.New("not implemented")
|
return 0, 0, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
func (stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
@@ -1786,6 +1786,9 @@ func (r *stubUsageLogRepo) GetAccountUsageStats(ctx context.Context, accountID i
|
|||||||
func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
|
func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
func (r *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
type stubSettingRepo struct {
|
type stubSettingRepo struct {
|
||||||
all map[string]string
|
all map[string]string
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
groups.GET("", h.Admin.Group.List)
|
groups.GET("", h.Admin.Group.List)
|
||||||
groups.GET("/all", h.Admin.Group.GetAll)
|
groups.GET("/all", h.Admin.Group.GetAll)
|
||||||
groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary)
|
groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary)
|
||||||
|
groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary)
|
||||||
groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder)
|
groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder)
|
||||||
groups.GET("/:id", h.Admin.Group.GetByID)
|
groups.GET("/:id", h.Admin.Group.GetByID)
|
||||||
groups.POST("", h.Admin.Group.Create)
|
groups.POST("", h.Admin.Group.Create)
|
||||||
|
|||||||
131
backend/internal/service/group_capacity_service.go
Normal file
131
backend/internal/service/group_capacity_service.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupCapacitySummary holds aggregated capacity for a single group.
|
||||||
|
type GroupCapacitySummary struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
ConcurrencyUsed int `json:"concurrency_used"`
|
||||||
|
ConcurrencyMax int `json:"concurrency_max"`
|
||||||
|
SessionsUsed int `json:"sessions_used"`
|
||||||
|
SessionsMax int `json:"sessions_max"`
|
||||||
|
RPMUsed int `json:"rpm_used"`
|
||||||
|
RPMMax int `json:"rpm_max"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupCapacityService aggregates per-group capacity from runtime data.
|
||||||
|
type GroupCapacityService struct {
|
||||||
|
accountRepo AccountRepository
|
||||||
|
groupRepo GroupRepository
|
||||||
|
concurrencyService *ConcurrencyService
|
||||||
|
sessionLimitCache SessionLimitCache
|
||||||
|
rpmCache RPMCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroupCapacityService creates a new GroupCapacityService.
|
||||||
|
func NewGroupCapacityService(
|
||||||
|
accountRepo AccountRepository,
|
||||||
|
groupRepo GroupRepository,
|
||||||
|
concurrencyService *ConcurrencyService,
|
||||||
|
sessionLimitCache SessionLimitCache,
|
||||||
|
rpmCache RPMCache,
|
||||||
|
) *GroupCapacityService {
|
||||||
|
return &GroupCapacityService{
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
|
concurrencyService: concurrencyService,
|
||||||
|
sessionLimitCache: sessionLimitCache,
|
||||||
|
rpmCache: rpmCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllGroupCapacity returns capacity summary for all active groups.
|
||||||
|
func (s *GroupCapacityService) GetAllGroupCapacity(ctx context.Context) ([]GroupCapacitySummary, error) {
|
||||||
|
groups, err := s.groupRepo.ListActive(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]GroupCapacitySummary, 0, len(groups))
|
||||||
|
for i := range groups {
|
||||||
|
cap, err := s.getGroupCapacity(ctx, groups[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
// Skip groups with errors, return partial results
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cap.GroupID = groups[i].ID
|
||||||
|
results = append(results, cap)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GroupCapacityService) getGroupCapacity(ctx context.Context, groupID int64) (GroupCapacitySummary, error) {
|
||||||
|
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return GroupCapacitySummary{}, err
|
||||||
|
}
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
return GroupCapacitySummary{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect account IDs and config values
|
||||||
|
accountIDs := make([]int64, 0, len(accounts))
|
||||||
|
sessionTimeouts := make(map[int64]time.Duration)
|
||||||
|
var concurrencyMax, sessionsMax, rpmMax int
|
||||||
|
|
||||||
|
for i := range accounts {
|
||||||
|
acc := &accounts[i]
|
||||||
|
accountIDs = append(accountIDs, acc.ID)
|
||||||
|
concurrencyMax += acc.Concurrency
|
||||||
|
|
||||||
|
if ms := acc.GetMaxSessions(); ms > 0 {
|
||||||
|
sessionsMax += ms
|
||||||
|
timeout := time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Minute
|
||||||
|
}
|
||||||
|
sessionTimeouts[acc.ID] = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpm := acc.GetBaseRPM(); rpm > 0 {
|
||||||
|
rpmMax += rpm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch query runtime data from Redis
|
||||||
|
concurrencyMap, _ := s.concurrencyService.GetAccountConcurrencyBatch(ctx, accountIDs)
|
||||||
|
|
||||||
|
var sessionsMap map[int64]int
|
||||||
|
if sessionsMax > 0 && s.sessionLimitCache != nil {
|
||||||
|
sessionsMap, _ = s.sessionLimitCache.GetActiveSessionCountBatch(ctx, accountIDs, sessionTimeouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpmMap map[int64]int
|
||||||
|
if rpmMax > 0 && s.rpmCache != nil {
|
||||||
|
rpmMap, _ = s.rpmCache.GetRPMBatch(ctx, accountIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate
|
||||||
|
var concurrencyUsed, sessionsUsed, rpmUsed int
|
||||||
|
for _, id := range accountIDs {
|
||||||
|
concurrencyUsed += concurrencyMap[id]
|
||||||
|
if sessionsMap != nil {
|
||||||
|
sessionsUsed += sessionsMap[id]
|
||||||
|
}
|
||||||
|
if rpmMap != nil {
|
||||||
|
rpmUsed += rpmMap[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GroupCapacitySummary{
|
||||||
|
ConcurrencyUsed: concurrencyUsed,
|
||||||
|
ConcurrencyMax: concurrencyMax,
|
||||||
|
SessionsUsed: sessionsUsed,
|
||||||
|
SessionsMax: sessionsMax,
|
||||||
|
RPMUsed: rpmUsed,
|
||||||
|
RPMMax: rpmMax,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -486,4 +486,5 @@ var ProviderSet = wire.NewSet(
|
|||||||
ProvideIdempotencyCleanupService,
|
ProvideIdempotencyCleanupService,
|
||||||
ProvideScheduledTestService,
|
ProvideScheduledTestService,
|
||||||
ProvideScheduledTestRunnerService,
|
ProvideScheduledTestRunnerService,
|
||||||
|
NewGroupCapacityService,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -234,6 +234,18 @@ export async function getUsageSummary(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get capacity summary (concurrency/sessions/RPM) for all active groups
|
||||||
|
*/
|
||||||
|
export async function getCapacitySummary(): Promise<
|
||||||
|
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
|
||||||
|
> {
|
||||||
|
const { data } = await apiClient.get<
|
||||||
|
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
|
||||||
|
>('/admin/groups/capacity-summary')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const groupsAPI = {
|
export const groupsAPI = {
|
||||||
list,
|
list,
|
||||||
getAll,
|
getAll,
|
||||||
@@ -249,7 +261,8 @@ export const groupsAPI = {
|
|||||||
clearGroupRateMultipliers,
|
clearGroupRateMultipliers,
|
||||||
batchSetGroupRateMultipliers,
|
batchSetGroupRateMultipliers,
|
||||||
updateSortOrder,
|
updateSortOrder,
|
||||||
getUsageSummary
|
getUsageSummary,
|
||||||
|
getCapacitySummary
|
||||||
}
|
}
|
||||||
|
|
||||||
export default groupsAPI
|
export default groupsAPI
|
||||||
|
|||||||
84
frontend/src/components/common/GroupCapacityBadge.vue
Normal file
84
frontend/src/components/common/GroupCapacityBadge.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- 并发槽位 -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
capacityClass(concurrencyUsed, concurrencyMax)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="h-2.5 w-2.5" 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">{{ concurrencyUsed }}</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||||
|
<span class="font-mono">{{ concurrencyMax }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 会话数 -->
|
||||||
|
<div v-if="sessionsMax > 0" class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
capacityClass(sessionsUsed, sessionsMax)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">{{ sessionsUsed }}</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||||
|
<span class="font-mono">{{ sessionsMax }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RPM -->
|
||||||
|
<div v-if="rpmMax > 0" class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
capacityClass(rpmUsed, rpmMax)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">{{ rpmUsed }}</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||||
|
<span class="font-mono">{{ rpmMax }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
concurrencyUsed: number
|
||||||
|
concurrencyMax: number
|
||||||
|
sessionsUsed: number
|
||||||
|
sessionsMax: number
|
||||||
|
rpmUsed: number
|
||||||
|
rpmMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
concurrencyUsed: 0,
|
||||||
|
concurrencyMax: 0,
|
||||||
|
sessionsUsed: 0,
|
||||||
|
sessionsMax: 0,
|
||||||
|
rpmUsed: 0,
|
||||||
|
rpmMax: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function capacityClass(used: number, max: number): string {
|
||||||
|
if (max > 0 && used >= max) {
|
||||||
|
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
}
|
||||||
|
if (used > 0) {
|
||||||
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1505,6 +1505,7 @@ export default {
|
|||||||
rateMultiplier: 'Rate Multiplier',
|
rateMultiplier: 'Rate Multiplier',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
accounts: 'Accounts',
|
accounts: 'Accounts',
|
||||||
|
capacity: 'Capacity',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
|
|||||||
@@ -1561,6 +1561,7 @@ export default {
|
|||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
apiKeys: 'API 密钥数',
|
apiKeys: 'API 密钥数',
|
||||||
accounts: '账号数',
|
accounts: '账号数',
|
||||||
|
capacity: '容量',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
|
|||||||
@@ -178,6 +178,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-capacity="{ row }">
|
||||||
|
<GroupCapacityBadge
|
||||||
|
v-if="capacityMap.get(row.id)"
|
||||||
|
:concurrency-used="capacityMap.get(row.id)!.concurrencyUsed"
|
||||||
|
:concurrency-max="capacityMap.get(row.id)!.concurrencyMax"
|
||||||
|
:sessions-used="capacityMap.get(row.id)!.sessionsUsed"
|
||||||
|
:sessions-max="capacityMap.get(row.id)!.sessionsMax"
|
||||||
|
:rpm-used="capacityMap.get(row.id)!.rpmUsed"
|
||||||
|
:rpm-max="capacityMap.get(row.id)!.rpmMax"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-xs text-gray-400">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-usage="{ row }">
|
<template #cell-usage="{ row }">
|
||||||
<div v-if="usageLoading" class="text-xs text-gray-400">—</div>
|
<div v-if="usageLoading" class="text-xs text-gray-400">—</div>
|
||||||
<div v-else class="space-y-0.5 text-xs">
|
<div v-else class="space-y-0.5 text-xs">
|
||||||
@@ -1838,6 +1851,7 @@ import Select from '@/components/common/Select.vue'
|
|||||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
|
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
|
||||||
|
import GroupCapacityBadge from '@/components/common/GroupCapacityBadge.vue'
|
||||||
import { VueDraggable } from 'vue-draggable-plus'
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||||
@@ -1853,6 +1867,7 @@ const columns = computed<Column[]>(() => [
|
|||||||
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
|
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
|
||||||
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
|
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
|
||||||
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
|
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
|
||||||
|
{ key: 'capacity', label: t('admin.groups.columns.capacity'), sortable: false },
|
||||||
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
|
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
|
||||||
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
|
||||||
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
|
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
|
||||||
@@ -1992,6 +2007,7 @@ const groups = ref<AdminGroup[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
|
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
|
||||||
const usageLoading = ref(false)
|
const usageLoading = ref(false)
|
||||||
|
const capacityMap = ref<Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>>(new Map())
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
platform: '',
|
platform: '',
|
||||||
@@ -2331,6 +2347,7 @@ const loadGroups = async () => {
|
|||||||
pagination.total = response.total
|
pagination.total = response.total
|
||||||
pagination.pages = response.pages
|
pagination.pages = response.pages
|
||||||
loadUsageSummary()
|
loadUsageSummary()
|
||||||
|
loadCapacitySummary()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
||||||
return
|
return
|
||||||
@@ -2367,6 +2384,26 @@ const loadUsageSummary = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadCapacitySummary = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminAPI.groups.getCapacitySummary()
|
||||||
|
const map = new Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>()
|
||||||
|
for (const item of data) {
|
||||||
|
map.set(item.group_id, {
|
||||||
|
concurrencyUsed: item.concurrency_used,
|
||||||
|
concurrencyMax: item.concurrency_max,
|
||||||
|
sessionsUsed: item.sessions_used,
|
||||||
|
sessionsMax: item.sessions_max,
|
||||||
|
rpmUsed: item.rpm_used,
|
||||||
|
rpmMax: item.rpm_max
|
||||||
|
})
|
||||||
|
}
|
||||||
|
capacityMap.value = map
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading group capacity summary:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout>
|
let searchTimeout: ReturnType<typeof setTimeout>
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
|
|||||||
Reference in New Issue
Block a user