mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-19 14:24:45 +08:00
feat(admin): 分组管理列表新增用量列与账号数分类
分组管理列表增强: 1. 今日/累计用量列: - 新增独立端点 GET /admin/groups/usage-summary - 一次查询返回所有分组的今日费用和累计费用(actual_cost) - 前端异步加载后合并显示在分组列表中 2. 账号数区分可用/限流/总量: - 将账号数列从单一总量改为 badge 内多行展示 - 可用: active + schedulable 的账号数(绿色) - 限流: rate_limit/overload/temp_unschedulable 的账号数(橙色,无限流时隐藏) - 总量: 全部关联账号数 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,7 +110,7 @@ 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)
|
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()
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
|||||||
adminSvc := newStubAdminService()
|
adminSvc := newStubAdminService()
|
||||||
|
|
||||||
userHandler := NewUserHandler(adminSvc, nil)
|
userHandler := NewUserHandler(adminSvc, nil)
|
||||||
groupHandler := NewGroupHandler(adminSvc)
|
groupHandler := NewGroupHandler(adminSvc, nil)
|
||||||
proxyHandler := NewProxyHandler(adminSvc)
|
proxyHandler := NewProxyHandler(adminSvc, nil, nil)
|
||||||
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
||||||
|
|
||||||
router.GET("/api/v1/admin/users", userHandler.List)
|
router.GET("/api/v1/admin/users", userHandler.List)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,6 +18,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
type optionalLimitField struct {
|
type optionalLimitField struct {
|
||||||
@@ -69,9 +71,10 @@ func (f optionalLimitField) ToServiceInput() *float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewGroupHandler creates a new admin group handler
|
// NewGroupHandler creates a new admin group handler
|
||||||
func NewGroupHandler(adminService service.AdminService) *GroupHandler {
|
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService) *GroupHandler {
|
||||||
return &GroupHandler{
|
return &GroupHandler{
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
|
dashboardService: dashboardService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +366,22 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
|
|||||||
_ = groupID // TODO: implement actual stats
|
_ = groupID // TODO: implement actual stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUsageSummary returns today's and cumulative cost for all groups.
|
||||||
|
// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai
|
||||||
|
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||||
|
userTZ := c.Query("timezone")
|
||||||
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
|
todayStart := timezone.StartOfDayInUserLocation(now, userTZ)
|
||||||
|
|
||||||
|
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, 500, "Failed to get group usage 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) {
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
|||||||
DefaultMappedModel: g.DefaultMappedModel,
|
DefaultMappedModel: g.DefaultMappedModel,
|
||||||
SupportedModelScopes: g.SupportedModelScopes,
|
SupportedModelScopes: g.SupportedModelScopes,
|
||||||
AccountCount: g.AccountCount,
|
AccountCount: g.AccountCount,
|
||||||
|
ActiveAccountCount: g.ActiveAccountCount,
|
||||||
|
RateLimitedAccountCount: g.RateLimitedAccountCount,
|
||||||
SortOrder: g.SortOrder,
|
SortOrder: g.SortOrder,
|
||||||
}
|
}
|
||||||
if len(g.AccountGroups) > 0 {
|
if len(g.AccountGroups) > 0 {
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ type AdminGroup struct {
|
|||||||
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"`
|
||||||
|
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
||||||
|
|
||||||
// 分组排序
|
// 分组排序
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
|
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
|
||||||
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, error) { return 0, nil }
|
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil }
|
||||||
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,8 +273,8 @@ func (r *stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform strin
|
|||||||
func (r *stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) {
|
func (r *stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
|
||||||
return 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
func (r *stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
func (r *stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ type EndpointStat struct {
|
|||||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupUsageSummary represents today's and cumulative cost for a single group.
|
||||||
|
type GroupUsageSummary struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
TodayCost float64 `json:"today_cost"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
// GroupStat represents usage statistics for a single group
|
// GroupStat represents usage statistics for a single group
|
||||||
type GroupStat struct {
|
type GroupStat struct {
|
||||||
GroupID int64 `json:"group_id"`
|
GroupID int64 `json:"group_id"`
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
count, _ := r.GetAccountCount(ctx, out.ID)
|
total, active, _ := r.GetAccountCount(ctx, out.ID)
|
||||||
out.AccountCount = count
|
out.AccountCount = total
|
||||||
|
out.ActiveAccountCount = active
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +257,10 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
|
|||||||
counts, err := r.loadAccountCounts(ctx, groupIDs)
|
counts, err := r.loadAccountCounts(ctx, groupIDs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for i := range outGroups {
|
for i := range outGroups {
|
||||||
outGroups[i].AccountCount = counts[outGroups[i].ID]
|
c := counts[outGroups[i].ID]
|
||||||
|
outGroups[i].AccountCount = c.Total
|
||||||
|
outGroups[i].ActiveAccountCount = c.Active
|
||||||
|
outGroups[i].RateLimitedAccountCount = c.RateLimited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +287,10 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro
|
|||||||
counts, err := r.loadAccountCounts(ctx, groupIDs)
|
counts, err := r.loadAccountCounts(ctx, groupIDs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for i := range outGroups {
|
for i := range outGroups {
|
||||||
outGroups[i].AccountCount = counts[outGroups[i].ID]
|
c := counts[outGroups[i].ID]
|
||||||
|
outGroups[i].AccountCount = c.Total
|
||||||
|
outGroups[i].ActiveAccountCount = c.Active
|
||||||
|
outGroups[i].RateLimitedAccountCount = c.RateLimited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +317,10 @@ func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform str
|
|||||||
counts, err := r.loadAccountCounts(ctx, groupIDs)
|
counts, err := r.loadAccountCounts(ctx, groupIDs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for i := range outGroups {
|
for i := range outGroups {
|
||||||
outGroups[i].AccountCount = counts[outGroups[i].ID]
|
c := counts[outGroups[i].ID]
|
||||||
|
outGroups[i].AccountCount = c.Total
|
||||||
|
outGroups[i].ActiveAccountCount = c.Active
|
||||||
|
outGroups[i].RateLimitedAccountCount = c.RateLimited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +379,20 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) {
|
||||||
var count int64
|
var rateLimited int64
|
||||||
if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &count); err != nil {
|
err = scanSingleRow(ctx, r.sql,
|
||||||
return 0, err
|
`SELECT COUNT(*),
|
||||||
}
|
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true),
|
||||||
return count, nil
|
COUNT(*) FILTER (WHERE a.status = 'active' AND (
|
||||||
|
a.rate_limit_reset_at > NOW() OR
|
||||||
|
a.overload_until > NOW() OR
|
||||||
|
a.temp_unschedulable_until > NOW()
|
||||||
|
))
|
||||||
|
FROM account_groups ag JOIN accounts a ON a.id = ag.account_id
|
||||||
|
WHERE ag.group_id = $1`,
|
||||||
|
[]any{groupID}, &total, &active, &rateLimited)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
@@ -500,15 +518,32 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64,
|
|||||||
return affectedUserIDs, nil
|
return affectedUserIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]int64, err error) {
|
type groupAccountCounts struct {
|
||||||
counts = make(map[int64]int64, len(groupIDs))
|
Total int64
|
||||||
|
Active int64
|
||||||
|
RateLimited int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) {
|
||||||
|
counts = make(map[int64]groupAccountCounts, len(groupIDs))
|
||||||
if len(groupIDs) == 0 {
|
if len(groupIDs) == 0 {
|
||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.sql.QueryContext(
|
rows, err := r.sql.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
"SELECT group_id, COUNT(*) FROM account_groups WHERE group_id = ANY($1) GROUP BY group_id",
|
`SELECT ag.group_id,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true) AS active,
|
||||||
|
COUNT(*) FILTER (WHERE a.status = 'active' AND (
|
||||||
|
a.rate_limit_reset_at > NOW() OR
|
||||||
|
a.overload_until > NOW() OR
|
||||||
|
a.temp_unschedulable_until > NOW()
|
||||||
|
)) AS rate_limited
|
||||||
|
FROM account_groups ag
|
||||||
|
JOIN accounts a ON a.id = ag.account_id
|
||||||
|
WHERE ag.group_id = ANY($1)
|
||||||
|
GROUP BY ag.group_id`,
|
||||||
pq.Array(groupIDs),
|
pq.Array(groupIDs),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -523,11 +558,11 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var groupID int64
|
var groupID int64
|
||||||
var count int64
|
var c groupAccountCounts
|
||||||
if err = rows.Scan(&groupID, &count); err != nil {
|
if err = rows.Scan(&groupID, &c.Total, &c.Active, &c.RateLimited); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
counts[groupID] = count
|
counts[groupID] = c
|
||||||
}
|
}
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -603,7 +603,7 @@ func (s *GroupRepoSuite) TestGetAccountCount() {
|
|||||||
_, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, group.ID, 2)
|
_, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, group.ID, 2)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
|
|
||||||
count, err := s.repo.GetAccountCount(s.ctx, group.ID)
|
count, _, err := s.repo.GetAccountCount(s.ctx, group.ID)
|
||||||
s.Require().NoError(err, "GetAccountCount")
|
s.Require().NoError(err, "GetAccountCount")
|
||||||
s.Require().Equal(int64(2), count)
|
s.Require().Equal(int64(2), count)
|
||||||
}
|
}
|
||||||
@@ -619,7 +619,7 @@ func (s *GroupRepoSuite) TestGetAccountCount_Empty() {
|
|||||||
}
|
}
|
||||||
s.Require().NoError(s.repo.Create(s.ctx, group))
|
s.Require().NoError(s.repo.Create(s.ctx, group))
|
||||||
|
|
||||||
count, err := s.repo.GetAccountCount(s.ctx, group.ID)
|
count, _, err := s.repo.GetAccountCount(s.ctx, group.ID)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.Require().Zero(count)
|
s.Require().Zero(count)
|
||||||
}
|
}
|
||||||
@@ -651,7 +651,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() {
|
|||||||
s.Require().NoError(err, "DeleteAccountGroupsByGroupID")
|
s.Require().NoError(err, "DeleteAccountGroupsByGroupID")
|
||||||
s.Require().Equal(int64(1), affected, "expected 1 affected row")
|
s.Require().Equal(int64(1), affected, "expected 1 affected row")
|
||||||
|
|
||||||
count, err := s.repo.GetAccountCount(s.ctx, g.ID)
|
count, _, err := s.repo.GetAccountCount(s.ctx, g.ID)
|
||||||
s.Require().NoError(err, "GetAccountCount")
|
s.Require().NoError(err, "GetAccountCount")
|
||||||
s.Require().Equal(int64(0), count, "expected 0 account groups")
|
s.Require().Equal(int64(0), count, "expected 0 account groups")
|
||||||
}
|
}
|
||||||
@@ -692,7 +692,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID_MultipleAccounts() {
|
|||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.Require().Equal(int64(3), affected)
|
s.Require().Equal(int64(3), affected)
|
||||||
|
|
||||||
count, _ := s.repo.GetAccountCount(s.ctx, g.ID)
|
count, _, _ := s.repo.GetAccountCount(s.ctx, g.ID)
|
||||||
s.Require().Zero(count)
|
s.Require().Zero(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3000,6 +3000,7 @@ 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 := `
|
||||||
@@ -3067,6 +3068,43 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group.
|
||||||
|
// todayStart is the start-of-day in the caller's timezone (UTC-based).
|
||||||
|
// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation.
|
||||||
|
// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s)
|
||||||
|
// or a materialized view / pre-aggregation table for cumulative costs.
|
||||||
|
func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
g.id AS group_id,
|
||||||
|
COALESCE(SUM(ul.actual_cost), 0) AS total_cost,
|
||||||
|
COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN usage_logs ul ON ul.group_id = g.id
|
||||||
|
GROUP BY g.id
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.sql.QueryContext(ctx, query, todayStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []usagestats.GroupUsageSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var row usagestats.GroupUsageSummary
|
||||||
|
if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil {
|
||||||
|
>>>>>>> c8c1b4d4 (feat(admin): 分组管理列表新增用量列与账号数分类)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveEndpointColumn maps endpoint type to the corresponding DB column name.
|
// resolveEndpointColumn maps endpoint type to the corresponding DB column name.
|
||||||
func resolveEndpointColumn(endpointType string) string {
|
func resolveEndpointColumn(endpointType string) string {
|
||||||
switch endpointType {
|
switch endpointType {
|
||||||
|
|||||||
@@ -227,6 +227,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.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)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type UsageLogRepository interface {
|
|||||||
GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error)
|
GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error)
|
||||||
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
|
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
|
||||||
GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error)
|
GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error)
|
||||||
|
GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error)
|
||||||
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error)
|
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error)
|
||||||
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
|
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
|
||||||
GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error)
|
GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error)
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func (s *groupRepoStubForGroupUpdate) ListActiveByPlatform(context.Context, stri
|
|||||||
func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) {
|
func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) {
|
||||||
panic("unexpected")
|
panic("unexpected")
|
||||||
}
|
}
|
||||||
func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, error) {
|
func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, int64, error) {
|
||||||
panic("unexpected")
|
panic("unexpected")
|
||||||
}
|
}
|
||||||
func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func (s *groupRepoStub) ExistsByName(ctx context.Context, name string) (bool, er
|
|||||||
panic("unexpected ExistsByName call")
|
panic("unexpected ExistsByName call")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
|
||||||
panic("unexpected GetAccountCount call")
|
panic("unexpected GetAccountCount call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (s *groupRepoStubForAdmin) ExistsByName(_ context.Context, _ string) (bool,
|
|||||||
panic("unexpected ExistsByName call")
|
panic("unexpected ExistsByName call")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, error) {
|
func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) {
|
||||||
panic("unexpected GetAccountCount call")
|
panic("unexpected GetAccountCount call")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ func (s *groupRepoStubForFallbackCycle) ExistsByName(_ context.Context, _ string
|
|||||||
panic("unexpected ExistsByName call")
|
panic("unexpected ExistsByName call")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, error) {
|
func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) {
|
||||||
panic("unexpected GetAccountCount call")
|
panic("unexpected GetAccountCount call")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +458,7 @@ func (s *groupRepoStubForInvalidRequestFallback) ExistsByName(_ context.Context,
|
|||||||
panic("unexpected ExistsByName call")
|
panic("unexpected ExistsByName call")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, error) {
|
func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) {
|
||||||
panic("unexpected GetAccountCount call")
|
panic("unexpected GetAccountCount call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGroupUsageSummary returns today's and cumulative cost for all groups.
|
||||||
|
func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
|
||||||
|
results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get group usage summary: %w", err)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) {
|
func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) {
|
||||||
data, err := s.cache.GetDashboardStats(ctx)
|
data, err := s.cache.GetDashboardStats(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -278,8 +278,8 @@ func (m *mockGroupRepoForGateway) ListActiveByPlatform(ctx context.Context, plat
|
|||||||
func (m *mockGroupRepoForGateway) ExistsByName(ctx context.Context, name string) (bool, error) {
|
func (m *mockGroupRepoForGateway) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
|
||||||
return 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ func (m *mockGroupRepoForGemini) ListActiveByPlatform(ctx context.Context, platf
|
|||||||
func (m *mockGroupRepoForGemini) ExistsByName(ctx context.Context, name string) (bool, error) {
|
func (m *mockGroupRepoForGemini) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
|
||||||
return 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type Group struct {
|
|||||||
|
|
||||||
AccountGroups []AccountGroup
|
AccountGroups []AccountGroup
|
||||||
AccountCount int64
|
AccountCount int64
|
||||||
|
ActiveAccountCount int64
|
||||||
|
RateLimitedAccountCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Group) IsActive() bool {
|
func (g *Group) IsActive() bool {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type GroupRepository interface {
|
|||||||
ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error)
|
ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error)
|
||||||
|
|
||||||
ExistsByName(ctx context.Context, name string) (bool, error)
|
ExistsByName(ctx context.Context, name string) (bool, error)
|
||||||
GetAccountCount(ctx context.Context, groupID int64) (int64, error)
|
GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error)
|
||||||
DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error)
|
DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error)
|
||||||
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重)
|
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重)
|
||||||
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
|
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
|
||||||
@@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取账号数量
|
// 获取账号数量
|
||||||
accountCount, err := s.groupRepo.GetAccountCount(ctx, id)
|
accountCount, _, err := s.groupRepo.GetAccountCount(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get account count: %w", err)
|
return nil, fmt.Errorf("get account count: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([
|
|||||||
func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) {
|
func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, error) {
|
func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, int64, error) {
|
||||||
return 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err
|
|||||||
func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) {
|
func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) {
|
||||||
panic("unexpected ExistsByName call")
|
panic("unexpected ExistsByName call")
|
||||||
}
|
}
|
||||||
func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, error) {
|
func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) {
|
||||||
panic("unexpected GetAccountCount call")
|
panic("unexpected GetAccountCount call")
|
||||||
}
|
}
|
||||||
func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||||
|
|||||||
@@ -218,6 +218,22 @@ export async function batchSetGroupRateMultipliers(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary (today + cumulative cost) for all groups
|
||||||
|
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
|
||||||
|
* @returns Array of group usage summaries
|
||||||
|
*/
|
||||||
|
export async function getUsageSummary(
|
||||||
|
timezone?: string
|
||||||
|
): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> {
|
||||||
|
const { data } = await apiClient.get<
|
||||||
|
{ group_id: number; today_cost: number; total_cost: number }[]
|
||||||
|
>('/admin/groups/usage-summary', {
|
||||||
|
params: timezone ? { timezone } : undefined
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const groupsAPI = {
|
export const groupsAPI = {
|
||||||
list,
|
list,
|
||||||
getAll,
|
getAll,
|
||||||
@@ -232,7 +248,8 @@ export const groupsAPI = {
|
|||||||
getGroupRateMultipliers,
|
getGroupRateMultipliers,
|
||||||
clearGroupRateMultipliers,
|
clearGroupRateMultipliers,
|
||||||
batchSetGroupRateMultipliers,
|
batchSetGroupRateMultipliers,
|
||||||
updateSortOrder
|
updateSortOrder,
|
||||||
|
getUsageSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
export default groupsAPI
|
export default groupsAPI
|
||||||
|
|||||||
@@ -1505,6 +1505,7 @@ export default {
|
|||||||
rateMultiplier: 'Rate Multiplier',
|
rateMultiplier: 'Rate Multiplier',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
accounts: 'Accounts',
|
accounts: 'Accounts',
|
||||||
|
usage: 'Usage',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
billingType: 'Billing Type',
|
billingType: 'Billing Type',
|
||||||
@@ -1513,6 +1514,12 @@ export default {
|
|||||||
userNotes: 'Notes',
|
userNotes: 'Notes',
|
||||||
userStatus: 'Status'
|
userStatus: 'Status'
|
||||||
},
|
},
|
||||||
|
usageToday: 'Today',
|
||||||
|
usageTotal: 'Total',
|
||||||
|
accountsAvailable: 'Avail:',
|
||||||
|
accountsRateLimited: 'Limited:',
|
||||||
|
accountsTotal: 'Total:',
|
||||||
|
accountsUnit: '',
|
||||||
rateAndAccounts: '{rate}x rate · {count} accounts',
|
rateAndAccounts: '{rate}x rate · {count} accounts',
|
||||||
accountsCount: '{count} accounts',
|
accountsCount: '{count} accounts',
|
||||||
form: {
|
form: {
|
||||||
|
|||||||
@@ -1561,6 +1561,7 @@ export default {
|
|||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
apiKeys: 'API 密钥数',
|
apiKeys: 'API 密钥数',
|
||||||
accounts: '账号数',
|
accounts: '账号数',
|
||||||
|
usage: '用量',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
billingType: '计费类型',
|
billingType: '计费类型',
|
||||||
@@ -1569,6 +1570,12 @@ export default {
|
|||||||
userNotes: '备注',
|
userNotes: '备注',
|
||||||
userStatus: '状态'
|
userStatus: '状态'
|
||||||
},
|
},
|
||||||
|
usageToday: '今日',
|
||||||
|
usageTotal: '累计',
|
||||||
|
accountsAvailable: '可用:',
|
||||||
|
accountsRateLimited: '限流:',
|
||||||
|
accountsTotal: '总量:',
|
||||||
|
accountsUnit: '个账号',
|
||||||
form: {
|
form: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
|
|||||||
@@ -411,6 +411,8 @@ export interface AdminGroup extends Group {
|
|||||||
|
|
||||||
// 分组下账号数量(仅管理员可见)
|
// 分组下账号数量(仅管理员可见)
|
||||||
account_count?: number
|
account_count?: number
|
||||||
|
active_account_count?: number
|
||||||
|
rate_limited_account_count?: number
|
||||||
|
|
||||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||||
default_mapped_model?: string
|
default_mapped_model?: string
|
||||||
|
|||||||
@@ -158,12 +158,38 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-account_count="{ value }">
|
<template #cell-account_count="{ row }">
|
||||||
<span
|
<div class="space-y-0.5 text-xs">
|
||||||
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
|
<div>
|
||||||
>
|
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span>
|
||||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
<span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span>
|
||||||
</span>
|
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.rate_limited_account_count">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span>
|
||||||
|
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span>
|
||||||
|
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span>
|
||||||
|
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span>
|
||||||
|
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-usage="{ row }">
|
||||||
|
<div v-if="usageLoading" class="text-xs text-gray-400">—</div>
|
||||||
|
<div v-else class="space-y-0.5 text-xs">
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageToday') }}</span>
|
||||||
|
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageTotal') }}</span>
|
||||||
|
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-status="{ value }">
|
<template #cell-status="{ value }">
|
||||||
@@ -1827,6 +1853,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: '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 }
|
||||||
])
|
])
|
||||||
@@ -1963,6 +1990,8 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
|
|||||||
|
|
||||||
const groups = ref<AdminGroup[]>([])
|
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 usageLoading = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
platform: '',
|
platform: '',
|
||||||
@@ -2301,6 +2330,7 @@ const loadGroups = async () => {
|
|||||||
groups.value = response.items
|
groups.value = response.items
|
||||||
pagination.total = response.total
|
pagination.total = response.total
|
||||||
pagination.pages = response.pages
|
pagination.pages = response.pages
|
||||||
|
loadUsageSummary()
|
||||||
} 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
|
||||||
@@ -2314,6 +2344,29 @@ const loadGroups = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatCost = (cost: number): string => {
|
||||||
|
if (cost >= 1000) return cost.toFixed(0)
|
||||||
|
if (cost >= 100) return cost.toFixed(1)
|
||||||
|
return cost.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsageSummary = async () => {
|
||||||
|
usageLoading.value = true
|
||||||
|
try {
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
const data = await adminAPI.groups.getUsageSummary(tz)
|
||||||
|
const map = new Map<number, { today_cost: number; total_cost: number }>()
|
||||||
|
for (const item of data) {
|
||||||
|
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost })
|
||||||
|
}
|
||||||
|
usageMap.value = map
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading group usage summary:', error)
|
||||||
|
} finally {
|
||||||
|
usageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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