Merge pull request #4425 from AdrianZhaoDev/agent/admin-users-batch-limits

feat(admin): batch update user concurrency and RPM
This commit is contained in:
Wesley Liddick
2026-07-16 19:33:32 +08:00
committed by GitHub
31 changed files with 1243 additions and 9 deletions
@@ -195,6 +195,10 @@ func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs [
return len(userIDs), nil
}
func (s *stubAdminService) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
return len(userIDs), nil
}
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
@@ -606,6 +606,73 @@ func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
response.Success(c, gin.H{"affected": affected})
}
// BatchUpdateLimits overwrites concurrency and/or RPM limits for multiple users.
// POST /api/v1/admin/users/batch-limits
type BatchUpdateLimitsRequest struct {
UserIDs []int64 `json:"user_ids"`
All bool `json:"all"`
Concurrency *int `json:"concurrency" binding:"omitempty,min=0"`
RPMLimit *int `json:"rpm_limit" binding:"omitempty,min=0"`
}
func (h *UserHandler) BatchUpdateLimits(c *gin.Context) {
var req BatchUpdateLimitsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if req.Concurrency == nil && req.RPMLimit == nil {
response.BadRequest(c, "at least one of concurrency or rpm_limit is required")
return
}
if !req.All && len(req.UserIDs) == 0 {
response.BadRequest(c, "user_ids is required unless all=true")
return
}
if !req.All && len(req.UserIDs) > 500 {
response.BadRequest(c, "user_ids cannot exceed 500")
return
}
userIDs := req.UserIDs
if req.All {
userIDs = nil
page := 1
const pageSize = 500
for {
users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
if len(users) < pageSize {
break
}
page++
}
}
if len(userIDs) == 0 {
response.Success(c, gin.H{"affected": 0})
return
}
affected, err := h.adminService.BatchUpdateLimits(
c.Request.Context(),
userIDs,
req.Concurrency,
req.RPMLimit,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": affected})
}
// GetUserPlatformQuotas GET /admin/users/:id/platform-quotas
// admin 视角:D14 lazy 归零 + 暴露 *_window_start 调试字段
func (h *UserHandler) GetUserPlatformQuotas(c *gin.Context) {
@@ -0,0 +1,146 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type batchLimitsAdminServiceStub struct {
*stubAdminService
calls []batchLimitsAdminServiceCall
}
type batchLimitsAdminServiceCall struct {
userIDs []int64
concurrency *int
rpmLimit *int
}
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
s.calls = append(s.calls, batchLimitsAdminServiceCall{
userIDs: append([]int64(nil), userIDs...),
concurrency: cloneIntPointer(concurrency),
rpmLimit: cloneIntPointer(rpmLimit),
})
return len(userIDs), nil
}
func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewUserHandler(serviceStub, nil, nil, nil)
router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits)
return router
}
func postBatchLimits(t *testing.T, router *gin.Engine, body []byte) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/users/batch-limits",
bytes.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
return recorder
}
func TestUserHandlerBatchUpdateLimitsAcceptsPartialAndZeroValues(t *testing.T) {
tests := []struct {
name string
body string
expectedConcurrency *int
expectedRPMLimit *int
}{
{name: "concurrency only", body: `{"user_ids":[1,2],"concurrency":10}`, expectedConcurrency: pointerTo(10)},
{name: "both limits", body: `{"user_ids":[1,2],"concurrency":8,"rpm_limit":60}`, expectedConcurrency: pointerTo(8), expectedRPMLimit: pointerTo(60)},
{name: "explicit zero", body: `{"user_ids":[1,2],"concurrency":0,"rpm_limit":0}`, expectedConcurrency: pointerTo(0), expectedRPMLimit: pointerTo(0)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), []byte(test.body))
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, serviceStub.calls, 1)
require.Equal(t, []int64{1, 2}, serviceStub.calls[0].userIDs)
require.Equal(t, test.expectedConcurrency, serviceStub.calls[0].concurrency)
require.Equal(t, test.expectedRPMLimit, serviceStub.calls[0].rpmLimit)
var response struct {
Data struct {
Affected int `json:"affected"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, 2, response.Data.Affected)
})
}
}
func TestUserHandlerBatchUpdateLimitsRejectsInvalidRequests(t *testing.T) {
tooManyIDs := make([]int64, 501)
for index := range tooManyIDs {
tooManyIDs[index] = int64(index + 1)
}
tooManyBody, err := json.Marshal(map[string]any{"user_ids": tooManyIDs, "rpm_limit": 10})
require.NoError(t, err)
tests := []struct {
name string
body []byte
}{
{name: "no limits", body: []byte(`{"user_ids":[1]}`)},
{name: "invalid json", body: []byte(`{"user_ids":`)},
{name: "missing user ids", body: []byte(`{"rpm_limit":10}`)},
{name: "more than 500 ids", body: tooManyBody},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), test.body)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Empty(t, serviceStub.calls)
})
}
}
func TestUserHandlerBatchUpdateLimitsAllUsesEveryListedUser(t *testing.T) {
base := newStubAdminService()
base.users = []service.User{{ID: 11}, {ID: 12}, {ID: 13}}
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: base}
recorder := postBatchLimits(
t,
setupBatchLimitsRouter(serviceStub),
[]byte(`{"all":true,"user_ids":[999],"rpm_limit":0}`),
)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, serviceStub.calls, 1)
require.Equal(t, []int64{11, 12, 13}, serviceStub.calls[0].userIDs)
require.Equal(t, 1, base.lastListUsers.calls)
}
func pointerTo(value int) *int {
return &value
}
@@ -3168,6 +3168,9 @@ func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64,
func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
panic("unexpected BatchAddConcurrency call")
}
func (r *oauthPendingFlowUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
panic("unexpected BatchUpdateLimits call")
}
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
return map[int64]*time.Time{}, nil
@@ -93,6 +93,9 @@ func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int)
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
+33
View File
@@ -867,6 +867,39 @@ func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int6
return int(affected), nil
}
func (r *userRepository) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
if len(userIDs) == 0 || (concurrency == nil && rpmLimit == nil) {
return 0, nil
}
setClauses := make([]string, 0, 3)
args := make([]any, 0, 3)
if concurrency != nil {
value := max(*concurrency, 0)
args = append(args, value)
setClauses = append(setClauses, fmt.Sprintf("concurrency = $%d", len(args)))
}
if rpmLimit != nil {
value := max(*rpmLimit, 0)
args = append(args, value)
setClauses = append(setClauses, fmt.Sprintf("rpm_limit = $%d", len(args)))
}
setClauses = append(setClauses, "updated_at = NOW()")
args = append(args, pq.Array(userIDs))
query := fmt.Sprintf(
"UPDATE users SET %s WHERE id = ANY($%d) AND deleted_at IS NULL",
strings.Join(setClauses, ", "),
len(args),
)
res, err := r.sql.ExecContext(ctx, query, args...)
if err != nil {
return 0, fmt.Errorf("batch update user limits: %w", err)
}
affected, _ := res.RowsAffected()
return int(affected), nil
}
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
}
@@ -161,6 +161,60 @@ func (s *UserRepoSuite) TestUpdate() {
s.Require().Equal("updated", updated.Username)
}
func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesOnlyProvidedFields() {
user := s.mustCreateUser(&service.User{
Email: "batch-limits-one-field@test.com",
Concurrency: 4,
RPMLimit: 20,
})
concurrency := 9
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, nil)
s.Require().NoError(err)
s.Equal(1, affected)
updated, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err)
s.Equal(9, updated.Concurrency)
s.Equal(20, updated.RPMLimit)
}
func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesBothFieldsToZero() {
user := s.mustCreateUser(&service.User{
Email: "batch-limits-zero@test.com",
Concurrency: 4,
RPMLimit: 20,
})
zero := 0
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &zero, &zero)
s.Require().NoError(err)
s.Equal(1, affected)
updated, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err)
s.Zero(updated.Concurrency)
s.Zero(updated.RPMLimit)
}
func (s *UserRepoSuite) TestBatchUpdateLimitsIgnoresDeletedUsersAndReturnsAffectedRows() {
active := s.mustCreateUser(&service.User{Email: "batch-limits-active@test.com", RPMLimit: 10})
deleted := s.mustCreateUser(&service.User{Email: "batch-limits-deleted@test.com", RPMLimit: 10})
s.Require().NoError(s.client.User.DeleteOneID(deleted.ID).Exec(s.ctx))
rpmLimit := 45
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{active.ID, deleted.ID}, nil, &rpmLimit)
s.Require().NoError(err)
s.Equal(1, affected)
updatedActive, err := s.repo.GetByID(s.ctx, active.ID)
s.Require().NoError(err)
s.Equal(45, updatedActive.RPMLimit)
updatedDeleted, err := s.repo.GetByIDIncludeDeleted(s.ctx, deleted.ID)
s.Require().NoError(err)
s.Equal(10, updatedDeleted.RPMLimit)
}
func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpsert() {
user := s.mustCreateUser(&service.User{Email: "update-existing-identity@test.com", Username: "original"})
@@ -1557,6 +1557,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (r *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
return false, errors.New("not implemented")
@@ -200,6 +200,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
panic("unexpected ExistsByEmail call")
+1
View File
@@ -275,6 +275,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency)
users.POST("/batch-limits", h.Admin.User.BatchUpdateLimits)
users.GET("/:id/platform-quotas", h.Admin.User.GetUserPlatformQuotas)
users.PUT("/:id/platform-quotas", h.Admin.User.UpdateUserPlatformQuotas)
users.POST("/:id/platform-quotas/reset", h.Admin.User.ResetUserPlatformQuotaWindow)
@@ -20,6 +20,7 @@ type AdminService interface {
DeleteUser(ctx context.Context, id int64) error
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error)
BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
@@ -75,6 +75,9 @@ func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int6
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
panic("unexpected")
}
@@ -0,0 +1,92 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
)
type batchLimitsUserRepoStub struct {
*userRepoStub
calls int
userIDs []int64
concurrency *int
rpmLimit *int
affected int
err error
}
func (s *batchLimitsUserRepoStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
s.calls++
s.userIDs = append([]int64(nil), userIDs...)
s.concurrency = cloneBatchLimitValue(concurrency)
s.rpmLimit = cloneBatchLimitValue(rpmLimit)
return s.affected, s.err
}
func cloneBatchLimitValue(value *int) *int {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func TestAdminServiceBatchUpdateLimitsPassesOnlyProvidedFields(t *testing.T) {
concurrency := 0
repo := &batchLimitsUserRepoStub{
userRepoStub: &userRepoStub{},
affected: 2,
}
invalidator := &authCacheInvalidatorStub{}
service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator}
affected, err := service.BatchUpdateLimits(
context.Background(),
[]int64{3, 0, 3, 7, -1},
&concurrency,
nil,
)
require.NoError(t, err)
require.Equal(t, 2, affected)
require.Equal(t, []int64{3, 7}, repo.userIDs)
require.Equal(t, pointerToInt(0), repo.concurrency)
require.Nil(t, repo.rpmLimit)
require.Equal(t, []int64{3, 7}, invalidator.userIDs)
}
func TestAdminServiceBatchUpdateLimitsDoesNotInvalidateCacheOnRepositoryError(t *testing.T) {
rpmLimit := 60
repo := &batchLimitsUserRepoStub{
userRepoStub: &userRepoStub{},
err: errors.New("database unavailable"),
}
invalidator := &authCacheInvalidatorStub{}
service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator}
affected, err := service.BatchUpdateLimits(context.Background(), []int64{1, 2}, nil, &rpmLimit)
require.EqualError(t, err, "database unavailable")
require.Zero(t, affected)
require.Empty(t, invalidator.userIDs)
}
func TestAdminServiceBatchUpdateLimitsRequiresAField(t *testing.T) {
repo := &batchLimitsUserRepoStub{userRepoStub: &userRepoStub{}}
service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: &authCacheInvalidatorStub{}}
affected, err := service.BatchUpdateLimits(context.Background(), []int64{1}, nil, nil)
require.Error(t, err)
require.Zero(t, affected)
require.Zero(t, repo.calls)
}
func pointerToInt(value int) *int {
return &value
}
@@ -133,6 +133,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i
func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
if s.existsErr != nil {
@@ -119,6 +119,9 @@ func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *emailSyncRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
+33
View File
@@ -459,6 +459,39 @@ func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs [
return affected, nil
}
func (s *adminServiceImpl) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
if concurrency == nil && rpmLimit == nil {
return 0, fmt.Errorf("at least one of concurrency or rpm_limit is required")
}
cleaned := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
cleaned = append(cleaned, userID)
}
if len(cleaned) == 0 {
return 0, nil
}
affected, err := s.userRepo.BatchUpdateLimits(ctx, cleaned, concurrency, rpmLimit)
if err != nil {
return 0, err
}
if s.authCacheInvalidator != nil {
for _, userID := range cleaned {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
}
return affected, nil
}
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
@@ -963,6 +963,9 @@ func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, in
func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *emailBindUserRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
@@ -249,6 +249,9 @@ func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context,
func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
panic("unexpected BatchAddConcurrency call")
}
func (r *contentModerationTestUserRepo) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
panic("unexpected BatchUpdateLimits call")
}
func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
panic("unexpected ExistsByEmail call")
+1
View File
@@ -107,6 +107,7 @@ type UserRepository interface {
UpdateConcurrency(ctx context.Context, id int64, amount int) error
BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
@@ -208,7 +208,10 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int
func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (m *mockUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
return 0, nil
}
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
out := make([]UserAuthIdentityRecord, len(m.identities))
copy(out, m.identities)
@@ -11,9 +11,12 @@ vi.mock('@/api/client', () => ({
}))
import {
batchUpdateLimits,
bindUserAuthIdentity,
type AdminBindAuthIdentityRequest,
type AdminBoundAuthIdentity,
type BatchUpdateUserLimitsRequest,
type BatchUpdateUserLimitsResponse,
} from '@/api/admin/users'
type Assert<T extends true> = T
@@ -63,6 +66,20 @@ const requestContractExact: Assert<
const responseContractExact: Assert<
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
> = true
const batchRequestContractExact: Assert<
IsExact<
BatchUpdateUserLimitsRequest,
{
user_ids: number[]
all?: boolean
concurrency?: number
rpm_limit?: number
}
>
> = true
const batchResponseContractExact: Assert<
IsExact<BatchUpdateUserLimitsResponse, { affected: number }>
> = true
describe('admin users api auth identity binding', () => {
beforeEach(() => {
@@ -114,4 +131,20 @@ describe('admin users api auth identity binding', () => {
expect(requestContractExact).toBe(true)
expect(responseContractExact).toBe(true)
})
it('posts batch limit updates once with only the supplied limit fields', async () => {
const request: BatchUpdateUserLimitsRequest = {
user_ids: [4, 7],
all: false,
rpm_limit: 0,
}
post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse })
const result = await batchUpdateLimits(request)
expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request)
expect(result).toEqual({ affected: 2 })
expect(batchRequestContractExact).toBe(true)
expect(batchResponseContractExact).toBe(true)
})
})
+23
View File
@@ -44,6 +44,17 @@ export interface AdminBoundAuthIdentity {
channel?: AdminBoundAuthIdentityChannel | null
}
export interface BatchUpdateUserLimitsRequest {
user_ids: number[]
all?: boolean
concurrency?: number
rpm_limit?: number
}
export interface BatchUpdateUserLimitsResponse {
affected: number
}
/**
* List all users with pagination
* @param page - Page number (default: 1)
@@ -184,6 +195,17 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
return update(id, { concurrency })
}
/** Overwrite concurrency and/or RPM limits for multiple users in one request. */
export async function batchUpdateLimits(
request: BatchUpdateUserLimitsRequest
): Promise<BatchUpdateUserLimitsResponse> {
const { data } = await apiClient.post<BatchUpdateUserLimitsResponse>(
'/admin/users/batch-limits',
request
)
return data
}
/**
* Toggle user status
* @param id - User ID
@@ -385,6 +407,7 @@ export const usersAPI = {
delete: deleteUser,
updateBalance,
updateConcurrency,
batchUpdateLimits,
toggleStatus,
getUserApiKeys,
getUserUsageStats,
@@ -0,0 +1,214 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.bulkLimits.title')"
width="normal"
@close="emit('close')"
>
<form id="bulk-edit-user-limits-form" class="space-y-5" @submit.prevent="handleSubmit">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.users.bulkLimits.selectedCount', { count: selectedIds.length }) }}
</p>
<div class="divide-y divide-gray-200 border-y border-gray-200 dark:divide-dark-700 dark:border-dark-700">
<div class="space-y-3 py-4">
<div class="flex items-center justify-between gap-4">
<label for="bulk-concurrency" class="input-label mb-0">
{{ t('admin.users.columns.concurrency') }}
</label>
<Toggle
v-model="enableConcurrency"
:aria-label="t('admin.users.bulkLimits.enableConcurrency')"
data-test="enable-concurrency"
/>
</div>
<input
v-if="enableConcurrency"
id="bulk-concurrency"
v-model="concurrencyValue"
type="number"
min="0"
step="1"
class="input"
data-test="concurrency-input"
/>
</div>
<div class="space-y-3 py-4">
<div class="flex items-center justify-between gap-4">
<label for="bulk-rpm-limit" class="input-label mb-0">
{{ t('admin.users.form.rpmLimit') }}
</label>
<Toggle
v-model="enableRPMLimit"
:aria-label="t('admin.users.bulkLimits.enableRPMLimit')"
data-test="enable-rpm-limit"
/>
</div>
<div v-if="enableRPMLimit">
<input
id="bulk-rpm-limit"
v-model="rpmLimitValue"
type="number"
min="0"
step="1"
class="input"
data-test="rpm-limit-input"
/>
<p v-if="parsedRPMLimit === 0" class="input-hint">
{{ t('admin.users.bulkLimits.unlimited') }}
</p>
</div>
</div>
</div>
<p v-if="hasInvalidValue" class="text-sm text-red-600 dark:text-red-400">
{{ t('admin.users.bulkLimits.nonNegativeInteger') }}
</p>
<p v-if="selectionTooLarge" class="text-sm text-red-600 dark:text-red-400">
{{ t('admin.users.bulkLimits.selectionLimit', { max: MAX_BATCH_USER_IDS }) }}
</p>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="emit('close')">
{{ t('common.cancel') }}
</button>
<button
type="submit"
form="bulk-edit-user-limits-form"
class="btn btn-primary"
:disabled="!canSubmit"
data-test="submit"
>
{{ submitting ? t('admin.users.bulkLimits.applying') : t('admin.users.bulkLimits.apply') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { BatchUpdateUserLimitsRequest } from '@/api/admin/users'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Toggle from '@/components/common/Toggle.vue'
const props = defineProps<{
show: boolean
selectedIds: number[]
}>()
const emit = defineEmits<{
close: []
success: [affected: number]
}>()
const { t } = useI18n()
const appStore = useAppStore()
const enableConcurrency = ref(false)
const enableRPMLimit = ref(false)
const concurrencyValue = ref<string | number>('')
const rpmLimitValue = ref<string | number>('')
const submitting = ref(false)
const MAX_BATCH_USER_IDS = 500
const parseLimit = (value: string | number): number | null | undefined => {
const trimmed = String(value).trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isInteger(parsed) || parsed < 0) return null
return parsed
}
const parsedConcurrency = computed(() =>
enableConcurrency.value ? parseLimit(concurrencyValue.value) : undefined
)
const parsedRPMLimit = computed(() =>
enableRPMLimit.value ? parseLimit(rpmLimitValue.value) : undefined
)
const hasInvalidValue = computed(() =>
parsedConcurrency.value === null || parsedRPMLimit.value === null
)
const hasUpdate = computed(() =>
(parsedConcurrency.value !== undefined && parsedConcurrency.value !== null)
|| (parsedRPMLimit.value !== undefined && parsedRPMLimit.value !== null)
)
const selectionTooLarge = computed(() => props.selectedIds.length > MAX_BATCH_USER_IDS)
const canSubmit = computed(() =>
props.selectedIds.length > 0
&& !selectionTooLarge.value
&& hasUpdate.value
&& !hasInvalidValue.value
&& !submitting.value
)
const reset = () => {
enableConcurrency.value = false
enableRPMLimit.value = false
concurrencyValue.value = ''
rpmLimitValue.value = ''
submitting.value = false
}
watch(
() => props.show,
(show) => {
if (show) reset()
}
)
const handleSubmit = async () => {
if (!canSubmit.value) return
const request: BatchUpdateUserLimitsRequest = {
user_ids: [...props.selectedIds],
all: false
}
const fields: string[] = []
if (parsedConcurrency.value !== undefined && parsedConcurrency.value !== null) {
request.concurrency = parsedConcurrency.value
fields.push(
t('admin.users.bulkLimits.concurrencyValue', { value: parsedConcurrency.value })
)
}
if (parsedRPMLimit.value !== undefined && parsedRPMLimit.value !== null) {
request.rpm_limit = parsedRPMLimit.value
fields.push(
parsedRPMLimit.value === 0
? t('admin.users.bulkLimits.rpmUnlimitedValue')
: t('admin.users.bulkLimits.rpmValue', { value: parsedRPMLimit.value })
)
}
const confirmed = window.confirm(
t('admin.users.bulkLimits.confirm', {
count: props.selectedIds.length,
fields: fields.join(', ')
})
)
if (!confirmed) return
submitting.value = true
try {
const result = await adminAPI.users.batchUpdateLimits(request)
appStore.showSuccess(
t('admin.users.bulkLimits.success', { count: result.affected })
)
emit('success', result.affected)
emit('close')
} catch (error: any) {
appStore.showError(
error.response?.data?.message
|| error.response?.data?.detail
|| t('admin.users.bulkLimits.failed')
)
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import BulkEditUserModal from '../BulkEditUserModal.vue'
const { batchUpdateLimits, showSuccess, showError } = vi.hoisted(() => ({
batchUpdateLimits: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
users: {
batchUpdateLimits
}
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showSuccess,
showError
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
})
}))
const mountModal = () => mount(BulkEditUserModal, {
props: {
show: true,
selectedIds: [4, 7]
},
global: {
stubs: {
BaseDialog: {
props: ['show', 'title'],
emits: ['close'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
}
}
}
})
describe('BulkEditUserModal', () => {
beforeEach(() => {
batchUpdateLimits.mockReset()
showSuccess.mockReset()
showError.mockReset()
batchUpdateLimits.mockResolvedValue({ affected: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('disables submission until at least one enabled field has a value', async () => {
const wrapper = mountModal()
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="concurrency-input"]').setValue('5')
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined()
})
it('disables submission when more than 500 users are selected', async () => {
const wrapper = mountModal()
await wrapper.setProps({ selectedIds: Array.from({ length: 501 }, (_, index) => index + 1) })
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
await wrapper.get('[data-test="concurrency-input"]').setValue('5')
expect(wrapper.text()).toContain('admin.users.bulkLimits.selectionLimit')
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
})
it('submits only the enabled RPM field and preserves zero as unlimited', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountModal()
await wrapper.get('[data-test="enable-rpm-limit"]').trigger('click')
await wrapper.get('[data-test="rpm-limit-input"]').setValue('0')
expect(wrapper.text()).toContain('admin.users.bulkLimits.unlimited')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(batchUpdateLimits).toHaveBeenCalledWith({
user_ids: [4, 7],
all: false,
rpm_limit: 0
})
expect(confirm).toHaveBeenCalledWith(
expect.stringContaining('admin.users.bulkLimits.rpmUnlimitedValue')
)
expect(wrapper.emitted('success')).toEqual([[2]])
})
it('omits disabled fields from the request', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountModal()
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
await wrapper.get('[data-test="concurrency-input"]').setValue('9')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(batchUpdateLimits).toHaveBeenCalledWith({
user_ids: [4, 7],
all: false,
concurrency: 9
})
})
it('does not call the API when overwrite confirmation is cancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
const wrapper = mountModal()
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
await wrapper.get('[data-test="concurrency-input"]').setValue('9')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(batchUpdateLimits).not.toHaveBeenCalled()
})
})
+121 -6
View File
@@ -32,14 +32,41 @@
</template>
<template v-else>
<div v-if="selectable" class="flex items-center justify-end gap-2 px-1">
<label class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-300">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
:checked="allVisibleSelected"
:indeterminate="someVisibleSelected"
data-test="select-all-mobile"
@change="toggleAllVisible(($event.target as HTMLInputElement).checked)"
/>
<span>{{ t('common.selectAll') }}</span>
</label>
</div>
<div
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
:class="{ 'cursor-pointer': clickableRows }"
:class="{
'cursor-pointer': clickableRows,
'border-primary-300 bg-primary-50/40 dark:border-primary-700 dark:bg-primary-900/10': selectable && isRowSelected(row, index)
}"
@click="clickableRows && emit('rowClick', row)"
>
<div class="space-y-3">
<div v-if="selectable" class="flex justify-end">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
:checked="isRowSelected(row, index)"
:aria-label="getRowSelectionLabel(row, index)"
data-test="select-row"
@click.stop
@change="toggleRowSelection(row, index, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div
v-for="column in dataColumns"
:key="column.key"
@@ -74,6 +101,21 @@
<table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
<thead class="table-header bg-gray-50 dark:bg-dark-800">
<tr>
<th
v-if="selectable"
scope="col"
class="sticky-header-cell w-11 min-w-11 px-3 py-3 text-center"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
:checked="allVisibleSelected"
:indeterminate="someVisibleSelected"
:aria-label="t('common.selectAll')"
data-test="select-all"
@change="toggleAllVisible(($event.target as HTMLInputElement).checked)"
/>
</th>
<th
v-for="(column, index) in columns"
:key="column.key"
@@ -126,6 +168,9 @@
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i">
<td v-if="selectable" class="w-11 min-w-11 px-3 py-4">
<div class="mx-auto h-4 w-4 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</td>
<td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
<div class="animate-pulse">
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
@@ -136,7 +181,7 @@
<!-- Empty state -->
<tr v-else-if="!data || data.length === 0">
<td
:colspan="columns.length"
:colspan="tableColumnCount"
:class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
>
<slot name="empty">
@@ -157,7 +202,7 @@
<!-- Data rows: windowed when large, fully rendered when small (shared row/cell template) -->
<template v-else>
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
<td :colspan="columns.length"
<td :colspan="tableColumnCount"
:style="{ height: virtualPaddingTop + 'px', padding: 0, border: 'none' }">
</td>
</tr>
@@ -168,9 +213,23 @@
:data-index="item.index"
:ref="item.measure ? measureElement : undefined"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
:class="{ 'cursor-pointer': clickableRows }"
:class="{
'cursor-pointer': clickableRows,
'bg-primary-50/40 dark:bg-primary-900/10': selectable && isRowSelected(item.row, item.index)
}"
@click="clickableRows && emit('rowClick', item.row)"
>
<td v-if="selectable" class="w-11 min-w-11 px-3 py-4 text-center">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
:checked="isRowSelected(item.row, item.index)"
:aria-label="getRowSelectionLabel(item.row, item.index)"
data-test="select-row"
@click.stop
@change="toggleRowSelection(item.row, item.index, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td
v-for="(column, colIndex) in columns"
:key="column.key"
@@ -192,7 +251,7 @@
</td>
</tr>
<tr v-if="virtualPaddingBottom > 0" aria-hidden="true">
<td :colspan="columns.length"
<td :colspan="tableColumnCount"
:style="{ height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }">
</td>
</tr>
@@ -219,6 +278,8 @@ const isDesktopViewport = ref(
const emit = defineEmits<{
sort: [key: string, order: 'asc' | 'desc']
rowClick: [row: any]
'update:selectedKeys': [keys: Array<string | number>]
selectionChange: [keys: Array<string | number>]
}>()
//
@@ -403,6 +464,12 @@ interface Props {
* estimated-vs-actual row heights when rows have variable height.
*/
virtualizeThreshold?: number
/** Enable controlled row selection. Stable row keys are strongly recommended. */
selectable?: boolean
/** Selected row keys. Keys outside the current data page are preserved. */
selectedKeys?: Array<string | number>
/** Accessible label for a row selection checkbox. */
selectionLabel?: string | ((row: any) => string)
}
const props = withDefaults(defineProps<Props>(), {
@@ -411,7 +478,9 @@ const props = withDefaults(defineProps<Props>(), {
stickyActionsColumn: true,
expandableActions: true,
defaultSortOrder: 'asc',
serverSideSort: false
serverSideSort: false,
selectable: false,
selectedKeys: () => []
})
const sortKey = ref<string>('')
@@ -634,6 +703,52 @@ const sortedData = computed(() => {
.map(item => item.row)
})
const tableColumnCount = computed(() => props.columns.length + (props.selectable ? 1 : 0))
const selectedKeySet = computed(() => new Set(props.selectedKeys))
const visibleRowKeys = computed(() =>
(sortedData.value ?? []).map((row, index) => resolveRowKey(row, index))
)
const allVisibleSelected = computed(() =>
visibleRowKeys.value.length > 0
&& visibleRowKeys.value.every((key) => selectedKeySet.value.has(key))
)
const someVisibleSelected = computed(() => {
if (allVisibleSelected.value) return false
return visibleRowKeys.value.some((key) => selectedKeySet.value.has(key))
})
const emitSelection = (next: Set<string | number>) => {
const keys = Array.from(next)
emit('update:selectedKeys', keys)
emit('selectionChange', keys)
}
const isRowSelected = (row: any, index: number) =>
selectedKeySet.value.has(resolveRowKey(row, index))
const getRowSelectionLabel = (row: any, index: number) => {
if (typeof props.selectionLabel === 'function') return props.selectionLabel(row)
if (props.selectionLabel) return props.selectionLabel
return `${t('common.selectOption')} ${resolveRowKey(row, index)}`
}
const toggleRowSelection = (row: any, index: number, checked: boolean) => {
const next = new Set(props.selectedKeys)
const key = resolveRowKey(row, index)
if (checked) next.add(key)
else next.delete(key)
emitSelection(next)
}
const toggleAllVisible = (checked: boolean) => {
const next = new Set(props.selectedKeys)
for (const key of visibleRowKeys.value) {
if (checked) next.add(key)
else next.delete(key)
}
emitSelection(next)
}
// --- Virtual scrolling ---
// :,
// //,
@@ -25,6 +25,22 @@ const stubDesktopMatchMedia = () => {
})
}
const stubMobileMatchMedia = () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
}
describe('DataTable', () => {
beforeEach(() => {
stubDesktopMatchMedia()
@@ -259,4 +275,53 @@ describe('DataTable', () => {
expect(measureSpy).not.toHaveBeenCalled()
expect(sizeCache.size).toBe(100)
})
it('emits controlled current-page selection while preserving off-page keys', async () => {
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data: [
{ id: 1, name: 'One' },
{ id: 2, name: 'Two' }
],
rowKey: 'id',
selectable: true,
selectedKeys: [99]
}
})
await wrapper.get('[data-test="select-all"]').setValue(true)
const selectedAll = wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]
expect(selectedAll).toEqual([99, 1, 2])
await wrapper.setProps({ selectedKeys: selectedAll as number[] })
const rowCheckboxes = wrapper.findAll<HTMLInputElement>('[data-test="select-row"]')
expect(rowCheckboxes.every((checkbox) => checkbox.element.checked)).toBe(true)
await rowCheckboxes[0].setValue(false)
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([99, 2])
expect(wrapper.emitted('selectionChange')?.at(-1)?.[0]).toEqual([99, 2])
})
it('offers current-page select all in the mobile card layout', async () => {
stubMobileMatchMedia()
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data: [
{ id: 1, name: 'One' },
{ id: 2, name: 'Two' }
],
rowKey: 'id',
selectable: true,
selectedKeys: [99]
}
})
await wrapper.get('[data-test="select-all-mobile"]').setValue(true)
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([99, 1, 2])
})
})
@@ -418,6 +418,25 @@ export default {
title: 'User Management',
description: 'Manage users and their permissions',
createUser: 'Create User',
bulkLimits: {
action: 'Set limits ({count})',
title: 'Set user limits',
selectedCount: '{count} users selected',
selectionLimit: 'Select no more than {max} users at a time.',
selectUser: 'Select {email}',
enableConcurrency: 'Update concurrency',
enableRPMLimit: 'Update RPM limit',
unlimited: 'Unlimited',
nonNegativeInteger: 'Enter a non-negative whole number.',
apply: 'Apply limits',
applying: 'Applying...',
concurrencyValue: 'Concurrency: {value}',
rpmValue: 'RPM: {value}',
rpmUnlimitedValue: 'RPM: Unlimited',
confirm: 'Overwrite limits for {count} users?\n{fields}',
success: 'Updated limits for {count} users',
failed: 'Failed to update user limits'
},
editUser: 'Edit User',
deleteUser: 'Delete User',
deleteConfirmMessage: "Are you sure you want to delete user '{email}'? This action cannot be undone.",
@@ -418,6 +418,25 @@ export default {
title: '用户管理',
description: '管理用户账户和权限',
createUser: '创建用户',
bulkLimits: {
action: '批量设置限制({count}',
title: '批量设置用户限制',
selectedCount: '已选择 {count} 个用户',
selectionLimit: '一次最多选择 {max} 个用户。',
selectUser: '选择 {email}',
enableConcurrency: '修改并发数',
enableRPMLimit: '修改 RPM 限制',
unlimited: '不限制',
nonNegativeInteger: '请输入非负整数。',
apply: '应用限制',
applying: '应用中...',
concurrencyValue: '并发数:{value}',
rpmValue: 'RPM{value}',
rpmUnlimitedValue: 'RPM:不限制',
confirm: '确定覆盖 {count} 个用户的限制吗?\n{fields}',
success: '已更新 {count} 个用户的限制',
failed: '批量更新用户限制失败'
},
editUser: '编辑用户',
deleteUser: '删除用户',
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
+1
View File
@@ -1734,6 +1734,7 @@ export interface UpdateUserRequest {
role?: 'admin' | 'user'
balance?: number
concurrency?: number
rpm_limit?: number
status?: 'active' | 'disabled'
allowed_groups?: number[] | null
// 用户专属分组倍率配置 (group_id -> rate_multiplier | null)
+46
View File
@@ -242,6 +242,16 @@
</button>
</div>
<button
v-if="selectedCount > 0"
class="btn btn-secondary flex-1 md:flex-initial"
data-test="bulk-edit-limits"
@click="showBulkEditModal = true"
>
<Icon name="users" size="md" class="mr-2" />
{{ t('admin.users.bulkLimits.action', { count: selectedCount }) }}
</button>
<!-- Create User Button (full width on mobile, auto width on desktop) -->
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
<Icon name="plus" size="md" class="mr-2" />
@@ -257,12 +267,17 @@
:columns="columns"
:data="sortedUsers"
:loading="loading"
row-key="id"
selectable
:selected-keys="selectedIds"
:selection-label="getUserSelectionLabel"
:actions-count="7"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
:sort-storage-key="USER_SORT_STORAGE_KEY"
@sort="handleSort"
@update:selected-keys="handleSelectedKeysUpdate"
>
<template #cell-email="{ value }">
<div class="flex items-center gap-2">
@@ -735,6 +750,12 @@
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.users.deleteUser')" :message="t('admin.users.deleteConfirm', { email: deletingUser?.email })" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<UserCreateModal :show="showCreateModal" @close="showCreateModal = false" @success="loadUsers" />
<UserEditModal :show="showEditModal" :user="editingUser" @close="closeEditModal" @success="loadUsers" />
<BulkEditUserModal
:show="showBulkEditModal"
:selected-ids="selectedIds"
@close="showBulkEditModal = false"
@success="handleBulkLimitsSuccess"
/>
<UserPlatformQuotaModal
:show="showPlatformQuotaModal"
:user="platformQuotaUser"
@@ -755,6 +776,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useTableSelection } from '@/composables/useTableSelection'
import { formatDateTime } from '@/utils/format'
import Icon from '@/components/icons/Icon.vue'
@@ -781,6 +803,7 @@ import PlatformCostCell from '@/components/user/PlatformCostCell.vue'
import UserPlatformQuotaCell from '@/components/user/UserPlatformQuotaCell.vue'
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import BulkEditUserModal from '@/components/admin/user/BulkEditUserModal.vue'
import UserPlatformQuotaModal from '@/components/admin/user/UserPlatformQuotaModal.vue'
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
@@ -1268,6 +1291,23 @@ const sortedUsers = computed(() => {
.map((x) => x.row)
})
const {
selectedIds,
selectedCount,
setSelectedIds,
clear: clearSelection
} = useTableSelection<AdminUser>({
rows: sortedUsers,
getId: (user) => user.id
})
const handleSelectedKeysUpdate = (keys: Array<string | number>) => {
setSelectedIds(keys.filter((key): key is number => typeof key === 'number'))
}
const getUserSelectionLabel = (user: AdminUser) =>
t('admin.users.bulkLimits.selectUser', { email: user.email })
// User attribute definitions and values
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
@@ -1280,6 +1320,7 @@ const pagination = reactive({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showBulkEditModal = ref(false)
const showDeleteDialog = ref(false)
const showApiKeysModal = ref(false)
const showAttributesModal = ref(false)
@@ -1584,6 +1625,11 @@ const loadUsers = async () => {
}
}
const handleBulkLimitsSuccess = async () => {
clearSelection()
await loadUsers()
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
@@ -77,13 +77,22 @@ const createAdminUser = (overrides: Partial<AdminUser> = {}): AdminUser => ({
})
const DataTableStub = {
props: ['columns', 'data'],
emits: ['sort'],
props: ['columns', 'data', 'selectedKeys'],
emits: ['sort', 'update:selectedKeys'],
template: `
<div>
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
<div data-test="row-order">{{ data.map(row => row.email).join(',') }}</div>
<div data-test="selected-keys">{{ (selectedKeys || []).join(',') }}</div>
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
<button
v-for="row in data"
:key="'select-' + row.id"
:data-test="'select-' + row.id"
@click="$emit('update:selectedKeys', Array.from(new Set([...(selectedKeys || []), row.id])))"
>
select
</button>
<template v-for="col in columns" :key="col.key">
<slot :name="'header-' + col.key" :column="col" />
</template>
@@ -94,6 +103,22 @@ const DataTableStub = {
`
}
const PaginationStub = {
emits: ['update:page'],
template: '<button data-test="next-page" @click="$emit(\'update:page\', 2)">next</button>'
}
const BulkEditUserModalStub = {
props: ['show', 'selectedIds'],
emits: ['close', 'success'],
template: `
<div v-if="show" data-test="bulk-modal">
<span data-test="bulk-modal-ids">{{ selectedIds.join(',') }}</span>
<button data-test="bulk-success" @click="$emit('success', selectedIds.length)">success</button>
</div>
`
}
describe('admin UsersView', () => {
beforeEach(() => {
vi.useRealTimers()
@@ -140,6 +165,8 @@ describe('admin UsersView', () => {
UserConcurrencyCell: true,
UserCreateModal: true,
UserEditModal: true,
BulkEditUserModal: BulkEditUserModalStub,
UserPlatformQuotaModal: true,
UserApiKeysModal: true,
UserAllowedGroupsModal: true,
UserBalanceModal: true,
@@ -224,6 +251,8 @@ describe('admin UsersView', () => {
UserConcurrencyCell: true,
UserCreateModal: true,
UserEditModal: true,
BulkEditUserModal: BulkEditUserModalStub,
UserPlatformQuotaModal: true,
UserApiKeysModal: true,
UserAllowedGroupsModal: true,
UserBalanceModal: true,
@@ -264,4 +293,80 @@ describe('admin UsersView', () => {
expect.any(Object)
)
})
it('keeps selected user IDs across pages and clears them after a successful bulk update', async () => {
let refreshed = false
listUsers.mockImplementation(async (page: number) => {
const user = page === 2
? createAdminUser({
id: 43,
email: refreshed ? 'refreshed-page-two@example.com' : 'page-two@example.com'
})
: createAdminUser({ id: 42, email: 'page-one@example.com' })
return {
items: [user],
total: 2,
page,
page_size: 20,
pages: 2
}
})
const wrapper = mount(UsersView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
DataTable: DataTableStub,
Pagination: PaginationStub,
ConfirmDialog: true,
EmptyState: true,
GroupBadge: true,
Select: true,
UserAttributesConfigModal: true,
UserConcurrencyCell: true,
UserCreateModal: true,
UserEditModal: true,
BulkEditUserModal: BulkEditUserModalStub,
UserPlatformQuotaModal: true,
UserApiKeysModal: true,
UserAllowedGroupsModal: true,
UserBalanceModal: true,
UserBalanceHistoryModal: true,
GroupReplaceModal: true,
Icon: true,
Teleport: true
}
}
})
await flushPromises()
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(false)
await wrapper.get('[data-test="select-42"]').trigger('click')
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(true)
await wrapper.get('[data-test="next-page"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
await wrapper.get('[data-test="select-43"]').trigger('click')
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42,43')
await wrapper.get('[data-test="bulk-edit-limits"]').trigger('click')
expect(wrapper.get('[data-test="bulk-modal-ids"]').text()).toBe('42,43')
const callsBeforeSuccess = listUsers.mock.calls.length
refreshed = true
await wrapper.get('[data-test="bulk-success"]').trigger('click')
await flushPromises()
expect(listUsers.mock.calls.length).toBeGreaterThan(callsBeforeSuccess)
expect(wrapper.get('[data-test="row-order"]').text()).toBe('refreshed-page-two@example.com')
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(false)
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('')
})
})