Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
1ee98447
Unverified
Commit
1ee98447
authored
Mar 13, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 13, 2026
Browse files
Merge pull request #957 from touwaeriol/feat/group-rate-multipliers-modal
feat(groups): add rate multipliers management modal
parents
fd693dc5
d6488112
Changes
13
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/admin_service_stub_test.go
View file @
1ee98447
...
...
@@ -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
}
...
...
backend/internal/handler/admin/group_handler.go
View file @
1ee98447
...
...
@@ -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
{
...
...
backend/internal/repository/user_group_rate_repo.go
View file @
1ee98447
...
...
@@ -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
)
...
...
backend/internal/server/routes/admin.go
View file @
1ee98447
...
...
@@ -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
)
}
}
...
...
backend/internal/service/admin_service.go
View file @
1ee98447
...
...
@@ -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)
...
...
@@ -1249,6 +1252,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
)
}
...
...
backend/internal/service/admin_service_group_rate_test.go
0 → 100644
View file @
1ee98447
//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"
)
})
}
backend/internal/service/admin_service_list_users_test.go
View file @
1ee98447
...
...
@@ -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"
)
}
...
...
backend/internal/service/user_group_rate.go
View file @
1ee98447
...
...
@@ -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
...
...
frontend/src/api/admin/groups.ts
View file @
1ee98447
...
...
@@ -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
}
...
...
frontend/src/components/admin/group/GroupRateMultipliersModal.vue
0 → 100644
View file @
1ee98447
<
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
>
frontend/src/i18n/locales/en.ts
View file @
1ee98447
...
...
@@ -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
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
1ee98447
...
...
@@ -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
:
'
计费类型
'
,
...
...
frontend/src/views/admin/GroupsView.vue
View file @
1ee98447
...
...
@@ -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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment