mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-31 01:13:06 +08:00
Merge pull request #4425 from AdrianZhaoDev/agent/admin-users-batch-limits
feat(admin): batch update user concurrency and RPM
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}' 吗?此操作无法撤销。",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user