feat(admin): 用户管理新增分组列、分组筛选与专属分组一键替换

- 新增分组列:展示用户的专属/公开分组,支持 hover 查看详情
- 新增分组筛选:下拉选择或模糊搜索分组名过滤用户
- 专属分组替换:点击专属分组弹出操作菜单,选择目标分组后
  自动授予新分组权限、迁移绑定的 Key、移除旧分组权限
- 后端新增 POST /admin/users/:id/replace-group 端点,事务内
  完成分组替换并失效认证缓存
This commit is contained in:
QTom
2026-03-18 23:28:11 +08:00
parent 0236b97d49
commit ba7d2aecbb
29 changed files with 594 additions and 9 deletions

View File

@@ -445,5 +445,9 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
return "" return ""
} }
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
}
// Ensure stub implements interface. // Ensure stub implements interface.
var _ service.AdminService = (*stubAdminService)(nil) var _ service.AdminService = (*stubAdminService)(nil)

View File

@@ -75,6 +75,7 @@ type UpdateBalanceRequest struct {
// - role: filter by user role // - role: filter by user role
// - search: search in email, username // - search: search in email, username
// - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company // - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company
// - group_name: fuzzy filter by allowed group name
func (h *UserHandler) List(c *gin.Context) { func (h *UserHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
@@ -89,6 +90,7 @@ func (h *UserHandler) List(c *gin.Context) {
Status: c.Query("status"), Status: c.Query("status"),
Role: c.Query("role"), Role: c.Query("role"),
Search: search, Search: search,
GroupName: strings.TrimSpace(c.Query("group_name")),
Attributes: parseAttributeFilters(c), Attributes: parseAttributeFilters(c),
} }
if raw, ok := c.GetQuery("include_subscriptions"); ok { if raw, ok := c.GetQuery("include_subscriptions"); ok {
@@ -366,3 +368,35 @@ func (h *UserHandler) GetBalanceHistory(c *gin.Context) {
"total_recharged": totalRecharged, "total_recharged": totalRecharged,
}) })
} }
// ReplaceGroupRequest represents the request to replace a user's exclusive group
type ReplaceGroupRequest struct {
OldGroupID int64 `json:"old_group_id" binding:"required,gt=0"`
NewGroupID int64 `json:"new_group_id" binding:"required,gt=0"`
}
// ReplaceGroup handles replacing a user's exclusive group
// POST /api/v1/admin/users/:id/replace-group
func (h *UserHandler) ReplaceGroup(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid user ID")
return
}
var req ReplaceGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.adminService.ReplaceUserGroup(c.Request.Context(), userID, req.OldGroupID, req.NewGroupID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{
"migrated_keys": result.MigratedKeys,
})
}

View File

@@ -942,6 +942,9 @@ func (r *stubUserRepoForHandler) ExistsByEmail(context.Context, string) (bool, e
func (r *stubUserRepoForHandler) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (r *stubUserRepoForHandler) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }
func (r *stubUserRepoForHandler) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
return nil
}
func (r *stubUserRepoForHandler) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForHandler) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (r *stubUserRepoForHandler) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForHandler) EnableTotp(context.Context, int64) error { return nil }
func (r *stubUserRepoForHandler) DisableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForHandler) DisableTotp(context.Context, int64) error { return nil }
@@ -1017,6 +1020,20 @@ func (r *stubAPIKeyRepoForHandler) SearchAPIKeys(context.Context, int64, string,
func (r *stubAPIKeyRepoForHandler) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { func (r *stubAPIKeyRepoForHandler) ClearGroupIDByGroupID(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }
func (r *stubAPIKeyRepoForHandler) UpdateGroupIDByUserAndGroup(_ context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
var updated int64
for id, key := range r.keys {
if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID {
continue
}
clone := *key
gid := newGroupID
clone.GroupID = &gid
r.keys[id] = &clone
updated++
}
return updated, nil
}
func (r *stubAPIKeyRepoForHandler) CountByGroupID(context.Context, int64) (int64, error) { func (r *stubAPIKeyRepoForHandler) CountByGroupID(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }

View File

@@ -409,6 +409,16 @@ func (r *apiKeyRepository) ClearGroupIDByGroupID(ctx context.Context, groupID in
return int64(n), err return int64(n), err
} }
// UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID
func (r *apiKeyRepository) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
client := clientFromContext(ctx, r.client)
n, err := client.APIKey.Update().
Where(apikey.UserIDEQ(userID), apikey.GroupIDEQ(oldGroupID), apikey.DeletedAtIsNil()).
SetGroupID(newGroupID).
Save(ctx)
return int64(n), err
}
// CountByGroupID 获取分组的 API Key 数量 // CountByGroupID 获取分组的 API Key 数量
func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
count, err := r.activeQuery().Where(apikey.GroupIDEQ(groupID)).Count(ctx) count, err := r.activeQuery().Where(apikey.GroupIDEQ(groupID)).Count(ctx)

View File

@@ -11,6 +11,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/apikey"
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
dbuser "github.com/Wei-Shaw/sub2api/ent/user" dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup" "github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -200,6 +201,12 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
) )
} }
if filters.GroupName != "" {
q = q.Where(dbuser.HasAllowedGroupsWith(
dbgroup.NameContainsFold(filters.GroupName),
))
}
// If attribute filters are specified, we need to filter by user IDs first // If attribute filters are specified, we need to filter by user IDs first
var allowedUserIDs []int64 var allowedUserIDs []int64
if len(filters.Attributes) > 0 { if len(filters.Attributes) > 0 {
@@ -453,6 +460,15 @@ func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, group
return int64(affected), nil return int64(affected), nil
} }
// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
func (r *userRepository) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
client := clientFromContext(ctx, r.client)
_, err := client.UserAllowedGroup.Delete().
Where(userallowedgroup.UserIDEQ(userID), userallowedgroup.GroupIDEQ(groupID)).
Exec(ctx)
return err
}
func (r *userRepository) GetFirstAdmin(ctx context.Context) (*service.User, error) { func (r *userRepository) GetFirstAdmin(ctx context.Context) (*service.User, error) {
m, err := r.client.User.Query(). m, err := r.client.User.Query().
Where( Where(

View File

@@ -807,6 +807,10 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }
func (r *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
return errors.New("not implemented")
}
func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
return errors.New("not implemented") return errors.New("not implemented")
} }
@@ -1509,6 +1513,22 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }
func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
var updated int64
for id, key := range r.byID {
if key.UserID != userID || key.GroupID == nil || *key.GroupID != oldGroupID {
continue
}
clone := *key
gid := newGroupID
clone.GroupID = &gid
r.byID[id] = &clone
r.byKey[clone.Key] = &clone
updated++
}
return updated, nil
}
func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }

View File

@@ -181,6 +181,10 @@ func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
panic("unexpected RemoveGroupFromAllowedGroups call") panic("unexpected RemoveGroupFromAllowedGroups call")
} }
func (s *stubUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
panic("unexpected RemoveGroupFromUserAllowedGroups call")
}
func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
panic("unexpected AddGroupToAllowedGroups call") panic("unexpected AddGroupToAllowedGroups call")
} }

View File

@@ -104,6 +104,9 @@ func (f fakeAPIKeyRepo) ResetRateLimitWindows(ctx context.Context, id int64) err
func (f fakeAPIKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*service.APIKeyRateLimitData, error) { func (f fakeAPIKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*service.APIKeyRateLimitData, error) {
return &service.APIKeyRateLimitData{}, nil return &service.APIKeyRateLimitData{}, nil
} }
func (f fakeAPIKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
return 0, errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error { func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error {
return errors.New("not implemented") return errors.New("not implemented")

View File

@@ -565,6 +565,10 @@ func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int6
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }
func (r *stubApiKeyRepo) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
return 0, errors.New("not implemented")
}
func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }

View File

@@ -215,6 +215,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys) users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
users.GET("/:id/usage", h.Admin.User.GetUserUsage) users.GET("/:id/usage", h.Admin.User.GetUserUsage)
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
// User attribute values // User attribute values
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)

View File

@@ -50,6 +50,9 @@ type AdminService interface {
// API Key management (admin) // API Key management (admin)
AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error)
// ReplaceUserGroup 替换用户的专属分组:授予新分组权限、迁移 Key、移除旧分组权限
ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error)
// Account management // Account management
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error)
GetAccount(ctx context.Context, id int64) (*Account, error) GetAccount(ctx context.Context, id int64) (*Account, error)
@@ -270,6 +273,11 @@ type AdminUpdateAPIKeyGroupIDResult struct {
GrantedGroupName string // the group name that was auto-granted GrantedGroupName string // the group name that was auto-granted
} }
// ReplaceUserGroupResult 分组替换操作的结果
type ReplaceUserGroupResult struct {
MigratedKeys int64 // 迁移的 Key 数量
}
// BulkUpdateAccountsResult is the aggregated response for bulk updates. // BulkUpdateAccountsResult is the aggregated response for bulk updates.
type BulkUpdateAccountsResult struct { type BulkUpdateAccountsResult struct {
Success int `json:"success"` Success int `json:"success"`
@@ -1377,6 +1385,71 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
return result, nil return result, nil
} }
// ReplaceUserGroup 替换用户的专属分组
func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) {
if oldGroupID == newGroupID {
return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different")
}
// 验证新分组存在且为活跃的专属标准分组
newGroup, err := s.groupRepo.GetByID(ctx, newGroupID)
if err != nil {
return nil, err
}
if newGroup.Status != StatusActive {
return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active")
}
if !newGroup.IsExclusive {
return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive")
}
if newGroup.IsSubscriptionType() {
return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement")
}
// 事务保证原子性
if s.entClient == nil {
return nil, fmt.Errorf("entClient is nil, cannot perform group replacement")
}
tx, err := s.entClient.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
opCtx := dbent.NewTxContext(ctx, tx)
// 1. 授予新分组权限
if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil {
return nil, fmt.Errorf("add new group to allowed groups: %w", err)
}
// 2. 迁移绑定旧分组的 Key 到新分组
migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID)
if err != nil {
return nil, fmt.Errorf("migrate api keys: %w", err)
}
// 3. 移除旧分组权限
if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil {
return nil, fmt.Errorf("remove old group from allowed groups: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
// 失效该用户所有 Key 的认证缓存
if s.authCacheInvalidator != nil {
keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID)
if keyErr == nil {
for _, k := range keys {
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k)
}
}
}
return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil
}
// Account management implementations // Account management implementations
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) { func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize}

View File

@@ -65,6 +65,9 @@ func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (boo
func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
panic("unexpected") panic("unexpected")
} }
func (s *userRepoStubForGroupUpdate) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error { func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error {
panic("unexpected") panic("unexpected")
} }
@@ -128,6 +131,9 @@ func (s *apiKeyRepoStubForGroupUpdate) SearchAPIKeys(context.Context, int64, str
func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, int64) (int64, error) {
panic("unexpected") panic("unexpected")
} }
func (s *apiKeyRepoStubForGroupUpdate) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) {
panic("unexpected")
}
func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) { func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) {
panic("unexpected") panic("unexpected")
} }

View File

@@ -93,6 +93,10 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
panic("unexpected RemoveGroupFromAllowedGroups call") panic("unexpected RemoveGroupFromAllowedGroups call")
} }
func (s *userRepoStub) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
panic("unexpected RemoveGroupFromUserAllowedGroups call")
}
func (s *userRepoStub) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { func (s *userRepoStub) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
panic("unexpected AddGroupToAllowedGroups call") panic("unexpected AddGroupToAllowedGroups call")
} }

View File

@@ -63,6 +63,8 @@ type APIKeyRepository interface {
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error)
SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error)
ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error)
// UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID
UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error)
CountByGroupID(ctx context.Context, groupID int64) (int64, error) CountByGroupID(ctx context.Context, groupID int64) (int64, error)
ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error)
ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error)

View File

@@ -80,6 +80,9 @@ func (s *authRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keyword
func (s *authRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { func (s *authRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected ClearGroupIDByGroupID call") panic("unexpected ClearGroupIDByGroupID call")
} }
func (s *authRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
panic("unexpected UpdateGroupIDByUserAndGroup call")
}
func (s *authRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { func (s *authRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected CountByGroupID call") panic("unexpected CountByGroupID call")

View File

@@ -108,6 +108,9 @@ func (s *apiKeyRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keywor
func (s *apiKeyRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { func (s *apiKeyRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected ClearGroupIDByGroupID call") panic("unexpected ClearGroupIDByGroupID call")
} }
func (s *apiKeyRepoStub) UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error) {
panic("unexpected UpdateGroupIDByUserAndGroup call")
}
func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
panic("unexpected CountByGroupID call") panic("unexpected CountByGroupID call")

View File

@@ -122,6 +122,9 @@ func (s *quotaBaseAPIKeyRepoStub) SearchAPIKeys(context.Context, int64, string,
func (s *quotaBaseAPIKeyRepoStub) ClearGroupIDByGroupID(context.Context, int64) (int64, error) { func (s *quotaBaseAPIKeyRepoStub) ClearGroupIDByGroupID(context.Context, int64) (int64, error) {
panic("unexpected ClearGroupIDByGroupID call") panic("unexpected ClearGroupIDByGroupID call")
} }
func (s *quotaBaseAPIKeyRepoStub) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) {
panic("unexpected UpdateGroupIDByUserAndGroup call")
}
func (s *quotaBaseAPIKeyRepoStub) CountByGroupID(context.Context, int64) (int64, error) { func (s *quotaBaseAPIKeyRepoStub) CountByGroupID(context.Context, int64) (int64, error) {
panic("unexpected CountByGroupID call") panic("unexpected CountByGroupID call")
} }

View File

@@ -162,6 +162,9 @@ func (r *stubUserRepoForQuota) ExistsByEmail(context.Context, string) (bool, err
func (r *stubUserRepoForQuota) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (r *stubUserRepoForQuota) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }
func (r *stubUserRepoForQuota) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
return nil
}
func (r *stubUserRepoForQuota) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForQuota) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (r *stubUserRepoForQuota) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForQuota) EnableTotp(context.Context, int64) error { return nil }
func (r *stubUserRepoForQuota) DisableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForQuota) DisableTotp(context.Context, int64) error { return nil }

View File

@@ -21,6 +21,7 @@ type UserListFilters struct {
Status string // User status filter Status string // User status filter
Role string // User role filter Role string // User role filter
Search string // Search in email, username Search string // Search in email, username
GroupName string // Filter by allowed group name (fuzzy match)
Attributes map[int64]string // Custom attribute filters: attributeID -> value Attributes map[int64]string // Custom attribute filters: attributeID -> value
// IncludeSubscriptions controls whether ListWithFilters should load active subscriptions. // IncludeSubscriptions controls whether ListWithFilters should load active subscriptions.
// For large datasets this can be expensive; admin list pages should enable it on demand. // For large datasets this can be expensive; admin list pages should enable it on demand.
@@ -46,6 +47,8 @@ type UserRepository interface {
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups幂等冲突忽略 // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups幂等冲突忽略
AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error
// TOTP 双因素认证 // TOTP 双因素认证
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error

View File

@@ -46,7 +46,10 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int
return 0, nil return 0, nil
} }
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
return nil
}
func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil } func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil } func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }

View File

@@ -21,6 +21,7 @@ export async function list(
status?: 'active' | 'disabled' status?: 'active' | 'disabled'
role?: 'admin' | 'user' role?: 'admin' | 'user'
search?: string search?: string
group_name?: string // fuzzy filter by allowed group name
attributes?: Record<number, string> // attributeId -> value attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean include_subscriptions?: boolean
}, },
@@ -35,6 +36,7 @@ export async function list(
status: filters?.status, status: filters?.status,
role: filters?.role, role: filters?.role,
search: filters?.search, search: filters?.search,
group_name: filters?.group_name,
include_subscriptions: filters?.include_subscriptions include_subscriptions: filters?.include_subscriptions
} }
@@ -223,6 +225,25 @@ export async function getUserBalanceHistory(
return data return data
} }
/**
* Replace user's exclusive group
* @param userId - User ID
* @param oldGroupId - Current group ID to replace
* @param newGroupId - New group ID to replace with
* @returns Number of migrated keys
*/
export async function replaceGroup(
userId: number,
oldGroupId: number,
newGroupId: number
): Promise<{ migrated_keys: number }> {
const { data } = await apiClient.post<{ migrated_keys: number }>(
`/admin/users/${userId}/replace-group`,
{ old_group_id: oldGroupId, new_group_id: newGroupId }
)
return data
}
export const usersAPI = { export const usersAPI = {
list, list,
getById, getById,
@@ -234,7 +255,8 @@ export const usersAPI = {
toggleStatus, toggleStatus,
getUserApiKeys, getUserApiKeys,
getUserUsageStats, getUserUsageStats,
getUserBalanceHistory getUserBalanceHistory,
replaceGroup
} }
export default usersAPI export default usersAPI

View File

@@ -0,0 +1,131 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.replaceGroupTitle')" width="narrow" @close="$emit('close')">
<div v-if="oldGroup" class="space-y-4">
<!-- 提示信息 -->
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.users.replaceGroupHint', { old: oldGroup.name }) }}
</p>
<!-- 当前分组 -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<div class="flex items-center gap-2">
<Icon name="shield" size="sm" class="text-purple-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ oldGroup.name }}</span>
<Icon name="arrowRight" size="sm" class="ml-auto text-gray-400" />
<span v-if="selectedGroupId" class="font-medium text-primary-600 dark:text-primary-400">
{{ availableGroups.find(g => g.id === selectedGroupId)?.name }}
</span>
<span v-else class="text-sm text-gray-400">?</span>
</div>
</div>
<!-- 可选分组列表 -->
<div v-if="availableGroups.length > 0" class="max-h-64 space-y-2 overflow-y-auto">
<label
v-for="group in availableGroups"
:key="group.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border-2 p-3 transition-all"
:class="selectedGroupId === group.id
? 'border-primary-400 bg-primary-50/50 dark:border-primary-500 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-dark-600 dark:hover:border-dark-500'"
>
<input
type="radio"
:value="group.id"
v-model="selectedGroupId"
class="sr-only"
/>
<div
class="flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all"
:class="selectedGroupId === group.id
? 'border-primary-500 bg-primary-500'
: 'border-gray-300 dark:border-dark-500'"
>
<div v-if="selectedGroupId === group.id" class="h-2 w-2 rounded-full bg-white"></div>
</div>
<div class="flex-1">
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
<span class="ml-2 text-xs text-gray-400">{{ group.platform }}</span>
</div>
</label>
</div>
<!-- 无可选分组 -->
<div v-else class="py-6 text-center text-sm text-gray-400">
{{ t('admin.users.noOtherGroups') }}
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary px-5">{{ t('common.cancel') }}</button>
<button
@click="handleReplace"
:disabled="!selectedGroupId || submitting"
class="btn btn-primary px-6"
>
<svg v-if="submitting" class="-ml-1 mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('common.saving') : t('admin.users.replaceGroupConfirm') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { AdminUser, AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
interface Props {
show: boolean
user: AdminUser | null
oldGroup: { id: number; name: string } | null
allGroups: AdminGroup[]
}
const props = defineProps<Props>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n()
const appStore = useAppStore()
const selectedGroupId = ref<number | null>(null)
const submitting = ref(false)
// 可选的专属标准分组(排除当前 oldGroup
const availableGroups = computed(() => {
if (!props.oldGroup) return []
return props.allGroups.filter(
g => g.status === 'active' && g.is_exclusive && g.subscription_type === 'standard' && g.id !== props.oldGroup!.id
)
})
watch(() => props.show, (v) => {
if (v) {
selectedGroupId.value = null
}
})
const handleReplace = async () => {
if (!props.user || !props.oldGroup || !selectedGroupId.value) return
submitting.value = true
try {
const result = await adminAPI.users.replaceGroup(props.user.id, props.oldGroup.id, selectedGroupId.value)
appStore.showSuccess(t('admin.users.replaceGroupSuccess', { count: result.migrated_keys }))
emit('success')
emit('close')
} catch (error) {
console.error('Failed to replace group:', error)
} finally {
submitting.value = false
}
}
</script>

View File

@@ -79,7 +79,8 @@
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400', 'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(), getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }, { 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index) getStickyColumnClass(column, index),
column.class
]" ]"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
@@ -168,7 +169,8 @@
:class="[ :class="[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100', 'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(), getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex) getStickyColumnClass(column, colIndex),
column.class
]" ]"
> >
<slot :name="`cell-${column.key}`" <slot :name="`cell-${column.key}`"

View File

@@ -77,7 +77,13 @@
]" ]"
> >
<slot name="option" :option="option" :selected="isSelected(option)"> <slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span> <Icon
v-if="option._creatable"
name="search"
size="sm"
class="flex-shrink-0 text-gray-400"
/>
<span class="select-option-label" :class="option._creatable && 'italic text-gray-500 dark:text-dark-300'">{{ getOptionLabel(option) }}</span>
<Icon <Icon
v-if="isSelected(option)" v-if="isSelected(option)"
name="check" name="check"
@@ -127,6 +133,8 @@ interface Props {
emptyText?: string emptyText?: string
valueKey?: string valueKey?: string
labelKey?: string labelKey?: string
creatable?: boolean
creatablePrefix?: string
} }
interface Emits { interface Emits {
@@ -138,6 +146,8 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false,
error: false, error: false,
searchable: false, searchable: false,
creatable: false,
creatablePrefix: '',
valueKey: 'value', valueKey: 'value',
labelKey: 'label' labelKey: 'label'
}) })
@@ -217,6 +227,10 @@ const selectedLabel = computed(() => {
if (selectedOption.value) { if (selectedOption.value) {
return getOptionLabel(selectedOption.value) return getOptionLabel(selectedOption.value)
} }
// In creatable mode, show the raw value if no matching option
if (props.creatable && props.modelValue) {
return String(props.modelValue)
}
return placeholderText.value return placeholderText.value
}) })
@@ -231,6 +245,12 @@ const filteredOptions = computed(() => {
if (opt.description && String(opt.description).toLowerCase().includes(query)) return true if (opt.description && String(opt.description).toLowerCase().includes(query)) return true
return false return false
}) })
// In creatable mode, always prepend a fuzzy search option
if (props.creatable && searchQuery.value.trim()) {
const trimmed = searchQuery.value.trim()
const prefix = props.creatablePrefix || t('common.search')
opts = [{ [props.valueKey]: trimmed, [props.labelKey]: `${prefix} "${trimmed}"`, _creatable: true }, ...opts]
}
} }
return opts return opts
}) })

View File

@@ -6,5 +6,6 @@ export interface Column {
key: string key: string
label: string label: string
sortable?: boolean sortable?: boolean
class?: string
formatter?: (value: any, row: any) => string formatter?: (value: any, row: any) => string
} }

View File

@@ -86,6 +86,7 @@ const icons = {
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4', download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
upload: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5', upload: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5',
filter: 'M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z', filter: 'M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z',
globe: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418',
sort: 'M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9', sort: 'M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9',
// Security // Security

View File

@@ -1289,6 +1289,9 @@ export default {
searchUsers: 'Search by email, username, notes, or API key...', searchUsers: 'Search by email, username, notes, or API key...',
allRoles: 'All Roles', allRoles: 'All Roles',
allStatus: 'All Status', allStatus: 'All Status',
allGroups: 'All Groups',
searchGroups: 'Search groups...',
fuzzySearch: 'Fuzzy search',
admin: 'Admin', admin: 'Admin',
user: 'User', user: 'User',
disabled: 'Disabled', disabled: 'Disabled',
@@ -1313,6 +1316,7 @@ export default {
username: 'Username', username: 'Username',
notes: 'Notes', notes: 'Notes',
role: 'Role', role: 'Role',
groups: 'Groups',
subscriptions: 'Subscriptions', subscriptions: 'Subscriptions',
balance: 'Balance', balance: 'Balance',
usage: 'Usage', usage: 'Usage',
@@ -1324,6 +1328,9 @@ export default {
today: 'Today', today: 'Today',
total: 'Last 30d', total: 'Last 30d',
noSubscription: 'No subscription', noSubscription: 'No subscription',
publicGroupCount: '+{count} public',
exclusiveLabel: 'exclusive',
publicLabel: 'public',
daysRemaining: '{days}d', daysRemaining: '{days}d',
expired: 'Expired', expired: 'Expired',
disable: 'Disable', disable: 'Disable',
@@ -1379,6 +1386,14 @@ export default {
useDefaultRate: 'Use Default', useDefaultRate: 'Use Default',
customRatePlaceholder: 'Leave empty for default', customRatePlaceholder: 'Leave empty for default',
groupConfigUpdated: 'Group configuration updated successfully', groupConfigUpdated: 'Group configuration updated successfully',
replaceGroup: 'Replace Group',
clickToReplace: 'Click to replace',
replaceGroupTitle: 'Replace Exclusive Group',
replaceGroupHint: 'Select a new group to replace "{old}". Keys will be migrated and permissions updated automatically.',
replaceGroupConfirm: 'Confirm Replace',
replaceGroupSuccess: 'Group replaced successfully, {count} key(s) migrated',
selectNewGroup: 'Select target group',
noOtherGroups: 'No other exclusive groups available',
deposit: 'Deposit', deposit: 'Deposit',
withdraw: 'Withdraw', withdraw: 'Withdraw',
depositAmount: 'Deposit Amount', depositAmount: 'Deposit Amount',

View File

@@ -1314,6 +1314,9 @@ export default {
roleFilter: '角色筛选', roleFilter: '角色筛选',
allRoles: '全部角色', allRoles: '全部角色',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组',
searchGroups: '搜索分组...',
fuzzySearch: '模糊搜索',
statusFilter: '状态筛选', statusFilter: '状态筛选',
allStatuses: '全部状态', allStatuses: '全部状态',
admin: '管理员', admin: '管理员',
@@ -1340,6 +1343,7 @@ export default {
username: '用户名', username: '用户名',
notes: '备注', notes: '备注',
role: '角色', role: '角色',
groups: '分组',
subscriptions: '订阅分组', subscriptions: '订阅分组',
balance: '余额', balance: '余额',
usage: '用量', usage: '用量',
@@ -1351,6 +1355,9 @@ export default {
today: '今日', today: '今日',
total: '近30天', total: '近30天',
noSubscription: '暂无订阅', noSubscription: '暂无订阅',
publicGroupCount: '+{count} 公开',
exclusiveLabel: '专属',
publicLabel: '公开',
daysRemaining: '{days}天', daysRemaining: '{days}天',
expired: '已过期', expired: '已过期',
disable: '禁用', disable: '禁用',
@@ -1442,6 +1449,14 @@ export default {
useDefaultRate: '使用默认', useDefaultRate: '使用默认',
customRatePlaceholder: '留空使用默认', customRatePlaceholder: '留空使用默认',
groupConfigUpdated: '分组配置更新成功', groupConfigUpdated: '分组配置更新成功',
replaceGroup: '替换分组',
clickToReplace: '点击替换分组',
replaceGroupTitle: '替换专属分组',
replaceGroupHint: '选择新分组替换「{old}」,将自动迁移绑定的 Key 并更新分组权限',
replaceGroupConfirm: '确认替换',
replaceGroupSuccess: '分组替换成功,已迁移 {count} 个 Key',
selectNewGroup: '请选择目标分组',
noOtherGroups: '没有其他可用的专属分组',
deposit: '充值', deposit: '充值',
withdraw: '退款', withdraw: '退款',
depositAmount: '充值金额', depositAmount: '充值金额',

View File

@@ -48,6 +48,19 @@
/> />
</div> </div>
<!-- Group Filter (visible when enabled) -->
<div v-if="visibleFilters.has('group')" class="w-full sm:w-44">
<Select
v-model="filters.group"
:options="groupFilterOptions"
searchable
creatable
:creatable-prefix="t('admin.users.fuzzySearch')"
:search-placeholder="t('admin.users.searchGroups')"
@change="applyFilter"
/>
</div>
<!-- Dynamic Attribute Filters --> <!-- Dynamic Attribute Filters -->
<template v-for="(value, attrId) in activeAttributeFilters" :key="attrId"> <template v-for="(value, attrId) in activeAttributeFilters" :key="attrId">
<div <div
@@ -275,6 +288,71 @@
</span> </span>
</template> </template>
<template #cell-groups="{ row }">
<div v-if="allGroups.length > 0" class="flex flex-col gap-1">
<!-- 专属分组行 -->
<span
v-if="getUserGroups(row).exclusive.length > 0"
class="group/ex relative inline-flex cursor-pointer items-center gap-1 whitespace-nowrap text-xs"
@click.stop="toggleExpandedGroup(row.id)"
>
<Icon name="shield" size="xs" class="h-3.5 w-3.5 text-purple-500 dark:text-purple-400" />
<span class="font-medium text-purple-600 dark:text-purple-400">{{ getUserGroups(row).exclusive.length }}</span>
<span class="text-gray-500 dark:text-dark-400">{{ t('admin.users.exclusiveLabel') }}</span>
<!-- Hover tooltip操作菜单未打开时显示 -->
<div
v-if="expandedGroupUserId !== row.id"
class="pointer-events-none absolute left-0 top-full z-50 mt-1.5 rounded bg-gray-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover/ex:opacity-100 dark:bg-dark-600"
>
<div class="absolute left-4 bottom-full border-4 border-transparent border-b-gray-900 dark:border-b-dark-600"></div>
<div class="flex flex-col gap-0.5 whitespace-nowrap">
<span v-for="g in getUserGroups(row).exclusive" :key="g.id">{{ g.name }}</span>
</div>
</div>
<!-- 点击展开分组操作菜单 -->
<div
v-if="expandedGroupUserId === row.id"
class="absolute left-0 top-full z-50 mt-1.5 min-w-[160px] overflow-hidden rounded-lg border border-gray-200 bg-white py-1 text-xs shadow-xl dark:border-dark-600 dark:bg-dark-700"
>
<div class="border-b border-gray-100 px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:border-dark-600 dark:text-dark-400">
{{ t('admin.users.clickToReplace') }}
</div>
<div
v-for="g in getUserGroups(row).exclusive"
:key="g.id"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-gray-700 transition-colors hover:bg-primary-50 hover:text-primary-600 dark:text-dark-200 dark:hover:bg-primary-900/30 dark:hover:text-primary-400"
@click.stop="openGroupReplace(row, g)"
>
<Icon name="swap" size="xs" class="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
<span class="flex-1">{{ g.name }}</span>
</div>
</div>
</span>
<!-- 公开分组行 -->
<span
v-if="getUserGroups(row).publicGroups.length > 0"
class="group/pub relative inline-flex cursor-default items-center gap-1 whitespace-nowrap text-xs"
>
<Icon name="globe" size="xs" class="h-3.5 w-3.5 text-gray-400 dark:text-dark-500" />
<span class="font-medium text-gray-600 dark:text-dark-300">{{ getUserGroups(row).publicGroups.length }}</span>
<span class="text-gray-400 dark:text-dark-500">{{ t('admin.users.publicLabel') }}</span>
<!-- Tooltip: 向下弹出 -->
<div class="pointer-events-none absolute left-0 top-full z-50 mt-1.5 rounded bg-gray-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover/pub:opacity-100 dark:bg-dark-600">
<div class="absolute left-4 bottom-full border-4 border-transparent border-b-gray-900 dark:border-b-dark-600"></div>
<div class="flex flex-col gap-0.5 whitespace-nowrap">
<span v-for="g in getUserGroups(row).publicGroups" :key="g.id">{{ g.name }}</span>
</div>
</div>
</span>
<!-- 都没有 -->
<span
v-if="getUserGroups(row).exclusive.length === 0 && getUserGroups(row).publicGroups.length === 0"
class="text-xs text-gray-400 dark:text-dark-500"
>-</span>
</div>
<span v-else class="text-xs text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-subscriptions="{ row }"> <template #cell-subscriptions="{ row }">
<div <div
v-if="row.subscriptions && row.subscriptions.length > 0" v-if="row.subscriptions && row.subscriptions.length > 0"
@@ -513,6 +591,7 @@
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" /> <UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" /> <UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" />
<UserBalanceHistoryModal :show="showBalanceHistoryModal" :user="balanceHistoryUser" @close="closeBalanceHistoryModal" @deposit="handleDepositFromHistory" @withdraw="handleWithdrawFromHistory" /> <UserBalanceHistoryModal :show="showBalanceHistoryModal" :user="balanceHistoryUser" @close="closeBalanceHistoryModal" @deposit="handleDepositFromHistory" @withdraw="handleWithdrawFromHistory" />
<GroupReplaceModal :show="showGroupReplaceModal" :user="groupReplaceUser" :old-group="groupReplaceOldGroup" :all-groups="allGroups" @close="closeGroupReplaceModal" @success="loadUsers" />
<UserAttributesConfigModal :show="showAttributesModal" @close="handleAttributesModalClose" /> <UserAttributesConfigModal :show="showAttributesModal" @close="handleAttributesModalClose" />
</AppLayout> </AppLayout>
</template> </template>
@@ -527,7 +606,7 @@ import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n() const { t } = useI18n()
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { AdminUser, UserAttributeDefinition } from '@/types' import type { AdminUser, AdminGroup, UserAttributeDefinition } from '@/types'
import type { BatchUserUsageStats } from '@/api/admin/dashboard' import type { BatchUserUsageStats } from '@/api/admin/dashboard'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
@@ -546,6 +625,7 @@ import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue' import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue' import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue' import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
import GroupReplaceModal from '@/components/admin/user/GroupReplaceModal.vue'
const appStore = useAppStore() const appStore = useAppStore()
@@ -604,6 +684,7 @@ const allColumns = computed<Column[]>(() => [
// Dynamic attribute columns // Dynamic attribute columns
...attributeColumns.value, ...attributeColumns.value,
{ key: 'role', label: t('admin.users.columns.role'), sortable: true }, { key: 'role', label: t('admin.users.columns.role'), sortable: true },
{ key: 'groups', label: t('admin.users.columns.groups'), sortable: false },
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false }, { key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true }, { key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false }, { key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
@@ -623,7 +704,7 @@ const toggleableColumns = computed(() =>
const hiddenColumns = reactive<Set<string>>(new Set()) const hiddenColumns = reactive<Set<string>>(new Set())
// Default hidden columns (columns hidden by default on first load) // Default hidden columns (columns hidden by default on first load)
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'subscriptions', 'usage', 'concurrency'] const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency']
// localStorage key for column settings // localStorage key for column settings
const HIDDEN_COLUMNS_KEY = 'user-hidden-columns' const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
@@ -669,12 +750,16 @@ const toggleColumn = (key: string) => {
if (key === 'subscriptions') { if (key === 'subscriptions') {
loadUsers() loadUsers()
} }
if (wasHidden && key === 'groups') {
loadAllGroups()
}
} }
// Check if column is visible (not in hidden set) // Check if column is visible (not in hidden set)
const isColumnVisible = (key: string) => !hiddenColumns.has(key) const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const hasVisibleUsageColumn = computed(() => !hiddenColumns.has('usage')) const hasVisibleUsageColumn = computed(() => !hiddenColumns.has('usage'))
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions')) const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
const hasVisibleGroupsColumn = computed(() => !hiddenColumns.has('groups'))
const hasVisibleAttributeColumns = computed(() => const hasVisibleAttributeColumns = computed(() =>
attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`)) attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`))
) )
@@ -690,10 +775,50 @@ const users = ref<AdminUser[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
// Groups data for the groups column
const allGroups = ref<AdminGroup[]>([])
const loadAllGroups = async () => {
if (allGroups.value.length > 0) return
try {
allGroups.value = await adminAPI.groups.getAll()
} catch (e) {
console.error('Failed to load groups:', e)
}
}
// Resolve user's accessible groups: exclusive groups first, then public groups
const getUserGroups = (user: AdminUser) => {
const exclusive: AdminGroup[] = []
const publicGroups: AdminGroup[] = []
for (const g of allGroups.value) {
if (g.status !== 'active' || g.subscription_type !== 'standard') continue
if (g.is_exclusive) {
if (user.allowed_groups?.includes(g.id)) {
exclusive.push(g)
}
} else {
publicGroups.push(g)
}
}
return { exclusive, publicGroups }
}
// Group filter options: "All Groups" + active exclusive groups (value = group name for fuzzy match)
const groupFilterOptions = computed(() => {
const options: { value: string; label: string }[] = [
{ value: '', label: t('admin.users.allGroups') }
]
for (const g of allGroups.value) {
if (g.status !== 'active' || !g.is_exclusive || g.subscription_type !== 'standard') continue
options.push({ value: g.name, label: g.name })
}
return options
})
// Filter values (role, status, and custom attributes) // Filter values (role, status, and custom attributes)
const filters = reactive({ const filters = reactive({
role: '', role: '',
status: '' status: '',
group: '' // group name for fuzzy match, '' = all
}) })
const activeAttributeFilters = reactive<Record<number, string>>({}) const activeAttributeFilters = reactive<Record<number, string>>({})
@@ -721,7 +846,8 @@ const filterableAttributes = computed(() =>
// Built-in filter definitions // Built-in filter definitions
const builtInFilters = computed(() => [ const builtInFilters = computed(() => [
{ key: 'role', name: t('admin.users.columns.role'), type: 'select' as const }, { key: 'role', name: t('admin.users.columns.role'), type: 'select' as const },
{ key: 'status', name: t('admin.users.columns.status'), type: 'select' as const } { key: 'status', name: t('admin.users.columns.status'), type: 'select' as const },
{ key: 'group', name: t('admin.users.columns.groups'), type: 'select' as const }
]) ])
// Load saved filters from localStorage // Load saved filters from localStorage
@@ -739,6 +865,7 @@ const loadSavedFilters = () => {
const parsed = JSON.parse(savedValues) const parsed = JSON.parse(savedValues)
if (parsed.role) filters.role = parsed.role if (parsed.role) filters.role = parsed.role
if (parsed.status) filters.status = parsed.status if (parsed.status) filters.status = parsed.status
if (parsed.group) filters.group = parsed.group
if (parsed.attributes) { if (parsed.attributes) {
Object.assign(activeAttributeFilters, parsed.attributes) Object.assign(activeAttributeFilters, parsed.attributes)
} }
@@ -757,6 +884,7 @@ const saveFiltersToStorage = () => {
const values = { const values = {
role: filters.role, role: filters.role,
status: filters.status, status: filters.status,
group: filters.group,
attributes: activeAttributeFilters attributes: activeAttributeFilters
} }
localStorage.setItem(FILTER_VALUES_KEY, JSON.stringify(values)) localStorage.setItem(FILTER_VALUES_KEY, JSON.stringify(values))
@@ -920,12 +1048,27 @@ const handleClickOutside = (event: MouseEvent) => {
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) { if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
showColumnDropdown.value = false showColumnDropdown.value = false
} }
// Close expanded group dropdown when clicking outside
if (expandedGroupUserId.value !== null) {
expandedGroupUserId.value = null
}
} }
// Allowed groups modal state // Allowed groups modal state
const showAllowedGroupsModal = ref(false) const showAllowedGroupsModal = ref(false)
const allowedGroupsUser = ref<AdminUser | null>(null) const allowedGroupsUser = ref<AdminUser | null>(null)
// Expanded group dropdown state (click to show exclusive groups list)
const expandedGroupUserId = ref<number | null>(null)
const toggleExpandedGroup = (userId: number) => {
expandedGroupUserId.value = expandedGroupUserId.value === userId ? null : userId
}
// Group replace modal state
const showGroupReplaceModal = ref(false)
const groupReplaceUser = ref<AdminUser | null>(null)
const groupReplaceOldGroup = ref<{ id: number; name: string } | null>(null)
// Balance (Deposit/Withdraw) modal state // Balance (Deposit/Withdraw) modal state
const showBalanceModal = ref(false) const showBalanceModal = ref(false)
const balanceUser = ref<AdminUser | null>(null) const balanceUser = ref<AdminUser | null>(null)
@@ -980,6 +1123,7 @@ const loadUsers = async () => {
role: filters.role as any, role: filters.role as any,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
group_name: filters.group || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined, attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
include_subscriptions: hasVisibleSubscriptionsColumn.value include_subscriptions: hasVisibleSubscriptionsColumn.value
}, },
@@ -1052,8 +1196,10 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters.delete(key) visibleFilters.delete(key)
if (key === 'role') filters.role = '' if (key === 'role') filters.role = ''
if (key === 'status') filters.status = '' if (key === 'status') filters.status = ''
if (key === 'group') filters.group = ''
} else { } else {
visibleFilters.add(key) visibleFilters.add(key)
if (key === 'group') loadAllGroups()
} }
saveFiltersToStorage() saveFiltersToStorage()
pagination.page = 1 pagination.page = 1
@@ -1129,6 +1275,19 @@ const closeAllowedGroupsModal = () => {
allowedGroupsUser.value = null allowedGroupsUser.value = null
} }
const openGroupReplace = (user: AdminUser, group: { id: number; name: string }) => {
expandedGroupUserId.value = null
groupReplaceUser.value = user
groupReplaceOldGroup.value = group
showGroupReplaceModal.value = true
}
const closeGroupReplaceModal = () => {
showGroupReplaceModal.value = false
groupReplaceUser.value = null
groupReplaceOldGroup.value = null
}
const handleDelete = (user: AdminUser) => { const handleDelete = (user: AdminUser) => {
deletingUser.value = user deletingUser.value = user
showDeleteDialog.value = true showDeleteDialog.value = true
@@ -1199,6 +1358,9 @@ onMounted(async () => {
loadSavedFilters() loadSavedFilters()
loadSavedColumns() loadSavedColumns()
loadUsers() loadUsers()
if (hasVisibleGroupsColumn.value || visibleFilters.has('group')) {
loadAllGroups()
}
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
window.addEventListener('scroll', handleScroll, true) window.addEventListener('scroll', handleScroll, true)
}) })