From 64fdc11ec4eb91bff76f2eb96bd4e0ae35a2770a Mon Sep 17 00:00:00 2001 From: Birditch Date: Thu, 9 Jul 2026 06:04:06 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20=E7=94=A8=E6=88=B7=E5=88=9B?= =?UTF-8?q?=E5=BB=BA/=E7=BC=96=E8=BE=91=E6=94=AF=E6=8C=81=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=B8=8E=E4=BF=AE=E6=94=B9=E8=A7=92=E8=89=B2=20(user/?= =?UTF-8?q?admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建用户时可指定角色,缺省仍为 user,不再硬编码为普通用户 - 编辑用户时可在 user/admin 之间切换角色 - 后端对角色做 admin/user 合法性校验 - 防锁死保护:管理员不能把自己降级为普通用户(与既有"不能禁用/删除 admin"保护一致;降级其他管理员仍允许) - 前端创建/编辑弹窗新增角色下拉,复用既有 i18n 文案 - 新增角色创建/更新/非法值/防降级单元测试 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/handler/admin/user_handler.go | 11 +++ backend/internal/service/admin_service.go | 2 + .../service/admin_service_role_test.go | 85 +++++++++++++++++++ backend/internal/service/admin_user.go | 29 ++++++- frontend/src/api/admin/users.ts | 1 + .../components/admin/user/UserCreateModal.vue | 11 ++- .../components/admin/user/UserEditModal.vue | 13 ++- 7 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 backend/internal/service/admin_service_role_test.go diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index b47310da1d..72f8f36f57 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -53,6 +53,7 @@ type CreateUserRequest struct { Password string `json:"password" binding:"required,min=6"` Username string `json:"username"` Notes string `json:"notes"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` Balance *float64 `json:"balance"` Concurrency int `json:"concurrency"` RPMLimit int `json:"rpm_limit"` @@ -66,6 +67,7 @@ type UpdateUserRequest struct { Password string `json:"password" binding:"omitempty,min=6"` Username *string `json:"username"` Notes *string `json:"notes"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` Balance *float64 `json:"balance"` Concurrency *int `json:"concurrency"` RPMLimit *int `json:"rpm_limit"` @@ -269,6 +271,7 @@ func (h *UserHandler) Create(c *gin.Context) { Password: req.Password, Username: req.Username, Notes: req.Notes, + Role: req.Role, Balance: req.Balance, Concurrency: req.Concurrency, RPMLimit: req.RPMLimit, @@ -297,12 +300,20 @@ func (h *UserHandler) Update(c *gin.Context) { return } + // 防锁死保护:管理员不能把自己降级为普通用户(单管理员场景下会失去后台访问权)。 + // 与既有"不能禁用/删除 admin"保护一致。降级其他管理员仍然允许。 + if req.Role == service.RoleUser && userID == getAdminIDFromContext(c) { + response.BadRequest(c, "cannot demote yourself from admin") + return + } + // 使用指针类型直接传递,nil 表示未提供该字段 user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{ Email: req.Email, Password: req.Password, Username: req.Username, Notes: req.Notes, + Role: req.Role, Balance: req.Balance, Concurrency: req.Concurrency, RPMLimit: req.RPMLimit, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index b6377123b1..1152b14132 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -125,6 +125,7 @@ type CreateUserInput struct { Password string Username string Notes string + Role string // 空字符串表示使用默认角色(user);合法值 admin/user Balance *float64 Concurrency int RPMLimit int @@ -136,6 +137,7 @@ type UpdateUserInput struct { Password string Username *string Notes *string + Role string // 空字符串表示"未提供"(不修改);合法值 admin/user Balance *float64 // 使用指针区分"未提供"和"设置为0" Concurrency *int // 使用指针区分"未提供"和"设置为0" RPMLimit *int // 使用指针区分"未提供"和"设置为0" diff --git a/backend/internal/service/admin_service_role_test.go b/backend/internal/service/admin_service_role_test.go new file mode 100644 index 0000000000..26cf9279b2 --- /dev/null +++ b/backend/internal/service/admin_service_role_test.go @@ -0,0 +1,85 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAdminService_CreateUser_WithAdminRole(t *testing.T) { + repo := &userRepoStub{nextID: 30} + svc := &adminServiceImpl{userRepo: repo} + + user, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "admin@test.com", + Password: "strong-pass", + Role: RoleAdmin, + }) + require.NoError(t, err) + require.Equal(t, RoleAdmin, user.Role) +} + +func TestAdminService_CreateUser_DefaultsToUserRole(t *testing.T) { + repo := &userRepoStub{nextID: 31} + svc := &adminServiceImpl{userRepo: repo} + + user, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "plain@test.com", + Password: "strong-pass", + }) + require.NoError(t, err) + require.Equal(t, RoleUser, user.Role) +} + +func TestAdminService_CreateUser_InvalidRoleRejected(t *testing.T) { + repo := &userRepoStub{nextID: 32} + svc := &adminServiceImpl{userRepo: repo} + + _, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "bad@test.com", + Password: "strong-pass", + Role: "superuser", + }) + require.Error(t, err) + require.Empty(t, repo.created, "非法角色不应写入用户") +} + +func TestAdminService_UpdateUser_PromoteToAdmin(t *testing.T) { + base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleUser}} + repo := &rpmUserRepoStub{userRepoStub: base} + invalidator := &authCacheInvalidatorStub{} + svc := &adminServiceImpl{ + userRepo: repo, + redeemCodeRepo: &redeemRepoStub{}, + authCacheInvalidator: invalidator, + } + + updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleAdmin}) + require.NoError(t, err) + require.Equal(t, RoleAdmin, updated.Role) + require.Equal(t, []int64{42}, invalidator.userIDs, "角色变更应失效认证缓存") +} + +func TestAdminService_UpdateUser_RoleOmittedKeepsExisting(t *testing.T) { + base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleAdmin}} + repo := &rpmUserRepoStub{userRepoStub: base} + svc := &adminServiceImpl{userRepo: repo, redeemCodeRepo: &redeemRepoStub{}} + + newName := "renamed" + updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Username: &newName}) + require.NoError(t, err) + require.Equal(t, RoleAdmin, updated.Role, "未提供 role 时不应改变现有角色") +} + +func TestAdminService_UpdateUser_InvalidRoleRejected(t *testing.T) { + base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleUser}} + repo := &rpmUserRepoStub{userRepoStub: base} + svc := &adminServiceImpl{userRepo: repo, redeemCodeRepo: &redeemRepoStub{}} + + _, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: "root"}) + require.Error(t, err) + require.Nil(t, repo.lastUpdated, "非法角色不应触发持久化") +} diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go index e95ed3d92e..9acd2deba7 100644 --- a/backend/internal/service/admin_user.go +++ b/backend/internal/service/admin_user.go @@ -105,6 +105,18 @@ func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) return s.userRepo.GetByIDIncludeDeleted(ctx, id) } +// normalizeUserRole 校验并归一化角色输入。 +// 空字符串返回 fallback(未提供时的默认角色);非法值返回错误。 +func normalizeUserRole(role, fallback string) (string, error) { + if role == "" { + return fallback, nil + } + if role != RoleAdmin && role != RoleUser { + return "", fmt.Errorf("invalid role: %q (must be %s or %s)", role, RoleAdmin, RoleUser) + } + return role, nil +} + func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { balance := 0.0 if input.Balance != nil { @@ -113,11 +125,17 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu balance = s.settingService.GetDefaultBalance(ctx) } + // 角色可由管理员在创建时指定(admin/user);未提供时默认 user。 + role, err := normalizeUserRole(input.Role, RoleUser) + if err != nil { + return nil, err + } + user := &User{ Email: input.Email, Username: input.Username, Notes: input.Notes, - Role: RoleUser, // Always create as regular user, never admin + Role: role, Balance: balance, Concurrency: input.Concurrency, RPMLimit: input.RPMLimit, @@ -197,6 +215,15 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda user.Status = input.Status } + // 角色变更(admin/user);空字符串表示不修改。 + if input.Role != "" { + role, err := normalizeUserRole(input.Role, user.Role) + if err != nil { + return nil, err + } + user.Role = role + } + if input.Concurrency != nil { user.Concurrency = *input.Concurrency } diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 8ff022ff68..78b4f8177b 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -121,6 +121,7 @@ export async function create(userData: { password: string username?: string notes?: string + role?: 'admin' | 'user' balance?: number concurrency?: number rpm_limit?: number diff --git a/frontend/src/components/admin/user/UserCreateModal.vue b/frontend/src/components/admin/user/UserCreateModal.vue index a638e79cf5..609f7cd520 100644 --- a/frontend/src/components/admin/user/UserCreateModal.vue +++ b/frontend/src/components/admin/user/UserCreateModal.vue @@ -25,6 +25,13 @@ +
+ + +
@@ -69,7 +76,7 @@ import Icon from '@/components/icons/Icon.vue' const props = defineProps<{ show: boolean }>() const emit = defineEmits(['close', 'success']); const { t } = useI18n() -const form = reactive({ email: '', password: '', username: '', notes: '', balance: '', concurrency: 1, rpm_limit: 0 }) +const form = reactive({ email: '', password: '', username: '', notes: '', role: 'user' as 'user' | 'admin', balance: '', concurrency: 1, rpm_limit: 0 }) const { loading, submit } = useForm({ form, @@ -86,7 +93,7 @@ const { loading, submit } = useForm({ successMsg: t('admin.users.userCreated') }) -watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: '', concurrency: 1, rpm_limit: 0 }) }) +watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', role: 'user', balance: '', concurrency: 1, rpm_limit: 0 }) }) const generateRandomPassword = () => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*' diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index de23498627..75b3563d5c 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -29,6 +29,13 @@
+
+ + +
@@ -78,11 +85,11 @@ const emit = defineEmits(['close', 'success']) const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard() const submitting = ref(false); const passwordCopied = ref(false) -const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, rpm_limit: 0, customAttributes: {} as UserAttributeValuesMap }) +const form = reactive({ email: '', password: '', username: '', notes: '', role: 'user', concurrency: 1, rpm_limit: 0, customAttributes: {} as UserAttributeValuesMap }) watch(() => props.user, (u) => { if (u) { - Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, rpm_limit: u.rpm_limit ?? 0, customAttributes: {} }) + Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', role: u.role || 'user', concurrency: u.concurrency, rpm_limit: u.rpm_limit ?? 0, customAttributes: {} }) passwordCopied.value = false } }, { immediate: true }) @@ -109,7 +116,7 @@ const handleUpdateUser = async () => { } submitting.value = true try { - const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, rpm_limit: form.rpm_limit } + const data: any = { email: form.email, username: form.username, notes: form.notes, role: form.role, concurrency: form.concurrency, rpm_limit: form.rpm_limit } if (form.password.trim()) data.password = form.password.trim() await adminAPI.users.update(props.user.id, data) if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes) From b062b366463b9c72a190237a66052d49bdb16171 Mon Sep 17 00:00:00 2001 From: Birditch Date: Thu, 9 Jul 2026 06:04:22 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(admin):=20=E7=94=A8=E9=87=8F=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E6=96=B0=E5=A2=9E"=E7=94=A8=E6=88=B7=20Token?= =?UTF-8?q?=20=E6=8E=92=E8=A1=8C"=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 /admin/usage 新增按用户聚合的 Token 排行表(输入/输出/缓存/总 Token、请求数、费用) - 支持按列排序、邮箱搜索、Top N (20/50/100/200),点击行下钻到该用户 - 复用现有筛选(用户/时间/模型/分组等)与既有 KPI 卡片、逐条日志 - 后端对 /admin/dashboard/user-breakdown 做加法扩展(向后兼容): - UserBreakdownItem 增加 input_tokens/output_tokens/cache_tokens - 新增 sort_by(白名单排序,防注入,缺省仍按 actual_cost) - 新增 sort_by 转发单元测试;更新 UsageView.spec 稳定桩 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handler/admin/dashboard_handler.go | 3 + .../dashboard_handler_user_breakdown_test.go | 16 +- .../pkg/usagestats/usage_log_types.go | 19 +- .../repository/usage_log_repo_trend.go | 14 +- frontend/src/api/admin/dashboard.ts | 2 + .../admin/usage/UserTokenRanking.vue | 180 ++++++++++++++++++ .../src/i18n/locales/en/admin/resources.ts | 15 ++ .../src/i18n/locales/zh/admin/resources.ts | 15 ++ frontend/src/types/index.ts | 3 + frontend/src/views/admin/UsageView.vue | 15 ++ .../views/admin/__tests__/UsageView.spec.ts | 6 +- 11 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/admin/usage/UserTokenRanking.vue diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index e9fbb630fa..b42b395d33 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -675,6 +675,9 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) { } } + // sort_by 由 repo 层 allowlist 校验;非法值静默回退默认排序(actual_cost)。 + dim.SortBy = strings.TrimSpace(c.Query("sort_by")) + limit := 50 if v := c.Query("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { diff --git a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go index b3a05111b3..3065eee3c4 100644 --- a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go +++ b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go @@ -59,7 +59,21 @@ func TestGetUserBreakdown_GroupIDFilter(t *testing.T) { require.Equal(t, int64(42), repo.capturedDim.GroupID) require.Empty(t, repo.capturedDim.Model) require.Empty(t, repo.capturedDim.Endpoint) - require.Equal(t, 50, repo.capturedLimit) // default limit + require.Equal(t, 50, repo.capturedLimit) // default limit + require.Empty(t, repo.capturedDim.SortBy) // no sort_by => empty (repo falls back to default) +} + +func TestGetUserBreakdown_SortBy(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&sort_by=total_tokens", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "total_tokens", repo.capturedDim.SortBy) } func TestGetUserBreakdown_ModelFilter(t *testing.T) { diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index a96ea670c4..29af4d2d50 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -164,13 +164,16 @@ type UserSpendingRankingResponse struct { // UserBreakdownItem represents per-user usage breakdown within a dimension (group, model, endpoint). type UserBreakdownItem struct { - UserID int64 `json:"user_id"` - Email string `json:"email"` - Requests int64 `json:"requests"` - TotalTokens int64 `json:"total_tokens"` - Cost float64 `json:"cost"` // 标准计费 - ActualCost float64 `json:"actual_cost"` // 实际扣除 - AccountCost float64 `json:"account_cost"` // 账号成本 + UserID int64 `json:"user_id"` + Email string `json:"email"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` // 输入 token 累计 + OutputTokens int64 `json:"output_tokens"` // 输出 token 累计 + CacheTokens int64 `json:"cache_tokens"` // 缓存创建 + 读取 token 累计 + TotalTokens int64 `json:"total_tokens"` // 输入+输出+缓存 token 累计 + Cost float64 `json:"cost"` // 标准计费 + ActualCost float64 `json:"actual_cost"` // 实际扣除 + AccountCost float64 `json:"account_cost"` // 账号成本 } // UserBreakdownDimension specifies the dimension to filter for user breakdown. @@ -187,6 +190,8 @@ type UserBreakdownDimension struct { RequestType *int16 // filter by request_type (non-nil to enable) Stream *bool // filter by stream flag (non-nil to enable) BillingType *int8 // filter by billing_type (non-nil to enable) + // SortBy 指定排序列(空 = 默认按 actual_cost)。合法值由 repo 层 allowlist 校验。 + SortBy string } // APIKeyUsageTrendPoint represents API key usage trend data point diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 392f1c8300..7302296683 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -603,6 +603,9 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim COALESCE(ul.user_id, 0) as user_id, COALESCE(u.email, '') as email, COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens), 0) as input_tokens, + COALESCE(SUM(ul.output_tokens), 0) as output_tokens, + COALESCE(SUM(ul.cache_creation_tokens + ul.cache_read_tokens), 0) as cache_tokens, COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, COALESCE(SUM(ul.total_cost), 0) as cost, COALESCE(SUM(ul.actual_cost), 0) as actual_cost, @@ -651,7 +654,13 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim args = append(args, *dim.BillingType) } - query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC" + // ORDER BY 列来自固定 allowlist(非用户原样字符串),避免 SQL 注入。 + orderBy := "actual_cost" + switch dim.SortBy { + case "total_tokens", "input_tokens", "output_tokens", "cache_tokens", "requests", "cost", "actual_cost": + orderBy = dim.SortBy + } + query += " GROUP BY ul.user_id, u.email ORDER BY " + orderBy + " DESC" if limit > 0 { query += fmt.Sprintf(" LIMIT %d", limit) } @@ -674,6 +683,9 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim &row.UserID, &row.Email, &row.Requests, + &row.InputTokens, + &row.OutputTokens, + &row.CacheTokens, &row.TotalTokens, &row.Cost, &row.ActualCost, diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index dda7d8927d..97c16aa362 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -167,6 +167,8 @@ export interface UserBreakdownParams { endpoint?: string endpoint_type?: 'inbound' | 'upstream' | 'path' limit?: number + // Sort column for the ranking (allowlisted server-side; falls back to actual_cost) + sort_by?: 'total_tokens' | 'input_tokens' | 'output_tokens' | 'cache_tokens' | 'requests' | 'cost' | 'actual_cost' // Additional filter conditions user_id?: number api_key_id?: number diff --git a/frontend/src/components/admin/usage/UserTokenRanking.vue b/frontend/src/components/admin/usage/UserTokenRanking.vue new file mode 100644 index 0000000000..ad24ae1288 --- /dev/null +++ b/frontend/src/components/admin/usage/UserTokenRanking.vue @@ -0,0 +1,180 @@ + + + diff --git a/frontend/src/i18n/locales/en/admin/resources.ts b/frontend/src/i18n/locales/en/admin/resources.ts index c6c42d4494..d7a48d0ad2 100644 --- a/frontend/src/i18n/locales/en/admin/resources.ts +++ b/frontend/src/i18n/locales/en/admin/resources.ts @@ -483,6 +483,21 @@ export default { clickToViewBalance: 'Click to view balance history', failedToLoadUser: 'Failed to load user info', userDeletedBadge: 'Deleted', + tokenRanking: { + title: 'User Token Ranking', + subtitle: 'Per-user token usage for the current filters and time range; click a row to drill down', + searchPlaceholder: 'Search user email...', + userCount: '{count} users', + columns: { + user: 'User', + requests: 'Requests', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheTokens: 'Cache Tokens', + totalTokens: 'Total Tokens', + cost: 'Cost' + } + }, cleanup: { button: 'Cleanup', title: 'Cleanup Usage Records', diff --git a/frontend/src/i18n/locales/zh/admin/resources.ts b/frontend/src/i18n/locales/zh/admin/resources.ts index 8398130d84..a99203521a 100644 --- a/frontend/src/i18n/locales/zh/admin/resources.ts +++ b/frontend/src/i18n/locales/zh/admin/resources.ts @@ -544,6 +544,21 @@ export default { clickToViewBalance: '点击查看充值记录', failedToLoadUser: '加载用户信息失败', userDeletedBadge: '已删除', + tokenRanking: { + title: '用户 Token 排行', + subtitle: '按当前筛选与时间范围统计每个用户的 Token 用量,点击行可下钻到该用户', + searchPlaceholder: '搜索用户邮箱...', + userCount: '共 {count} 位用户', + columns: { + user: '用户', + requests: '请求数', + inputTokens: '输入 Token', + outputTokens: '输出 Token', + cacheTokens: '缓存 Token', + totalTokens: '总 Token', + cost: '费用' + } + }, cleanup: { button: '清理', title: '清理使用记录', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 990ef0575c..a60c70116c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1594,6 +1594,9 @@ export interface UserBreakdownItem { user_id: number email: string requests: number + input_tokens: number + output_tokens: number + cache_tokens: number total_tokens: number cost: number actual_cost: number diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index eb9a047662..3509b99d72 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -109,6 +109,14 @@
+ { } } +// Drill down from the per-user token ranking: scope the whole usage view to that user. +const handleRankingSelectUser = (userId: number) => { + filters.value = { ...filters.value, user_id: userId } + applyFilters() +} + const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }]) // Use local timezone to avoid UTC timezone issues const formatLD = (d: Date) => { diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts index 1da0d09c04..f83d609b86 100644 --- a/frontend/src/views/admin/__tests__/UsageView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts @@ -163,7 +163,7 @@ describe('admin UsageView distribution metric toggles', () => { UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub, - EndpointDistributionChart: true, + EndpointDistributionChart: true, UserTokenRanking: true, } }, }) vi.advanceTimersByTime(120) @@ -201,6 +201,7 @@ describe('admin UsageView distribution metric toggles', () => { TokenUsageTrend: true, ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub, + UserTokenRanking: true, }, }, }) @@ -281,6 +282,7 @@ describe('admin UsageView handleUserClick', () => { ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, + UserTokenRanking: true, }, }, }) @@ -326,7 +328,7 @@ describe('admin UsageView errors tab filter forwarding', () => { UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, - OpsErrorLogTable: true, OpsErrorDetailModal: true, + UserTokenRanking: true, OpsErrorLogTable: true, OpsErrorDetailModal: true, } }, }) vi.advanceTimersByTime(120)