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
fd57fa49
Unverified
Commit
fd57fa49
authored
Mar 01, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 01, 2026
Browse files
Merge pull request #690 from touwaeriol/pr/bulk-edit-mixed-channel-warning
feat: add mixed-channel warning for bulk account edit
parents
8c4d22b3
c08889b0
Changes
8
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_handler.go
View file @
fd57fa49
...
@@ -1122,6 +1122,14 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
...
@@ -1122,6 +1122,14 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
SkipMixedChannelCheck
:
skipCheck
,
SkipMixedChannelCheck
:
skipCheck
,
})
})
if
err
!=
nil
{
if
err
!=
nil
{
var
mixedErr
*
service
.
MixedChannelError
if
errors
.
As
(
err
,
&
mixedErr
)
{
c
.
JSON
(
409
,
gin
.
H
{
"error"
:
"mixed_channel_warning"
,
"message"
:
mixedErr
.
Error
(),
})
return
}
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
...
...
backend/internal/handler/admin/account_handler_mixed_channel_test.go
View file @
fd57fa49
...
@@ -19,6 +19,7 @@ func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
...
@@ -19,6 +19,7 @@ func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
router
.
POST
(
"/api/v1/admin/accounts/check-mixed-channel"
,
accountHandler
.
CheckMixedChannel
)
router
.
POST
(
"/api/v1/admin/accounts/check-mixed-channel"
,
accountHandler
.
CheckMixedChannel
)
router
.
POST
(
"/api/v1/admin/accounts"
,
accountHandler
.
Create
)
router
.
POST
(
"/api/v1/admin/accounts"
,
accountHandler
.
Create
)
router
.
PUT
(
"/api/v1/admin/accounts/:id"
,
accountHandler
.
Update
)
router
.
PUT
(
"/api/v1/admin/accounts/:id"
,
accountHandler
.
Update
)
router
.
POST
(
"/api/v1/admin/accounts/bulk-update"
,
accountHandler
.
BulkUpdate
)
return
router
return
router
}
}
...
@@ -145,3 +146,53 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T
...
@@ -145,3 +146,53 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T
require
.
False
(
t
,
hasDetails
)
require
.
False
(
t
,
hasDetails
)
require
.
False
(
t
,
hasRequireConfirmation
)
require
.
False
(
t
,
hasRequireConfirmation
)
}
}
func
TestAccountHandlerBulkUpdateMixedChannelConflict
(
t
*
testing
.
T
)
{
adminSvc
:=
newStubAdminService
()
adminSvc
.
bulkUpdateAccountErr
=
&
service
.
MixedChannelError
{
GroupID
:
27
,
GroupName
:
"claude-max"
,
CurrentPlatform
:
"Antigravity"
,
OtherPlatform
:
"Anthropic"
,
}
router
:=
setupAccountMixedChannelRouter
(
adminSvc
)
body
,
_
:=
json
.
Marshal
(
map
[
string
]
any
{
"account_ids"
:
[]
int64
{
1
,
2
,
3
},
"group_ids"
:
[]
int64
{
27
},
})
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/accounts/bulk-update"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusConflict
,
rec
.
Code
)
var
resp
map
[
string
]
any
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
"mixed_channel_warning"
,
resp
[
"error"
])
require
.
Contains
(
t
,
resp
[
"message"
],
"claude-max"
)
}
func
TestAccountHandlerBulkUpdateMixedChannelConfirmSkips
(
t
*
testing
.
T
)
{
adminSvc
:=
newStubAdminService
()
router
:=
setupAccountMixedChannelRouter
(
adminSvc
)
body
,
_
:=
json
.
Marshal
(
map
[
string
]
any
{
"account_ids"
:
[]
int64
{
1
,
2
},
"group_ids"
:
[]
int64
{
27
},
"confirm_mixed_channel_risk"
:
true
,
})
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/accounts/bulk-update"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
var
resp
map
[
string
]
any
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
float64
(
0
),
resp
[
"code"
])
data
,
ok
:=
resp
[
"data"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
float64
(
2
),
data
[
"success"
])
require
.
Equal
(
t
,
float64
(
0
),
data
[
"failed"
])
}
backend/internal/handler/admin/admin_service_stub_test.go
View file @
fd57fa49
...
@@ -10,22 +10,23 @@ import (
...
@@ -10,22 +10,23 @@ import (
)
)
type
stubAdminService
struct
{
type
stubAdminService
struct
{
users
[]
service
.
User
users
[]
service
.
User
apiKeys
[]
service
.
APIKey
apiKeys
[]
service
.
APIKey
groups
[]
service
.
Group
groups
[]
service
.
Group
accounts
[]
service
.
Account
accounts
[]
service
.
Account
proxies
[]
service
.
Proxy
proxies
[]
service
.
Proxy
proxyCounts
[]
service
.
ProxyWithAccountCount
proxyCounts
[]
service
.
ProxyWithAccountCount
redeems
[]
service
.
RedeemCode
redeems
[]
service
.
RedeemCode
createdAccounts
[]
*
service
.
CreateAccountInput
createdAccounts
[]
*
service
.
CreateAccountInput
createdProxies
[]
*
service
.
CreateProxyInput
createdProxies
[]
*
service
.
CreateProxyInput
updatedProxyIDs
[]
int64
updatedProxyIDs
[]
int64
updatedProxies
[]
*
service
.
UpdateProxyInput
updatedProxies
[]
*
service
.
UpdateProxyInput
testedProxyIDs
[]
int64
testedProxyIDs
[]
int64
createAccountErr
error
createAccountErr
error
updateAccountErr
error
updateAccountErr
error
checkMixedErr
error
bulkUpdateAccountErr
error
lastMixedCheck
struct
{
checkMixedErr
error
lastMixedCheck
struct
{
accountID
int64
accountID
int64
platform
string
platform
string
groupIDs
[]
int64
groupIDs
[]
int64
...
@@ -235,7 +236,10 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64,
...
@@ -235,7 +236,10 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64,
}
}
func
(
s
*
stubAdminService
)
BulkUpdateAccounts
(
ctx
context
.
Context
,
input
*
service
.
BulkUpdateAccountsInput
)
(
*
service
.
BulkUpdateAccountsResult
,
error
)
{
func
(
s
*
stubAdminService
)
BulkUpdateAccounts
(
ctx
context
.
Context
,
input
*
service
.
BulkUpdateAccountsInput
)
(
*
service
.
BulkUpdateAccountsResult
,
error
)
{
return
&
service
.
BulkUpdateAccountsResult
{
Success
:
1
,
Failed
:
0
,
SuccessIDs
:
[]
int64
{
1
}},
nil
if
s
.
bulkUpdateAccountErr
!=
nil
{
return
nil
,
s
.
bulkUpdateAccountErr
}
return
&
service
.
BulkUpdateAccountsResult
{
Success
:
len
(
input
.
AccountIDs
),
Failed
:
0
,
SuccessIDs
:
input
.
AccountIDs
},
nil
}
}
func
(
s
*
stubAdminService
)
CheckMixedChannelRisk
(
ctx
context
.
Context
,
currentAccountID
int64
,
currentAccountPlatform
string
,
groupIDs
[]
int64
)
error
{
func
(
s
*
stubAdminService
)
CheckMixedChannelRisk
(
ctx
context
.
Context
,
currentAccountID
int64
,
currentAccountPlatform
string
,
groupIDs
[]
int64
)
error
{
...
...
backend/internal/service/admin_service.go
View file @
fd57fa49
...
@@ -1539,30 +1539,31 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
...
@@ -1539,30 +1539,31 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
needMixedChannelCheck
:=
input
.
GroupIDs
!=
nil
&&
!
input
.
SkipMixedChannelCheck
needMixedChannelCheck
:=
input
.
GroupIDs
!=
nil
&&
!
input
.
SkipMixedChannelCheck
// 预加载账号平台信息(混合渠道检查
或 Sora 同步
需要)。
// 预加载账号平台信息(混合渠道检查需要)。
platformByID
:=
map
[
int64
]
string
{}
platformByID
:=
map
[
int64
]
string
{}
groupAccountsByID
:=
map
[
int64
][]
Account
{}
groupNameByID
:=
map
[
int64
]
string
{}
if
needMixedChannelCheck
{
if
needMixedChannelCheck
{
accounts
,
err
:=
s
.
accountRepo
.
GetByIDs
(
ctx
,
input
.
AccountIDs
)
accounts
,
err
:=
s
.
accountRepo
.
GetByIDs
(
ctx
,
input
.
AccountIDs
)
if
err
!=
nil
{
if
err
!=
nil
{
if
needMixedChannelCheck
{
return
nil
,
err
return
nil
,
err
}
}
for
_
,
account
:=
range
accounts
{
}
else
{
if
account
!=
nil
{
for
_
,
account
:=
range
accounts
{
platformByID
[
account
.
ID
]
=
account
.
Platform
if
account
!=
nil
{
platformByID
[
account
.
ID
]
=
account
.
Platform
}
}
}
}
}
}
loadedAccounts
,
loadedNames
,
err
:=
s
.
preloadMixedChannelRiskData
(
ctx
,
*
input
.
GroupIDs
)
// 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
if
err
!=
nil
{
if
needMixedChannelCheck
{
return
nil
,
err
for
_
,
accountID
:=
range
input
.
AccountIDs
{
platform
:=
platformByID
[
accountID
]
if
platform
==
""
{
continue
}
if
err
:=
s
.
checkMixedChannelRisk
(
ctx
,
accountID
,
platform
,
*
input
.
GroupIDs
);
err
!=
nil
{
return
nil
,
err
}
}
}
groupAccountsByID
=
loadedAccounts
groupNameByID
=
loadedNames
}
}
if
input
.
RateMultiplier
!=
nil
{
if
input
.
RateMultiplier
!=
nil
{
...
@@ -1606,34 +1607,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
...
@@ -1606,34 +1607,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
// Handle group bindings per account (requires individual operations).
// Handle group bindings per account (requires individual operations).
for
_
,
accountID
:=
range
input
.
AccountIDs
{
for
_
,
accountID
:=
range
input
.
AccountIDs
{
entry
:=
BulkUpdateAccountResult
{
AccountID
:
accountID
}
entry
:=
BulkUpdateAccountResult
{
AccountID
:
accountID
}
platform
:=
""
if
input
.
GroupIDs
!=
nil
{
if
input
.
GroupIDs
!=
nil
{
// 检查混合渠道风险(除非用户已确认)
if
!
input
.
SkipMixedChannelCheck
{
platform
=
platformByID
[
accountID
]
if
platform
==
""
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
if
err
!=
nil
{
entry
.
Success
=
false
entry
.
Error
=
err
.
Error
()
result
.
Failed
++
result
.
FailedIDs
=
append
(
result
.
FailedIDs
,
accountID
)
result
.
Results
=
append
(
result
.
Results
,
entry
)
continue
}
platform
=
account
.
Platform
}
if
err
:=
s
.
checkMixedChannelRiskWithPreloaded
(
accountID
,
platform
,
*
input
.
GroupIDs
,
groupAccountsByID
,
groupNameByID
);
err
!=
nil
{
entry
.
Success
=
false
entry
.
Error
=
err
.
Error
()
result
.
Failed
++
result
.
FailedIDs
=
append
(
result
.
FailedIDs
,
accountID
)
result
.
Results
=
append
(
result
.
Results
,
entry
)
continue
}
}
if
err
:=
s
.
accountRepo
.
BindGroups
(
ctx
,
accountID
,
*
input
.
GroupIDs
);
err
!=
nil
{
if
err
:=
s
.
accountRepo
.
BindGroups
(
ctx
,
accountID
,
*
input
.
GroupIDs
);
err
!=
nil
{
entry
.
Success
=
false
entry
.
Success
=
false
entry
.
Error
=
err
.
Error
()
entry
.
Error
=
err
.
Error
()
...
@@ -1642,9 +1617,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
...
@@ -1642,9 +1617,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
result
.
Results
=
append
(
result
.
Results
,
entry
)
result
.
Results
=
append
(
result
.
Results
,
entry
)
continue
continue
}
}
if
!
input
.
SkipMixedChannelCheck
&&
platform
!=
""
{
updateMixedChannelPreloadedAccounts
(
groupAccountsByID
,
*
input
.
GroupIDs
,
accountID
,
platform
)
}
}
}
entry
.
Success
=
true
entry
.
Success
=
true
...
@@ -2316,41 +2288,6 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
...
@@ -2316,41 +2288,6 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
return
nil
return
nil
}
}
func
(
s
*
adminServiceImpl
)
preloadMixedChannelRiskData
(
ctx
context
.
Context
,
groupIDs
[]
int64
)
(
map
[
int64
][]
Account
,
map
[
int64
]
string
,
error
)
{
accountsByGroup
:=
make
(
map
[
int64
][]
Account
)
groupNameByID
:=
make
(
map
[
int64
]
string
)
if
len
(
groupIDs
)
==
0
{
return
accountsByGroup
,
groupNameByID
,
nil
}
seen
:=
make
(
map
[
int64
]
struct
{},
len
(
groupIDs
))
for
_
,
groupID
:=
range
groupIDs
{
if
groupID
<=
0
{
continue
}
if
_
,
ok
:=
seen
[
groupID
];
ok
{
continue
}
seen
[
groupID
]
=
struct
{}{}
accounts
,
err
:=
s
.
accountRepo
.
ListByGroup
(
ctx
,
groupID
)
if
err
!=
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"get accounts in group %d: %w"
,
groupID
,
err
)
}
accountsByGroup
[
groupID
]
=
accounts
group
,
err
:=
s
.
groupRepo
.
GetByID
(
ctx
,
groupID
)
if
err
!=
nil
{
continue
}
if
group
!=
nil
{
groupNameByID
[
groupID
]
=
group
.
Name
}
}
return
accountsByGroup
,
groupNameByID
,
nil
}
func
(
s
*
adminServiceImpl
)
validateGroupIDsExist
(
ctx
context
.
Context
,
groupIDs
[]
int64
)
error
{
func
(
s
*
adminServiceImpl
)
validateGroupIDsExist
(
ctx
context
.
Context
,
groupIDs
[]
int64
)
error
{
if
len
(
groupIDs
)
==
0
{
if
len
(
groupIDs
)
==
0
{
return
nil
return
nil
...
@@ -2380,71 +2317,6 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [
...
@@ -2380,71 +2317,6 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [
return
nil
return
nil
}
}
func
(
s
*
adminServiceImpl
)
checkMixedChannelRiskWithPreloaded
(
currentAccountID
int64
,
currentAccountPlatform
string
,
groupIDs
[]
int64
,
accountsByGroup
map
[
int64
][]
Account
,
groupNameByID
map
[
int64
]
string
)
error
{
currentPlatform
:=
getAccountPlatform
(
currentAccountPlatform
)
if
currentPlatform
==
""
{
return
nil
}
for
_
,
groupID
:=
range
groupIDs
{
accounts
:=
accountsByGroup
[
groupID
]
for
_
,
account
:=
range
accounts
{
if
currentAccountID
>
0
&&
account
.
ID
==
currentAccountID
{
continue
}
otherPlatform
:=
getAccountPlatform
(
account
.
Platform
)
if
otherPlatform
==
""
{
continue
}
if
currentPlatform
!=
otherPlatform
{
groupName
:=
fmt
.
Sprintf
(
"Group %d"
,
groupID
)
if
name
:=
strings
.
TrimSpace
(
groupNameByID
[
groupID
]);
name
!=
""
{
groupName
=
name
}
return
&
MixedChannelError
{
GroupID
:
groupID
,
GroupName
:
groupName
,
CurrentPlatform
:
currentPlatform
,
OtherPlatform
:
otherPlatform
,
}
}
}
}
return
nil
}
func
updateMixedChannelPreloadedAccounts
(
accountsByGroup
map
[
int64
][]
Account
,
groupIDs
[]
int64
,
accountID
int64
,
platform
string
)
{
if
len
(
groupIDs
)
==
0
||
accountID
<=
0
||
platform
==
""
{
return
}
for
_
,
groupID
:=
range
groupIDs
{
if
groupID
<=
0
{
continue
}
accounts
:=
accountsByGroup
[
groupID
]
found
:=
false
for
i
:=
range
accounts
{
if
accounts
[
i
]
.
ID
!=
accountID
{
continue
}
accounts
[
i
]
.
Platform
=
platform
found
=
true
break
}
if
!
found
{
accounts
=
append
(
accounts
,
Account
{
ID
:
accountID
,
Platform
:
platform
,
})
}
accountsByGroup
[
groupID
]
=
accounts
}
}
// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
func
(
s
*
adminServiceImpl
)
CheckMixedChannelRisk
(
ctx
context
.
Context
,
currentAccountID
int64
,
currentAccountPlatform
string
,
groupIDs
[]
int64
)
error
{
func
(
s
*
adminServiceImpl
)
CheckMixedChannelRisk
(
ctx
context
.
Context
,
currentAccountID
int64
,
currentAccountPlatform
string
,
groupIDs
[]
int64
)
error
{
return
s
.
checkMixedChannelRisk
(
ctx
,
currentAccountID
,
currentAccountPlatform
,
groupIDs
)
return
s
.
checkMixedChannelRisk
(
ctx
,
currentAccountID
,
currentAccountPlatform
,
groupIDs
)
...
...
backend/internal/service/admin_service_bulk_update_test.go
View file @
fd57fa49
...
@@ -139,34 +139,34 @@ func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T)
...
@@ -139,34 +139,34 @@ func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T)
require
.
Contains
(
t
,
err
.
Error
(),
"group repository not configured"
)
require
.
Contains
(
t
,
err
.
Error
(),
"group repository not configured"
)
}
}
func
TestAdminService_BulkUpdateAccounts_MixedChannelCheckUsesUpdatedSnapshot
(
t
*
testing
.
T
)
{
// TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict verifies
// that the global pre-check detects a conflict with existing group members and returns an
// error before any DB write is performed.
func
TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict
(
t
*
testing
.
T
)
{
repo
:=
&
accountRepoStubForBulkUpdate
{
repo
:=
&
accountRepoStubForBulkUpdate
{
getByIDsAccounts
:
[]
*
Account
{
getByIDsAccounts
:
[]
*
Account
{
{
ID
:
1
,
Platform
:
PlatformAnthropic
},
{
ID
:
1
,
Platform
:
PlatformAntigravity
},
{
ID
:
2
,
Platform
:
PlatformAntigravity
},
},
},
// Group 10 already contains an Anthropic account.
listByGroupData
:
map
[
int64
][]
Account
{
listByGroupData
:
map
[
int64
][]
Account
{
10
:
{},
10
:
{
{
ID
:
99
,
Platform
:
PlatformAnthropic
}
},
},
},
}
}
svc
:=
&
adminServiceImpl
{
svc
:=
&
adminServiceImpl
{
accountRepo
:
repo
,
accountRepo
:
repo
,
groupRepo
:
&
groupRepoStubForAdmin
{
getByID
:
&
Group
{
ID
:
10
,
Name
:
"
目标分组
"
}},
groupRepo
:
&
groupRepoStubForAdmin
{
getByID
:
&
Group
{
ID
:
10
,
Name
:
"
target-group
"
}},
}
}
groupIDs
:=
[]
int64
{
10
}
groupIDs
:=
[]
int64
{
10
}
input
:=
&
BulkUpdateAccountsInput
{
input
:=
&
BulkUpdateAccountsInput
{
AccountIDs
:
[]
int64
{
1
,
2
},
AccountIDs
:
[]
int64
{
1
},
GroupIDs
:
&
groupIDs
,
GroupIDs
:
&
groupIDs
,
}
}
result
,
err
:=
svc
.
BulkUpdateAccounts
(
context
.
Background
(),
input
)
result
,
err
:=
svc
.
BulkUpdateAccounts
(
context
.
Background
(),
input
)
require
.
NoError
(
t
,
err
)
require
.
Nil
(
t
,
result
)
require
.
Equal
(
t
,
1
,
result
.
Success
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
1
,
result
.
Failed
)
require
.
Contains
(
t
,
err
.
Error
(),
"mixed channel"
)
require
.
ElementsMatch
(
t
,
[]
int64
{
1
},
result
.
SuccessIDs
)
// No BindGroups should have been called since the check runs before any write.
require
.
ElementsMatch
(
t
,
[]
int64
{
2
},
result
.
FailedIDs
)
require
.
Empty
(
t
,
repo
.
bindGroupsCalls
)
require
.
Len
(
t
,
result
.
Results
,
2
)
require
.
Contains
(
t
,
result
.
Results
[
1
]
.
Error
,
"mixed channel"
)
require
.
Equal
(
t
,
[]
int64
{
1
},
repo
.
bindGroupsCalls
)
}
}
frontend/src/api/client.ts
View file @
fd57fa49
...
@@ -267,6 +267,7 @@ apiClient.interceptors.response.use(
...
@@ -267,6 +267,7 @@ apiClient.interceptors.response.use(
return
Promise
.
reject
({
return
Promise
.
reject
({
status
,
status
,
code
:
apiData
.
code
,
code
:
apiData
.
code
,
error
:
apiData
.
error
,
message
:
apiData
.
message
||
apiData
.
detail
||
error
.
message
message
:
apiData
.
message
||
apiData
.
detail
||
error
.
message
})
})
}
}
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
fd57fa49
...
@@ -756,6 +756,17 @@
...
@@ -756,6 +756,17 @@
<
/div
>
<
/div
>
<
/template
>
<
/template
>
<
/BaseDialog
>
<
/BaseDialog
>
<
ConfirmDialog
:
show
=
"
showMixedChannelWarning
"
:
title
=
"
t('admin.accounts.mixedChannelWarningTitle')
"
:
message
=
"
mixedChannelWarningMessage
"
:
confirm
-
text
=
"
t('common.confirm')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
handleMixedChannelConfirm
"
@
cancel
=
"
handleMixedChannelCancel
"
/>
<
/template
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
...
@@ -765,6 +776,7 @@ import { useAppStore } from '@/stores/app'
...
@@ -765,6 +776,7 @@ import { useAppStore } from '@/stores/app'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Proxy
as
ProxyConfig
,
AdminGroup
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
type
{
Proxy
as
ProxyConfig
,
AdminGroup
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
...
@@ -844,6 +856,9 @@ const enableRpmLimit = ref(false)
...
@@ -844,6 +856,9 @@ const enableRpmLimit = ref(false)
// State - field values
// State - field values
const
submitting
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
showMixedChannelWarning
=
ref
(
false
)
const
mixedChannelWarningMessage
=
ref
(
''
)
const
pendingUpdatesForConfirm
=
ref
<
Record
<
string
,
unknown
>
|
null
>
(
null
)
const
baseUrl
=
ref
(
''
)
const
baseUrl
=
ref
(
''
)
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
const
allowedModels
=
ref
<
string
[]
>
([])
...
@@ -1237,10 +1252,46 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
...
@@ -1237,10 +1252,46 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
return
Object
.
keys
(
updates
).
length
>
0
?
updates
:
null
return
Object
.
keys
(
updates
).
length
>
0
?
updates
:
null
}
}
const
mixedChannelConfirmed
=
ref
(
false
)
// 是否需要预检查:改了分组 + 全是单一的 antigravity 或 anthropic 平台
// 多平台混合的情况由 submitBulkUpdate 的 409 catch 兜底
const
canPreCheck
=
()
=>
enableGroups
.
value
&&
groupIds
.
value
.
length
>
0
&&
props
.
selectedPlatforms
.
length
===
1
&&
(
props
.
selectedPlatforms
[
0
]
===
'
antigravity
'
||
props
.
selectedPlatforms
[
0
]
===
'
anthropic
'
)
const
handleClose
=
()
=>
{
const
handleClose
=
()
=>
{
showMixedChannelWarning
.
value
=
false
mixedChannelWarningMessage
.
value
=
''
pendingUpdatesForConfirm
.
value
=
null
mixedChannelConfirmed
.
value
=
false
emit
(
'
close
'
)
emit
(
'
close
'
)
}
}
// 预检查:提交前调接口检测,有风险就弹窗阻止,返回 false 表示需要用户确认
const
preCheckMixedChannelRisk
=
async
(
built
:
Record
<
string
,
unknown
>
):
Promise
<
boolean
>
=>
{
if
(
!
canPreCheck
())
return
true
if
(
mixedChannelConfirmed
.
value
)
return
true
try
{
const
result
=
await
adminAPI
.
accounts
.
checkMixedChannelRisk
({
platform
:
props
.
selectedPlatforms
[
0
],
group_ids
:
groupIds
.
value
}
)
if
(
!
result
.
has_risk
)
return
true
pendingUpdatesForConfirm
.
value
=
built
mixedChannelWarningMessage
.
value
=
result
.
message
||
t
(
'
admin.accounts.bulkEdit.failed
'
)
showMixedChannelWarning
.
value
=
true
return
false
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
message
||
t
(
'
admin.accounts.bulkEdit.failed
'
))
return
false
}
}
const
handleSubmit
=
async
()
=>
{
const
handleSubmit
=
async
()
=>
{
if
(
props
.
accountIds
.
length
===
0
)
{
if
(
props
.
accountIds
.
length
===
0
)
{
appStore
.
showError
(
t
(
'
admin.accounts.bulkEdit.noSelection
'
))
appStore
.
showError
(
t
(
'
admin.accounts.bulkEdit.noSelection
'
))
...
@@ -1265,12 +1316,24 @@ const handleSubmit = async () => {
...
@@ -1265,12 +1316,24 @@ const handleSubmit = async () => {
return
return
}
}
const
updates
=
buildUpdatePayload
()
const
built
=
buildUpdatePayload
()
if
(
!
updates
)
{
if
(
!
built
)
{
appStore
.
showError
(
t
(
'
admin.accounts.bulkEdit.noFieldsSelected
'
))
appStore
.
showError
(
t
(
'
admin.accounts.bulkEdit.noFieldsSelected
'
))
return
return
}
}
const
canContinue
=
await
preCheckMixedChannelRisk
(
built
)
if
(
!
canContinue
)
return
await
submitBulkUpdate
(
built
)
}
const
submitBulkUpdate
=
async
(
baseUpdates
:
Record
<
string
,
unknown
>
)
=>
{
// 无论是预检查确认还是 409 兜底确认,只要 mixedChannelConfirmed 为 true 就带上 flag
const
updates
=
mixedChannelConfirmed
.
value
?
{
...
baseUpdates
,
confirm_mixed_channel_risk
:
true
}
:
baseUpdates
submitting
.
value
=
true
submitting
.
value
=
true
try
{
try
{
...
@@ -1287,17 +1350,38 @@ const handleSubmit = async () => {
...
@@ -1287,17 +1350,38 @@ const handleSubmit = async () => {
}
}
if
(
success
>
0
)
{
if
(
success
>
0
)
{
pendingUpdatesForConfirm
.
value
=
null
emit
(
'
updated
'
)
emit
(
'
updated
'
)
handleClose
()
handleClose
()
}
}
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.bulkEdit.failed
'
))
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
console
.
error
(
'
Error bulk updating accounts:
'
,
error
)
if
(
error
.
status
===
409
&&
error
.
error
===
'
mixed_channel_warning
'
)
{
pendingUpdatesForConfirm
.
value
=
baseUpdates
mixedChannelWarningMessage
.
value
=
error
.
message
showMixedChannelWarning
.
value
=
true
}
else
{
appStore
.
showError
(
error
.
message
||
t
(
'
admin.accounts.bulkEdit.failed
'
))
console
.
error
(
'
Error bulk updating accounts:
'
,
error
)
}
}
finally
{
}
finally
{
submitting
.
value
=
false
submitting
.
value
=
false
}
}
}
}
const
handleMixedChannelConfirm
=
async
()
=>
{
showMixedChannelWarning
.
value
=
false
mixedChannelConfirmed
.
value
=
true
if
(
pendingUpdatesForConfirm
.
value
)
{
await
submitBulkUpdate
(
pendingUpdatesForConfirm
.
value
)
}
}
const
handleMixedChannelCancel
=
()
=>
{
showMixedChannelWarning
.
value
=
false
pendingUpdatesForConfirm
.
value
=
null
}
// Reset form when modal closes
// Reset form when modal closes
watch
(
watch
(
()
=>
props
.
show
,
()
=>
props
.
show
,
...
@@ -1330,10 +1414,12 @@ watch(
...
@@ -1330,10 +1414,12 @@ watch(
rateMultiplier
.
value
=
1
rateMultiplier
.
value
=
1
status
.
value
=
'
active
'
status
.
value
=
'
active
'
groupIds
.
value
=
[]
groupIds
.
value
=
[]
rpmLimitEnabled
.
value
=
false
bulkBaseRpm
.
value
=
null
// Reset mixed channel warning state
bulkRpmStrategy
.
value
=
'
tiered
'
showMixedChannelWarning
.
value
=
false
bulkRpmStickyBuffer
.
value
=
null
mixedChannelWarningMessage
.
value
=
''
pendingUpdatesForConfirm
.
value
=
null
mixedChannelConfirmed
.
value
=
false
}
}
}
}
)
)
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
fd57fa49
...
@@ -1961,7 +1961,7 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<v
...
@@ -1961,7 +1961,7 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<v
}
)
}
)
return
false
return
false
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
appStore
.
showError
(
error
.
message
||
t
(
'
admin.accounts.failedToUpdate
'
))
return
false
return
false
}
}
}
}
...
@@ -1984,9 +1984,9 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
...
@@ -1984,9 +1984,9 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
emit
(
'
updated
'
,
updatedAccount
)
emit
(
'
updated
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
if
(
error
.
response
?.
status
===
409
&&
error
.
response
?.
data
?.
error
===
'
mixed_channel_warning
'
&&
needsMixedChannelCheck
())
{
if
(
error
.
status
===
409
&&
error
.
error
===
'
mixed_channel_warning
'
&&
needsMixedChannelCheck
())
{
openMixedChannelDialog
({
openMixedChannelDialog
({
message
:
error
.
response
?.
data
?.
message
,
message
:
error
.
message
,
onConfirm
:
async
()
=>
{
onConfirm
:
async
()
=>
{
antigravityMixedChannelConfirmed
.
value
=
true
antigravityMixedChannelConfirmed
.
value
=
true
await
submitUpdateAccount
(
accountID
,
updatePayload
)
await
submitUpdateAccount
(
accountID
,
updatePayload
)
...
@@ -1994,7 +1994,7 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
...
@@ -1994,7 +1994,7 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
}
)
}
)
return
return
}
}
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
appStore
.
showError
(
error
.
message
||
t
(
'
admin.accounts.failedToUpdate
'
))
}
finally
{
}
finally
{
submitting
.
value
=
false
submitting
.
value
=
false
}
}
...
@@ -2245,7 +2245,7 @@ const handleSubmit = async () => {
...
@@ -2245,7 +2245,7 @@ const handleSubmit = async () => {
await
submitUpdateAccount
(
accountID
,
updatePayload
)
await
submitUpdateAccount
(
accountID
,
updatePayload
)
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
appStore
.
showError
(
error
.
message
||
t
(
'
admin.accounts.failedToUpdate
'
))
}
}
}
}
...
...
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