mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 02:07:32 +00:00
feat(groups): add rate multipliers management modal
Add a dedicated modal in group management for viewing, adding, editing, and deleting per-user rate multipliers within a group. Backend: - GET /admin/groups/:id/rate-multipliers - list entries with user details - PUT /admin/groups/:id/rate-multipliers - batch sync (full replace) - DELETE /admin/groups/:id/rate-multipliers - clear all entries - Repository: GetByGroupID, SyncGroupRateMultipliers methods on user_group_rate_multipliers table (same table as user-side rates) Frontend: - New GroupRateMultipliersModal component with: - User search and add with email autocomplete - Editable rate column with local edit mode (cancel/save) - Batch adjust: multiply all rates by a factor - Clear all (local operation, requires save to persist) - Pagination (10/20/50 per page) - Platform icon with brand colors in group info bar - Unsaved changes indicator with revert option - Unit tests for all three backend endpoints
This commit is contained in:
@@ -175,6 +175,18 @@ func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, p
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) ([]service.UserGroupRateEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
|
||||
return s.accounts, int64(len(s.accounts)), nil
|
||||
}
|
||||
|
||||
@@ -335,6 +335,72 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
response.Paginated(c, outKeys, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
|
||||
// GET /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.adminService.GetGroupRateMultipliers(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entries == nil {
|
||||
entries = []service.UserGroupRateEntry{}
|
||||
}
|
||||
response.Success(c, entries)
|
||||
}
|
||||
|
||||
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.ClearGroupRateMultipliers(c.Request.Context(), groupID); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"})
|
||||
}
|
||||
|
||||
// BatchSetGroupRateMultipliersRequest represents batch set rate multipliers request
|
||||
type BatchSetGroupRateMultipliersRequest struct {
|
||||
Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
|
||||
// PUT /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchSetGroupRateMultipliersRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.BatchSetGroupRateMultipliers(c.Request.Context(), groupID, req.Entries); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Rate multipliers updated successfully"})
|
||||
}
|
||||
|
||||
// UpdateSortOrderRequest represents the request to update group sort orders
|
||||
type UpdateSortOrderRequest struct {
|
||||
Updates []struct {
|
||||
|
||||
@@ -95,6 +95,35 @@ func (r *userGroupRateRepository) GetByUserIDs(ctx context.Context, userIDs []in
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByGroupID 获取指定分组下所有用户的专属倍率
|
||||
func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int64) ([]service.UserGroupRateEntry, error) {
|
||||
query := `
|
||||
SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, ugr.rate_multiplier
|
||||
FROM user_group_rate_multipliers ugr
|
||||
JOIN users u ON u.id = ugr.user_id
|
||||
WHERE ugr.group_id = $1
|
||||
ORDER BY ugr.user_id
|
||||
`
|
||||
rows, err := r.sql.QueryContext(ctx, query, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []service.UserGroupRateEntry
|
||||
for rows.Next() {
|
||||
var entry service.UserGroupRateEntry
|
||||
if err := rows.Scan(&entry.UserID, &entry.UserName, &entry.UserEmail, &entry.UserNotes, &entry.UserStatus, &entry.RateMultiplier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByUserAndGroup 获取用户在特定分组的专属倍率
|
||||
func (r *userGroupRateRepository) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) {
|
||||
query := `SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`
|
||||
@@ -164,6 +193,31 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(先删后插)
|
||||
func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []service.GroupRateMultiplierInput) error {
|
||||
if _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
userIDs := make([]int64, len(entries))
|
||||
rates := make([]float64, len(entries))
|
||||
for i, e := range entries {
|
||||
userIDs[i] = e.UserID
|
||||
rates[i] = e.RateMultiplier
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := r.sql.ExecContext(ctx, `
|
||||
INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at)
|
||||
SELECT data.user_id, $1::bigint, data.rate_multiplier, $2::timestamptz, $2::timestamptz
|
||||
FROM unnest($3::bigint[], $4::double precision[]) AS data(user_id, rate_multiplier)
|
||||
ON CONFLICT (user_id, group_id)
|
||||
DO UPDATE SET rate_multiplier = EXCLUDED.rate_multiplier, updated_at = EXCLUDED.updated_at
|
||||
`, groupID, now, pq.Array(userIDs), pq.Array(rates))
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByGroupID 删除指定分组的所有用户专属倍率
|
||||
func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error {
|
||||
_, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID)
|
||||
|
||||
@@ -228,6 +228,9 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
groups.PUT("/:id", h.Admin.Group.Update)
|
||||
groups.DELETE("/:id", h.Admin.Group.Delete)
|
||||
groups.GET("/:id/stats", h.Admin.Group.GetStats)
|
||||
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
|
||||
groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers)
|
||||
groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers)
|
||||
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ type AdminService interface {
|
||||
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
||||
DeleteGroup(ctx context.Context, id int64) error
|
||||
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||
ClearGroupRateMultipliers(ctx context.Context, groupID int64) error
|
||||
BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||
|
||||
// API Key management (admin)
|
||||
@@ -1244,6 +1247,27 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p
|
||||
return keys, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.userGroupRateRepo.GetByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
|
||||
return s.groupRepo.UpdateSortOrders(ctx, updates)
|
||||
}
|
||||
|
||||
176
backend/internal/service/admin_service_group_rate_test.go
Normal file
176
backend/internal/service/admin_service_group_rate_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// userGroupRateRepoStubForGroupRate implements UserGroupRateRepository for group rate tests.
|
||||
type userGroupRateRepoStubForGroupRate struct {
|
||||
getByGroupIDData map[int64][]UserGroupRateEntry
|
||||
getByGroupIDErr error
|
||||
|
||||
deletedGroupIDs []int64
|
||||
deleteByGroupErr error
|
||||
|
||||
syncedGroupID int64
|
||||
syncedEntries []GroupRateMultiplierInput
|
||||
syncGroupErr error
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) {
|
||||
panic("unexpected GetByUserID call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByUserAndGroup(_ context.Context, _, _ int64) (*float64, error) {
|
||||
panic("unexpected GetByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||
if s.getByGroupIDErr != nil {
|
||||
return nil, s.getByGroupIDErr
|
||||
}
|
||||
return s.getByGroupIDData[groupID], nil
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context, _ int64, _ map[int64]*float64) error {
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) SyncGroupRateMultipliers(_ context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
|
||||
s.syncedGroupID = groupID
|
||||
s.syncedEntries = entries
|
||||
return s.syncGroupErr
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, groupID)
|
||||
return s.deleteByGroupErr
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) DeleteByUserID(_ context.Context, _ int64) error {
|
||||
panic("unexpected DeleteByUserID call")
|
||||
}
|
||||
|
||||
func TestAdminService_GetGroupRateMultipliers(t *testing.T) {
|
||||
t.Run("returns entries for group", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDData: map[int64][]UserGroupRateEntry{
|
||||
10: {
|
||||
{UserID: 1, UserName: "alice", UserEmail: "alice@test.com", RateMultiplier: 1.5},
|
||||
{UserID: 2, UserName: "bob", UserEmail: "bob@test.com", RateMultiplier: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
entries, err := svc.GetGroupRateMultipliers(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
require.Equal(t, int64(1), entries[0].UserID)
|
||||
require.Equal(t, "alice", entries[0].UserName)
|
||||
require.Equal(t, 1.5, entries[0].RateMultiplier)
|
||||
require.Equal(t, int64(2), entries[1].UserID)
|
||||
require.Equal(t, 0.8, entries[1].RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
svc := &adminServiceImpl{userGroupRateRepo: nil}
|
||||
|
||||
entries, err := svc.GetGroupRateMultipliers(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, entries)
|
||||
})
|
||||
|
||||
t.Run("returns empty slice for group with no entries", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDData: map[int64][]UserGroupRateEntry{},
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
entries, err := svc.GetGroupRateMultipliers(context.Background(), 99)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, entries)
|
||||
})
|
||||
|
||||
t.Run("propagates repo error", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDErr: errors.New("db error"),
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
_, err := svc.GetGroupRateMultipliers(context.Background(), 10)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "db error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdminService_ClearGroupRateMultipliers(t *testing.T) {
|
||||
t.Run("deletes by group ID", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{42}, repo.deletedGroupIDs)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
svc := &adminServiceImpl{userGroupRateRepo: nil}
|
||||
|
||||
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("propagates repo error", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
deleteByGroupErr: errors.New("delete failed"),
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "delete failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdminService_BatchSetGroupRateMultipliers(t *testing.T) {
|
||||
t.Run("syncs entries to repo", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
entries := []GroupRateMultiplierInput{
|
||||
{UserID: 1, RateMultiplier: 1.5},
|
||||
{UserID: 2, RateMultiplier: 0.8},
|
||||
}
|
||||
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, entries)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(10), repo.syncedGroupID)
|
||||
require.Equal(t, entries, repo.syncedEntries)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
svc := &adminServiceImpl{userGroupRateRepo: nil}
|
||||
|
||||
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("propagates repo error", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
syncGroupErr: errors.New("sync failed"),
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, []GroupRateMultiplierInput{
|
||||
{UserID: 1, RateMultiplier: 1.0},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "sync failed")
|
||||
})
|
||||
}
|
||||
@@ -68,7 +68,15 @@ func (s *userGroupRateRepoStubForListUsers) SyncUserGroupRates(_ context.Context
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
func (s *userGroupRateRepoStubForListUsers) GetByGroupID(_ context.Context, _ int64) ([]UserGroupRateEntry, error) {
|
||||
panic("unexpected GetByGroupID call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) SyncGroupRateMultipliers(_ context.Context, _ int64, _ []GroupRateMultiplierInput) error {
|
||||
panic("unexpected SyncGroupRateMultipliers call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, _ int64) error {
|
||||
panic("unexpected DeleteByGroupID call")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,22 @@ package service
|
||||
|
||||
import "context"
|
||||
|
||||
// UserGroupRateEntry 分组下用户专属倍率条目
|
||||
type UserGroupRateEntry struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserNotes string `json:"user_notes"`
|
||||
UserStatus string `json:"user_status"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
}
|
||||
|
||||
// GroupRateMultiplierInput 批量设置分组倍率的输入条目
|
||||
type GroupRateMultiplierInput struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
}
|
||||
|
||||
// UserGroupRateRepository 用户专属分组倍率仓储接口
|
||||
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
||||
type UserGroupRateRepository interface {
|
||||
@@ -13,10 +29,16 @@ type UserGroupRateRepository interface {
|
||||
// 如果未设置专属倍率,返回 nil
|
||||
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
|
||||
|
||||
// GetByGroupID 获取指定分组下所有用户的专属倍率
|
||||
GetByGroupID(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||
|
||||
// SyncUserGroupRates 同步用户的分组专属倍率
|
||||
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
|
||||
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
|
||||
|
||||
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组数据)
|
||||
SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||||
|
||||
// DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用)
|
||||
DeleteByGroupID(ctx context.Context, groupID int64) error
|
||||
|
||||
|
||||
@@ -153,6 +153,30 @@ export async function getGroupApiKeys(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate multiplier entry for a user in a group
|
||||
*/
|
||||
export interface GroupRateMultiplierEntry {
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
user_notes: string
|
||||
user_status: string
|
||||
rate_multiplier: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate multipliers for users in a group
|
||||
* @param id - Group ID
|
||||
* @returns List of user rate multiplier entries
|
||||
*/
|
||||
export async function getGroupRateMultipliers(id: number): Promise<GroupRateMultiplierEntry[]> {
|
||||
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
|
||||
`/admin/groups/${id}/rate-multipliers`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group sort orders
|
||||
* @param updates - Array of { id, sort_order } objects
|
||||
@@ -167,6 +191,33 @@ export async function updateSortOrder(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate multipliers for a group
|
||||
* @param id - Group ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function clearGroupRateMultipliers(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rate-multipliers`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set rate multipliers for users in a group
|
||||
* @param id - Group ID
|
||||
* @param entries - Array of { user_id, rate_multiplier }
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchSetGroupRateMultipliers(
|
||||
id: number,
|
||||
entries: Array<{ user_id: number; rate_multiplier: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>(
|
||||
`/admin/groups/${id}/rate-multipliers`,
|
||||
{ entries }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -178,6 +229,9 @@ export const groupsAPI = {
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys,
|
||||
getGroupRateMultipliers,
|
||||
clearGroupRateMultipliers,
|
||||
batchSetGroupRateMultipliers,
|
||||
updateSortOrder
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="wide" @close="handleClose">
|
||||
<div v-if="group" class="space-y-4">
|
||||
<!-- 分组信息 -->
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-2.5 text-sm dark:bg-dark-700">
|
||||
<span class="inline-flex items-center gap-1.5" :class="platformColorClass">
|
||||
<PlatformIcon :platform="group.platform" size="sm" />
|
||||
{{ t('admin.groups.platforms.' + group.platform) }}
|
||||
</span>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.groups.columns.rateMultiplier') }}: {{ group.rate_multiplier }}x
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<!-- 添加用户 -->
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.addUserRate') }}
|
||||
</h4>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.groups.searchUserPlaceholder')"
|
||||
@input="handleSearchUsers"
|
||||
@focus="showDropdown = true"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown && searchResults.length > 0"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
|
||||
>
|
||||
<button
|
||||
v-for="user in searchResults"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<span class="text-gray-400">#{{ user.id }}</span>
|
||||
<span class="text-gray-900 dark:text-white">{{ user.username || user.email }}</span>
|
||||
<span v-if="user.username" class="text-xs text-gray-400">{{ user.email }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<input
|
||||
v-model.number="newRate"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
class="hide-spinner input w-full"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary shrink-0"
|
||||
:disabled="!selectedUser || !newRate"
|
||||
@click="handleAddLocal"
|
||||
>
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 批量调整 + 全部清空 -->
|
||||
<div v-if="localEntries.length > 0" class="mt-3 flex items-center gap-3 border-t border-gray-100 pt-3 dark:border-dark-600">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.batchAdjust') }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-400">×</span>
|
||||
<input
|
||||
v-model.number="batchFactor"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm shrink-0 px-2.5 py-1 text-xs"
|
||||
:disabled="!batchFactor || batchFactor <= 0"
|
||||
@click="applyBatchFactor"
|
||||
>
|
||||
{{ t('admin.groups.applyMultiplier') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
|
||||
@click="clearAllLocal"
|
||||
>
|
||||
{{ t('admin.groups.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex justify-center py-6">
|
||||
<svg class="h-6 w-6 animate-spin text-primary-500" 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>
|
||||
</div>
|
||||
|
||||
<!-- 已设置的用户列表 -->
|
||||
<div v-else>
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.rateMultipliers') }} ({{ localEntries.length }})
|
||||
</h4>
|
||||
|
||||
<div v-if="localEntries.length === 0" class="py-6 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.groups.noRateMultipliers') }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 表格 -->
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<div class="max-h-[420px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 z-[1]">
|
||||
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">ID</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userName') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userNotes') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userStatus') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.rateMultiplier') }}</th>
|
||||
<th v-if="showFinalRate" class="px-3 py-2 text-left text-xs font-medium text-primary-600 dark:text-primary-400">{{ t('admin.groups.finalRate') }}</th>
|
||||
<th class="w-10 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-600">
|
||||
<tr
|
||||
v-for="entry in paginatedLocalEntries"
|
||||
:key="entry.user_id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-700/50"
|
||||
>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ entry.user_email }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2 text-gray-400 dark:text-gray-500">{{ entry.user_id }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white">{{ entry.user_name || '-' }}</td>
|
||||
<td class="max-w-[160px] truncate px-3 py-2 text-gray-500 dark:text-gray-400" :title="entry.user_notes">{{ entry.user_notes || '-' }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
entry.user_status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ entry.user_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
:value="entry.rate_multiplier"
|
||||
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
@change="updateLocalRate(entry.user_id, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</td>
|
||||
<td v-if="showFinalRate" class="whitespace-nowrap px-3 py-2 font-medium text-primary-600 dark:text-primary-400">
|
||||
{{ computeFinalRate(entry.rate_multiplier) }}
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
@click="removeLocal(entry.user_id)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="localEntries.length"
|
||||
:page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@update:page="currentPage = $event"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="flex items-center gap-3 border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<!-- 左侧:未保存提示 + 撤销 -->
|
||||
<template v-if="isDirty">
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">{{ t('admin.groups.unsavedChanges') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('admin.groups.revertChanges') }}
|
||||
</button>
|
||||
</template>
|
||||
<!-- 右侧:关闭 / 保存 -->
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<button type="button" class="btn btn-sm px-4 py-1.5" @click="handleClose">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isDirty"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm px-4 py-1.5"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GroupRateMultiplierEntry } from '@/api/admin/groups'
|
||||
import type { AdminGroup, AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
|
||||
interface LocalEntry extends GroupRateMultiplierEntry {}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
group: AdminGroup | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const serverEntries = ref<GroupRateMultiplierEntry[]>([])
|
||||
const localEntries = ref<LocalEntry[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<AdminUser[]>([])
|
||||
const showDropdown = ref(false)
|
||||
const selectedUser = ref<AdminUser | null>(null)
|
||||
const newRate = ref<number | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const batchFactor = ref<number | null>(null)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const platformColorClass = computed(() => {
|
||||
switch (props.group?.platform) {
|
||||
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
|
||||
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
|
||||
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
|
||||
default: return 'text-blue-700 dark:text-blue-400'
|
||||
}
|
||||
})
|
||||
|
||||
// 是否显示"最终倍率"预览列
|
||||
const showFinalRate = computed(() => {
|
||||
return batchFactor.value != null && batchFactor.value > 0 && batchFactor.value !== 1
|
||||
})
|
||||
|
||||
// 计算最终倍率预览
|
||||
const computeFinalRate = (rate: number) => {
|
||||
if (!batchFactor.value) return rate
|
||||
return parseFloat((rate * batchFactor.value).toFixed(6))
|
||||
}
|
||||
|
||||
// 检测是否有未保存的修改
|
||||
const isDirty = computed(() => {
|
||||
if (localEntries.value.length !== serverEntries.value.length) return true
|
||||
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier]))
|
||||
return localEntries.value.some(e => {
|
||||
const serverRate = serverMap.get(e.user_id)
|
||||
return serverRate === undefined || serverRate !== e.rate_multiplier
|
||||
})
|
||||
})
|
||||
|
||||
const paginatedLocalEntries = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return localEntries.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const cloneEntries = (entries: GroupRateMultiplierEntry[]): LocalEntry[] => {
|
||||
return entries.map(e => ({ ...e }))
|
||||
}
|
||||
|
||||
const loadEntries = async () => {
|
||||
if (!props.group) return
|
||||
loading.value = true
|
||||
try {
|
||||
serverEntries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
adjustPage()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading group rate multipliers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const adjustPage = () => {
|
||||
const totalPages = Math.max(1, Math.ceil(localEntries.value.length / pageSize.value))
|
||||
if (currentPage.value > totalPages) {
|
||||
currentPage.value = totalPages
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.group) {
|
||||
currentPage.value = 1
|
||||
batchFactor.value = null
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
selectedUser.value = null
|
||||
newRate.value = null
|
||||
loadEntries()
|
||||
}
|
||||
})
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
pageSize.value = newSize
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleSearchUsers = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
selectedUser.value = null
|
||||
if (!searchQuery.value.trim()) {
|
||||
searchResults.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await adminAPI.users.list(1, 10, { search: searchQuery.value.trim() })
|
||||
searchResults.value = res.items
|
||||
showDropdown.value = true
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const selectUser = (user: AdminUser) => {
|
||||
selectedUser.value = user
|
||||
searchQuery.value = user.email
|
||||
showDropdown.value = false
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
// 本地添加(或覆盖已有用户)
|
||||
const handleAddLocal = () => {
|
||||
if (!selectedUser.value || !newRate.value) return
|
||||
const user = selectedUser.value
|
||||
const idx = localEntries.value.findIndex(e => e.user_id === user.id)
|
||||
const entry: LocalEntry = {
|
||||
user_id: user.id,
|
||||
user_name: user.username || '',
|
||||
user_email: user.email,
|
||||
user_notes: user.notes || '',
|
||||
user_status: user.status || 'active',
|
||||
rate_multiplier: newRate.value
|
||||
}
|
||||
if (idx >= 0) {
|
||||
localEntries.value[idx] = entry
|
||||
} else {
|
||||
localEntries.value.push(entry)
|
||||
}
|
||||
searchQuery.value = ''
|
||||
selectedUser.value = null
|
||||
newRate.value = null
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
// 本地修改倍率
|
||||
const updateLocalRate = (userId: number, value: string) => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return
|
||||
const entry = localEntries.value.find(e => e.user_id === userId)
|
||||
if (entry) {
|
||||
entry.rate_multiplier = num
|
||||
}
|
||||
}
|
||||
|
||||
// 本地删除
|
||||
const removeLocal = (userId: number) => {
|
||||
localEntries.value = localEntries.value.filter(e => e.user_id !== userId)
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
// 批量乘数应用到本地
|
||||
const applyBatchFactor = () => {
|
||||
if (!batchFactor.value || batchFactor.value <= 0) return
|
||||
for (const entry of localEntries.value) {
|
||||
entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6))
|
||||
}
|
||||
batchFactor.value = null
|
||||
}
|
||||
|
||||
// 本地清空
|
||||
const clearAllLocal = () => {
|
||||
localEntries.value = []
|
||||
}
|
||||
|
||||
// 取消:恢复到服务器数据
|
||||
const handleCancel = () => {
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
batchFactor.value = null
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
// 保存:一次性提交所有数据
|
||||
const handleSave = async () => {
|
||||
if (!props.group) return
|
||||
saving.value = true
|
||||
try {
|
||||
const entries = localEntries.value.map(e => ({
|
||||
user_id: e.user_id,
|
||||
rate_multiplier: e.rate_multiplier
|
||||
}))
|
||||
await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries)
|
||||
appStore.showSuccess(t('admin.groups.rateSaved'))
|
||||
emit('success')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error saving rate multipliers:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭时如果有未保存修改,先恢复
|
||||
const handleClose = () => {
|
||||
if (isDirty.value) {
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉
|
||||
const handleClickOutside = () => {
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hide-spinner::-webkit-outer-spin-button,
|
||||
.hide-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.hide-spinner {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -1372,7 +1372,11 @@ export default {
|
||||
accounts: 'Accounts',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
billingType: 'Billing Type'
|
||||
billingType: 'Billing Type',
|
||||
userName: 'Username',
|
||||
userEmail: 'Email',
|
||||
userNotes: 'Notes',
|
||||
userStatus: 'Status'
|
||||
},
|
||||
rateAndAccounts: '{rate}x rate · {count} accounts',
|
||||
accountsCount: '{count} accounts',
|
||||
@@ -1411,6 +1415,26 @@ export default {
|
||||
failedToUpdate: 'Failed to update group',
|
||||
failedToDelete: 'Failed to delete group',
|
||||
nameRequired: 'Please enter group name',
|
||||
rateMultipliers: 'Rate Multipliers',
|
||||
rateMultipliersTitle: 'Group Rate Multipliers',
|
||||
addUserRate: 'Add User Rate Multiplier',
|
||||
searchUserPlaceholder: 'Search user email...',
|
||||
noRateMultipliers: 'No user rate multipliers configured',
|
||||
rateUpdated: 'Rate multiplier updated',
|
||||
rateDeleted: 'Rate multiplier removed',
|
||||
rateAdded: 'Rate multiplier added',
|
||||
clearAll: 'Clear All',
|
||||
confirmClearAll: 'Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.',
|
||||
rateCleared: 'All rate multipliers cleared',
|
||||
batchAdjust: 'Batch Adjust Rates',
|
||||
multiplierFactor: 'Factor',
|
||||
applyMultiplier: 'Apply',
|
||||
rateAdjusted: 'Rates adjusted successfully',
|
||||
rateSaved: 'Rate multipliers saved',
|
||||
finalRate: 'Final Rate',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
revertChanges: 'Revert',
|
||||
userInfo: 'User Info',
|
||||
platforms: {
|
||||
all: 'All Platforms',
|
||||
anthropic: 'Anthropic',
|
||||
|
||||
@@ -1428,7 +1428,11 @@ export default {
|
||||
accounts: '账号数',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
billingType: '计费类型'
|
||||
billingType: '计费类型',
|
||||
userName: '用户名',
|
||||
userEmail: '邮箱',
|
||||
userNotes: '备注',
|
||||
userStatus: '状态'
|
||||
},
|
||||
form: {
|
||||
name: '名称',
|
||||
@@ -1510,6 +1514,26 @@ export default {
|
||||
failedToCreate: '创建分组失败',
|
||||
failedToUpdate: '更新分组失败',
|
||||
nameRequired: '请输入分组名称',
|
||||
rateMultipliers: '专属倍率',
|
||||
rateMultipliersTitle: '分组专属倍率管理',
|
||||
addUserRate: '添加用户专属倍率',
|
||||
searchUserPlaceholder: '搜索用户邮箱...',
|
||||
noRateMultipliers: '暂无用户设置了专属倍率',
|
||||
rateUpdated: '专属倍率已更新',
|
||||
rateDeleted: '专属倍率已删除',
|
||||
rateAdded: '专属倍率已添加',
|
||||
clearAll: '全部清空',
|
||||
confirmClearAll: '确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。',
|
||||
rateCleared: '已清空所有专属倍率',
|
||||
batchAdjust: '批量调整倍率',
|
||||
multiplierFactor: '乘数',
|
||||
applyMultiplier: '应用',
|
||||
rateAdjusted: '倍率已批量调整',
|
||||
rateSaved: '专属倍率已保存',
|
||||
finalRate: '最终倍率',
|
||||
unsavedChanges: '有未保存的修改',
|
||||
revertChanges: '撤销修改',
|
||||
userInfo: '用户信息',
|
||||
subscription: {
|
||||
title: '订阅设置',
|
||||
type: '计费类型',
|
||||
|
||||
@@ -181,6 +181,13 @@
|
||||
<Icon name="edit" size="sm" />
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRateMultipliers(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
|
||||
>
|
||||
<Icon name="dollar" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.groups.rateMultipliers') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
@@ -1775,6 +1782,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Group Rate Multipliers Modal -->
|
||||
<GroupRateMultipliersModal
|
||||
:show="showRateMultipliersModal"
|
||||
:group="rateMultipliersGroup"
|
||||
@close="showRateMultipliersModal = false"
|
||||
@success="loadGroups"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1796,6 +1811,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
@@ -1970,6 +1986,8 @@ const submitting = ref(false)
|
||||
const sortSubmitting = ref(false)
|
||||
const editingGroup = ref<AdminGroup | null>(null)
|
||||
const deletingGroup = ref<AdminGroup | null>(null)
|
||||
const showRateMultipliersModal = ref(false)
|
||||
const rateMultipliersGroup = ref<AdminGroup | null>(null)
|
||||
const sortableGroups = ref<AdminGroup[]>([])
|
||||
|
||||
const createForm = reactive({
|
||||
@@ -2459,6 +2477,11 @@ const handleUpdateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRateMultipliers = (group: AdminGroup) => {
|
||||
rateMultipliersGroup.value = group
|
||||
showRateMultipliersModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group
|
||||
showDeleteDialog.value = true
|
||||
|
||||
Reference in New Issue
Block a user