feat: add mixed-channel precheck API for account-group binding
Add a dedicated CheckMixedChannel endpoint that allows the frontend to pre-validate mixed channel risk before submitting create/update requests. This improves UX by showing warnings earlier in the flow instead of only after form submission. Backend changes: - Add CheckMixedChannelRequest struct and CheckMixedChannel handler - Register POST /check-mixed-channel route - Expose CheckMixedChannelRisk as public method on AdminService - Simplify Create/Update 409 responses (remove details/require_confirmation) - Add comprehensive handler tests and stub methods Frontend changes: - Add checkMixedChannelRisk API function and TypeScript types - Refactor CreateAccountModal to precheck before step transition and submission - Refactor EditAccountModal to precheck before update submission - Replace pendingPayload pattern with action-based dialog flow
This commit is contained in:
@@ -139,6 +139,13 @@ type BulkUpdateAccountsRequest struct {
|
|||||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckMixedChannelRequest represents check mixed channel risk request
|
||||||
|
type CheckMixedChannelRequest struct {
|
||||||
|
Platform string `json:"platform" binding:"required"`
|
||||||
|
GroupIDs []int64 `json:"group_ids"`
|
||||||
|
AccountID *int64 `json:"account_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||||
type AccountWithConcurrency struct {
|
type AccountWithConcurrency struct {
|
||||||
*dto.Account
|
*dto.Account
|
||||||
@@ -389,6 +396,50 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
|
|||||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckMixedChannel handles checking mixed channel risk for account-group binding.
|
||||||
|
// POST /api/v1/admin/accounts/check-mixed-channel
|
||||||
|
func (h *AccountHandler) CheckMixedChannel(c *gin.Context) {
|
||||||
|
var req CheckMixedChannelRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.GroupIDs) == 0 {
|
||||||
|
response.Success(c, gin.H{"has_risk": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := int64(0)
|
||||||
|
if req.AccountID != nil {
|
||||||
|
accountID = *req.AccountID
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.adminService.CheckMixedChannelRisk(c.Request.Context(), accountID, req.Platform, req.GroupIDs)
|
||||||
|
if err != nil {
|
||||||
|
var mixedErr *service.MixedChannelError
|
||||||
|
if errors.As(err, &mixedErr) {
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"has_risk": true,
|
||||||
|
"error": "mixed_channel_warning",
|
||||||
|
"message": mixedErr.Error(),
|
||||||
|
"details": gin.H{
|
||||||
|
"group_id": mixedErr.GroupID,
|
||||||
|
"group_name": mixedErr.GroupName,
|
||||||
|
"current_platform": mixedErr.CurrentPlatform,
|
||||||
|
"other_platform": mixedErr.OtherPlatform,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{"has_risk": false})
|
||||||
|
}
|
||||||
|
|
||||||
// Create handles creating a new account
|
// Create handles creating a new account
|
||||||
// POST /api/v1/admin/accounts
|
// POST /api/v1/admin/accounts
|
||||||
func (h *AccountHandler) Create(c *gin.Context) {
|
func (h *AccountHandler) Create(c *gin.Context) {
|
||||||
@@ -431,17 +482,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
|||||||
// 检查是否为混合渠道错误
|
// 检查是否为混合渠道错误
|
||||||
var mixedErr *service.MixedChannelError
|
var mixedErr *service.MixedChannelError
|
||||||
if errors.As(err, &mixedErr) {
|
if errors.As(err, &mixedErr) {
|
||||||
// 返回特殊错误码要求确认
|
// 创建接口仅返回最小必要字段,详细信息由专门检查接口提供
|
||||||
c.JSON(409, gin.H{
|
c.JSON(409, gin.H{
|
||||||
"error": "mixed_channel_warning",
|
"error": "mixed_channel_warning",
|
||||||
"message": mixedErr.Error(),
|
"message": mixedErr.Error(),
|
||||||
"details": gin.H{
|
|
||||||
"group_id": mixedErr.GroupID,
|
|
||||||
"group_name": mixedErr.GroupName,
|
|
||||||
"current_platform": mixedErr.CurrentPlatform,
|
|
||||||
"other_platform": mixedErr.OtherPlatform,
|
|
||||||
},
|
|
||||||
"require_confirmation": true,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -501,17 +545,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
|||||||
// 检查是否为混合渠道错误
|
// 检查是否为混合渠道错误
|
||||||
var mixedErr *service.MixedChannelError
|
var mixedErr *service.MixedChannelError
|
||||||
if errors.As(err, &mixedErr) {
|
if errors.As(err, &mixedErr) {
|
||||||
// 返回特殊错误码要求确认
|
// 更新接口仅返回最小必要字段,详细信息由专门检查接口提供
|
||||||
c.JSON(409, gin.H{
|
c.JSON(409, gin.H{
|
||||||
"error": "mixed_channel_warning",
|
"error": "mixed_channel_warning",
|
||||||
"message": mixedErr.Error(),
|
"message": mixedErr.Error(),
|
||||||
"details": gin.H{
|
|
||||||
"group_id": mixedErr.GroupID,
|
|
||||||
"group_name": mixedErr.GroupName,
|
|
||||||
"current_platform": mixedErr.CurrentPlatform,
|
|
||||||
"other_platform": mixedErr.OtherPlatform,
|
|
||||||
},
|
|
||||||
"require_confirmation": true,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel)
|
||||||
|
router.POST("/api/v1/admin/accounts", accountHandler.Create)
|
||||||
|
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountHandlerCheckMixedChannelNoRisk(t *testing.T) {
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
router := setupAccountMixedChannelRouter(adminSvc)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"platform": "antigravity",
|
||||||
|
"group_ids": []int64{27},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/check-mixed-channel", 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, false, data["has_risk"])
|
||||||
|
require.Equal(t, int64(0), adminSvc.lastMixedCheck.accountID)
|
||||||
|
require.Equal(t, "antigravity", adminSvc.lastMixedCheck.platform)
|
||||||
|
require.Equal(t, []int64{27}, adminSvc.lastMixedCheck.groupIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountHandlerCheckMixedChannelWithRisk(t *testing.T) {
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
adminSvc.checkMixedErr = &service.MixedChannelError{
|
||||||
|
GroupID: 27,
|
||||||
|
GroupName: "claude-max",
|
||||||
|
CurrentPlatform: "Antigravity",
|
||||||
|
OtherPlatform: "Anthropic",
|
||||||
|
}
|
||||||
|
router := setupAccountMixedChannelRouter(adminSvc)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"platform": "antigravity",
|
||||||
|
"group_ids": []int64{27},
|
||||||
|
"account_id": 99,
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/check-mixed-channel", 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, true, data["has_risk"])
|
||||||
|
require.Equal(t, "mixed_channel_warning", data["error"])
|
||||||
|
details, ok := data["details"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, float64(27), details["group_id"])
|
||||||
|
require.Equal(t, "claude-max", details["group_name"])
|
||||||
|
require.Equal(t, "Antigravity", details["current_platform"])
|
||||||
|
require.Equal(t, "Anthropic", details["other_platform"])
|
||||||
|
require.Equal(t, int64(99), adminSvc.lastMixedCheck.accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountHandlerCreateMixedChannelConflictSimplifiedResponse(t *testing.T) {
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
adminSvc.createAccountErr = &service.MixedChannelError{
|
||||||
|
GroupID: 27,
|
||||||
|
GroupName: "claude-max",
|
||||||
|
CurrentPlatform: "Antigravity",
|
||||||
|
OtherPlatform: "Anthropic",
|
||||||
|
}
|
||||||
|
router := setupAccountMixedChannelRouter(adminSvc)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "ag-oauth-1",
|
||||||
|
"platform": "antigravity",
|
||||||
|
"type": "oauth",
|
||||||
|
"credentials": map[string]any{"refresh_token": "rt"},
|
||||||
|
"group_ids": []int64{27},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts", 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"], "mixed_channel_warning")
|
||||||
|
_, hasDetails := resp["details"]
|
||||||
|
_, hasRequireConfirmation := resp["require_confirmation"]
|
||||||
|
require.False(t, hasDetails)
|
||||||
|
require.False(t, hasRequireConfirmation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T) {
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
adminSvc.updateAccountErr = &service.MixedChannelError{
|
||||||
|
GroupID: 27,
|
||||||
|
GroupName: "claude-max",
|
||||||
|
CurrentPlatform: "Antigravity",
|
||||||
|
OtherPlatform: "Anthropic",
|
||||||
|
}
|
||||||
|
router := setupAccountMixedChannelRouter(adminSvc)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"group_ids": []int64{27},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/3", 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"], "mixed_channel_warning")
|
||||||
|
_, hasDetails := resp["details"]
|
||||||
|
_, hasRequireConfirmation := resp["require_confirmation"]
|
||||||
|
require.False(t, hasDetails)
|
||||||
|
require.False(t, hasRequireConfirmation)
|
||||||
|
}
|
||||||
@@ -10,19 +10,27 @@ 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
|
||||||
mu sync.Mutex
|
createAccountErr error
|
||||||
|
updateAccountErr error
|
||||||
|
checkMixedErr error
|
||||||
|
lastMixedCheck struct {
|
||||||
|
accountID int64
|
||||||
|
platform string
|
||||||
|
groupIDs []int64
|
||||||
|
}
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStubAdminService() *stubAdminService {
|
func newStubAdminService() *stubAdminService {
|
||||||
@@ -188,11 +196,17 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.createdAccounts = append(s.createdAccounts, input)
|
s.createdAccounts = append(s.createdAccounts, input)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if s.createAccountErr != nil {
|
||||||
|
return nil, s.createAccountErr
|
||||||
|
}
|
||||||
account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive}
|
account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive}
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||||
|
if s.updateAccountErr != nil {
|
||||||
|
return nil, s.updateAccountErr
|
||||||
|
}
|
||||||
account := service.Account{ID: id, Name: input.Name, Status: service.StatusActive}
|
account := service.Account{ID: id, Name: input.Name, Status: service.StatusActive}
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
@@ -224,6 +238,13 @@ func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *servic
|
|||||||
return &service.BulkUpdateAccountsResult{Success: 1, Failed: 0, SuccessIDs: []int64{1}}, nil
|
return &service.BulkUpdateAccountsResult{Success: 1, Failed: 0, SuccessIDs: []int64{1}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubAdminService) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
|
||||||
|
s.lastMixedCheck.accountID = currentAccountID
|
||||||
|
s.lastMixedCheck.platform = currentAccountPlatform
|
||||||
|
s.lastMixedCheck.groupIDs = append([]int64(nil), groupIDs...)
|
||||||
|
return s.checkMixedErr
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.Proxy, int64, error) {
|
func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.Proxy, int64, error) {
|
||||||
search = strings.TrimSpace(strings.ToLower(search))
|
search = strings.TrimSpace(strings.ToLower(search))
|
||||||
filtered := make([]service.Proxy, 0, len(s.proxies))
|
filtered := make([]service.Proxy, 0, len(s.proxies))
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
accounts.GET("", h.Admin.Account.List)
|
accounts.GET("", h.Admin.Account.List)
|
||||||
accounts.GET("/:id", h.Admin.Account.GetByID)
|
accounts.GET("/:id", h.Admin.Account.GetByID)
|
||||||
accounts.POST("", h.Admin.Account.Create)
|
accounts.POST("", h.Admin.Account.Create)
|
||||||
|
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
|
||||||
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
||||||
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
|
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
|
||||||
accounts.PUT("/:id", h.Admin.Account.Update)
|
accounts.PUT("/:id", h.Admin.Account.Update)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type AdminService interface {
|
|||||||
SetAccountError(ctx context.Context, id int64, errorMsg string) error
|
SetAccountError(ctx context.Context, id int64, errorMsg string) error
|
||||||
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
|
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
|
||||||
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
||||||
|
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
|
||||||
|
|
||||||
// Proxy management
|
// Proxy management
|
||||||
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]Proxy, int64, error)
|
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]Proxy, int64, error)
|
||||||
@@ -2114,6 +2115,11 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) {
|
func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) {
|
||||||
if s.proxyLatencyCache == nil || len(proxies) == 0 {
|
if s.proxyLatencyCache == nil || len(proxies) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import type {
|
|||||||
AccountUsageStatsResponse,
|
AccountUsageStatsResponse,
|
||||||
TempUnschedulableStatus,
|
TempUnschedulableStatus,
|
||||||
AdminDataPayload,
|
AdminDataPayload,
|
||||||
AdminDataImportResult
|
AdminDataImportResult,
|
||||||
|
CheckMixedChannelRequest,
|
||||||
|
CheckMixedChannelResponse
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +135,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check mixed-channel risk for account-group binding.
|
||||||
|
*/
|
||||||
|
export async function checkMixedChannelRisk(
|
||||||
|
payload: CheckMixedChannelRequest
|
||||||
|
): Promise<CheckMixedChannelResponse> {
|
||||||
|
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete account
|
* Delete account
|
||||||
* @param id - Account ID
|
* @param id - Account ID
|
||||||
@@ -535,6 +547,7 @@ export const accountsAPI = {
|
|||||||
getById,
|
getById,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
|
checkMixedChannelRisk,
|
||||||
delete: deleteAccount,
|
delete: deleteAccount,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
testAccount,
|
testAccount,
|
||||||
|
|||||||
@@ -2157,7 +2157,7 @@
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showMixedChannelWarning"
|
:show="showMixedChannelWarning"
|
||||||
:title="t('admin.accounts.mixedChannelWarningTitle')"
|
:title="t('admin.accounts.mixedChannelWarningTitle')"
|
||||||
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''"
|
:message="mixedChannelWarningMessageText"
|
||||||
:confirm-text="t('common.confirm')"
|
:confirm-text="t('common.confirm')"
|
||||||
:cancel-text="t('common.cancel')"
|
:cancel-text="t('common.cancel')"
|
||||||
:danger="true"
|
:danger="true"
|
||||||
@@ -2189,7 +2189,14 @@ import {
|
|||||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||||
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
import type {
|
||||||
|
Proxy,
|
||||||
|
AdminGroup,
|
||||||
|
AccountPlatform,
|
||||||
|
AccountType,
|
||||||
|
CheckMixedChannelResponse,
|
||||||
|
CreateAccountRequest
|
||||||
|
} from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
@@ -2338,10 +2345,13 @@ const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>
|
|||||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||||
const geminiAIStudioOAuthEnabled = ref(false)
|
const geminiAIStudioOAuthEnabled = ref(false)
|
||||||
|
|
||||||
// Mixed channel warning dialog state
|
|
||||||
const showMixedChannelWarning = ref(false)
|
const showMixedChannelWarning = ref(false)
|
||||||
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null)
|
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
|
||||||
const pendingCreatePayload = ref<any>(null)
|
null
|
||||||
|
)
|
||||||
|
const mixedChannelWarningRawMessage = ref('')
|
||||||
|
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
|
||||||
|
const antigravityMixedChannelConfirmed = ref(false)
|
||||||
const showAdvancedOAuth = ref(false)
|
const showAdvancedOAuth = ref(false)
|
||||||
const showGeminiHelpDialog = ref(false)
|
const showGeminiHelpDialog = ref(false)
|
||||||
|
|
||||||
@@ -2379,6 +2389,13 @@ const isOpenAIModelRestrictionDisabled = computed(() =>
|
|||||||
form.platform === 'openai' && openaiPassthroughEnabled.value
|
form.platform === 'openai' && openaiPassthroughEnabled.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mixedChannelWarningMessageText = computed(() => {
|
||||||
|
if (mixedChannelWarningDetails.value) {
|
||||||
|
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
|
||||||
|
}
|
||||||
|
return mixedChannelWarningRawMessage.value
|
||||||
|
})
|
||||||
|
|
||||||
const geminiQuotaDocs = {
|
const geminiQuotaDocs = {
|
||||||
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
|
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
|
||||||
aiStudio: 'https://ai.google.dev/pricing',
|
aiStudio: 'https://ai.google.dev/pricing',
|
||||||
@@ -2795,6 +2812,105 @@ const splitTempUnschedKeywords = (value: string) => {
|
|||||||
.filter((item) => item.length > 0)
|
.filter((item) => item.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsMixedChannelCheck = (platform: AccountPlatform) => platform === 'antigravity' || platform === 'anthropic'
|
||||||
|
|
||||||
|
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
|
||||||
|
const details = resp?.details
|
||||||
|
if (!details) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
groupName: details.group_name || 'Unknown',
|
||||||
|
currentPlatform: details.current_platform || 'Unknown',
|
||||||
|
otherPlatform: details.other_platform || 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMixedChannelDialog = () => {
|
||||||
|
showMixedChannelWarning.value = false
|
||||||
|
mixedChannelWarningDetails.value = null
|
||||||
|
mixedChannelWarningRawMessage.value = ''
|
||||||
|
mixedChannelWarningAction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMixedChannelDialog = (opts: {
|
||||||
|
response?: CheckMixedChannelResponse
|
||||||
|
message?: string
|
||||||
|
onConfirm: () => Promise<void>
|
||||||
|
}) => {
|
||||||
|
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
|
||||||
|
mixedChannelWarningRawMessage.value =
|
||||||
|
opts.message || opts.response?.message || t('admin.accounts.failedToCreate')
|
||||||
|
mixedChannelWarningAction.value = opts.onConfirm
|
||||||
|
showMixedChannelWarning.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAntigravityConfirmFlag = (payload: CreateAccountRequest): CreateAccountRequest => {
|
||||||
|
if (needsMixedChannelCheck(payload.platform) && antigravityMixedChannelConfirmed.value) {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
confirm_mixed_channel_risk: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cloned = { ...payload }
|
||||||
|
delete cloned.confirm_mixed_channel_risk
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
|
||||||
|
if (!needsMixedChannelCheck(form.platform)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (antigravityMixedChannelConfirmed.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||||||
|
platform: form.platform,
|
||||||
|
group_ids: form.group_ids
|
||||||
|
})
|
||||||
|
if (!result.has_risk) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
openMixedChannelDialog({
|
||||||
|
response: result,
|
||||||
|
onConfirm: async () => {
|
||||||
|
antigravityMixedChannelConfirmed.value = true
|
||||||
|
await onConfirm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreateAccount = async (payload: CreateAccountRequest) => {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
|
||||||
|
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||||
|
emit('created')
|
||||||
|
handleClose()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck(form.platform)) {
|
||||||
|
openMixedChannelDialog({
|
||||||
|
message: error.response?.data?.message,
|
||||||
|
onConfirm: async () => {
|
||||||
|
antigravityMixedChannelConfirmed.value = true
|
||||||
|
await submitCreateAccount(payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
step.value = 1
|
step.value = 1
|
||||||
@@ -2856,9 +2972,13 @@ const resetForm = () => {
|
|||||||
geminiOAuth.resetState()
|
geminiOAuth.resetState()
|
||||||
antigravityOAuth.resetState()
|
antigravityOAuth.resetState()
|
||||||
oauthFlowRef.value?.reset()
|
oauthFlowRef.value?.reset()
|
||||||
|
antigravityMixedChannelConfirmed.value = false
|
||||||
|
clearMixedChannelDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
antigravityMixedChannelConfirmed.value = false
|
||||||
|
clearMixedChannelDialog()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2917,56 +3037,34 @@ const buildSoraExtra = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create account with mixed channel warning handling
|
// Helper function to create account with mixed channel warning handling
|
||||||
const doCreateAccount = async (payload: any) => {
|
const doCreateAccount = async (payload: CreateAccountRequest) => {
|
||||||
|
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
|
||||||
|
await submitCreateAccount(payload)
|
||||||
|
})
|
||||||
|
if (!canContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await submitCreateAccount(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mixed channel warning confirmation
|
||||||
|
const handleMixedChannelConfirm = async () => {
|
||||||
|
const action = mixedChannelWarningAction.value
|
||||||
|
if (!action) {
|
||||||
|
clearMixedChannelDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearMixedChannelDialog()
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await adminAPI.accounts.create(payload)
|
await action()
|
||||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
|
||||||
emit('created')
|
|
||||||
handleClose()
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle 409 mixed_channel_warning - show confirmation dialog
|
|
||||||
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') {
|
|
||||||
const details = error.response.data.details || {}
|
|
||||||
mixedChannelWarningDetails.value = {
|
|
||||||
groupName: details.group_name || 'Unknown',
|
|
||||||
currentPlatform: details.current_platform || 'Unknown',
|
|
||||||
otherPlatform: details.other_platform || 'Unknown'
|
|
||||||
}
|
|
||||||
pendingCreatePayload.value = payload
|
|
||||||
showMixedChannelWarning.value = true
|
|
||||||
} else {
|
|
||||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle mixed channel warning confirmation
|
|
||||||
const handleMixedChannelConfirm = async () => {
|
|
||||||
showMixedChannelWarning.value = false
|
|
||||||
if (pendingCreatePayload.value) {
|
|
||||||
pendingCreatePayload.value.confirm_mixed_channel_risk = true
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await adminAPI.accounts.create(pendingCreatePayload.value)
|
|
||||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
|
||||||
emit('created')
|
|
||||||
handleClose()
|
|
||||||
} catch (error: any) {
|
|
||||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
pendingCreatePayload.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMixedChannelCancel = () => {
|
const handleMixedChannelCancel = () => {
|
||||||
showMixedChannelWarning.value = false
|
clearMixedChannelDialog()
|
||||||
pendingCreatePayload.value = null
|
|
||||||
mixedChannelWarningDetails.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -2976,6 +3074,12 @@ const handleSubmit = async () => {
|
|||||||
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
|
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
|
||||||
|
step.value = 2
|
||||||
|
})
|
||||||
|
if (!canContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
step.value = 2
|
step.value = 2
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3132,7 +3236,7 @@ const createAccountAndFinish = async (
|
|||||||
if (!applyTempUnschedConfig(credentials)) {
|
if (!applyTempUnschedConfig(credentials)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await adminAPI.accounts.create({
|
await doCreateAccount({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
platform,
|
platform,
|
||||||
@@ -3147,9 +3251,6 @@ const createAccountAndFinish = async (
|
|||||||
expires_at: form.expires_at,
|
expires_at: form.expires_at,
|
||||||
auto_pause_on_expired: autoPauseOnExpired.value
|
auto_pause_on_expired: autoPauseOnExpired.value
|
||||||
})
|
})
|
||||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
|
||||||
emit('created')
|
|
||||||
handleClose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI OAuth 授权码兑换
|
// OpenAI OAuth 授权码兑换
|
||||||
@@ -3497,7 +3598,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
|
|||||||
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
|
||||||
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
|
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
|
||||||
await adminAPI.accounts.create({
|
const createPayload = withAntigravityConfirmFlag({
|
||||||
name: accountName,
|
name: accountName,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
platform: 'antigravity',
|
platform: 'antigravity',
|
||||||
@@ -3512,6 +3613,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
|
|||||||
expires_at: form.expires_at,
|
expires_at: form.expires_at,
|
||||||
auto_pause_on_expired: autoPauseOnExpired.value
|
auto_pause_on_expired: autoPauseOnExpired.value
|
||||||
})
|
})
|
||||||
|
await adminAPI.accounts.create(createPayload)
|
||||||
successCount++
|
successCount++
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
failedCount++
|
failedCount++
|
||||||
|
|||||||
@@ -1139,7 +1139,7 @@
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showMixedChannelWarning"
|
:show="showMixedChannelWarning"
|
||||||
:title="t('admin.accounts.mixedChannelWarningTitle')"
|
:title="t('admin.accounts.mixedChannelWarningTitle')"
|
||||||
:message="mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''"
|
:message="mixedChannelWarningMessageText"
|
||||||
:confirm-text="t('common.confirm')"
|
:confirm-text="t('common.confirm')"
|
||||||
:cancel-text="t('common.cancel')"
|
:cancel-text="t('common.cancel')"
|
||||||
:danger="true"
|
:danger="true"
|
||||||
@@ -1154,7 +1154,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
@@ -1234,10 +1234,13 @@ const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-mod
|
|||||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
||||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
||||||
|
|
||||||
// Mixed channel warning dialog state
|
|
||||||
const showMixedChannelWarning = ref(false)
|
const showMixedChannelWarning = ref(false)
|
||||||
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null)
|
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
|
||||||
const pendingUpdatePayload = ref<Record<string, unknown> | null>(null)
|
null
|
||||||
|
)
|
||||||
|
const mixedChannelWarningRawMessage = ref('')
|
||||||
|
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
|
||||||
|
const antigravityMixedChannelConfirmed = ref(false)
|
||||||
|
|
||||||
// Quota control state (Anthropic OAuth/SetupToken only)
|
// Quota control state (Anthropic OAuth/SetupToken only)
|
||||||
const windowCostEnabled = ref(false)
|
const windowCostEnabled = ref(false)
|
||||||
@@ -1298,6 +1301,13 @@ const defaultBaseUrl = computed(() => {
|
|||||||
return 'https://api.anthropic.com'
|
return 'https://api.anthropic.com'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mixedChannelWarningMessageText = computed(() => {
|
||||||
|
if (mixedChannelWarningDetails.value) {
|
||||||
|
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
|
||||||
|
}
|
||||||
|
return mixedChannelWarningRawMessage.value
|
||||||
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -1327,6 +1337,11 @@ watch(
|
|||||||
() => props.account,
|
() => props.account,
|
||||||
(newAccount) => {
|
(newAccount) => {
|
||||||
if (newAccount) {
|
if (newAccount) {
|
||||||
|
antigravityMixedChannelConfirmed.value = false
|
||||||
|
showMixedChannelWarning.value = false
|
||||||
|
mixedChannelWarningDetails.value = null
|
||||||
|
mixedChannelWarningRawMessage.value = ''
|
||||||
|
mixedChannelWarningAction.value = null
|
||||||
form.name = newAccount.name
|
form.name = newAccount.name
|
||||||
form.notes = newAccount.notes || ''
|
form.notes = newAccount.notes || ''
|
||||||
form.proxy_id = newAccount.proxy_id
|
form.proxy_id = newAccount.proxy_id
|
||||||
@@ -1726,18 +1741,123 @@ function toPositiveNumber(value: unknown) {
|
|||||||
return Math.trunc(num)
|
return Math.trunc(num)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsMixedChannelCheck = () => props.account?.platform === 'antigravity' || props.account?.platform === 'anthropic'
|
||||||
|
|
||||||
|
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
|
||||||
|
const details = resp?.details
|
||||||
|
if (!details) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
groupName: details.group_name || 'Unknown',
|
||||||
|
currentPlatform: details.current_platform || 'Unknown',
|
||||||
|
otherPlatform: details.other_platform || 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMixedChannelDialog = () => {
|
||||||
|
showMixedChannelWarning.value = false
|
||||||
|
mixedChannelWarningDetails.value = null
|
||||||
|
mixedChannelWarningRawMessage.value = ''
|
||||||
|
mixedChannelWarningAction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMixedChannelDialog = (opts: {
|
||||||
|
response?: CheckMixedChannelResponse
|
||||||
|
message?: string
|
||||||
|
onConfirm: () => Promise<void>
|
||||||
|
}) => {
|
||||||
|
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
|
||||||
|
mixedChannelWarningRawMessage.value =
|
||||||
|
opts.message || opts.response?.message || t('admin.accounts.failedToUpdate')
|
||||||
|
mixedChannelWarningAction.value = opts.onConfirm
|
||||||
|
showMixedChannelWarning.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAntigravityConfirmFlag = (payload: Record<string, unknown>) => {
|
||||||
|
if (needsMixedChannelCheck() && antigravityMixedChannelConfirmed.value) {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
confirm_mixed_channel_risk: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cloned = { ...payload }
|
||||||
|
delete cloned.confirm_mixed_channel_risk
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
|
||||||
|
if (!needsMixedChannelCheck()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (antigravityMixedChannelConfirmed.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!props.account) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||||||
|
platform: props.account.platform,
|
||||||
|
group_ids: form.group_ids,
|
||||||
|
account_id: props.account.id
|
||||||
|
})
|
||||||
|
if (!result.has_risk) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
openMixedChannelDialog({
|
||||||
|
response: result,
|
||||||
|
onConfirm: async () => {
|
||||||
|
antigravityMixedChannelConfirmed.value = true
|
||||||
|
await onConfirm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatDateTimeLocal = formatDateTimeLocalInput
|
const formatDateTimeLocal = formatDateTimeLocalInput
|
||||||
const parseDateTimeLocal = parseDateTimeLocalInput
|
const parseDateTimeLocal = parseDateTimeLocalInput
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
antigravityMixedChannelConfirmed.value = false
|
||||||
|
clearMixedChannelDialog()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitUpdateAccount = async (accountID: number, updatePayload: Record<string, unknown>) => {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
|
||||||
|
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||||
|
emit('updated', updatedAccount)
|
||||||
|
handleClose()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck()) {
|
||||||
|
openMixedChannelDialog({
|
||||||
|
message: error.response?.data?.message,
|
||||||
|
onConfirm: async () => {
|
||||||
|
antigravityMixedChannelConfirmed.value = true
|
||||||
|
await submitUpdateAccount(accountID, updatePayload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!props.account) return
|
if (!props.account) return
|
||||||
|
const accountID = props.account.id
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
const updatePayload: Record<string, unknown> = { ...form }
|
const updatePayload: Record<string, unknown> = { ...form }
|
||||||
try {
|
try {
|
||||||
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
|
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
|
||||||
@@ -1769,7 +1889,6 @@ const handleSubmit = async () => {
|
|||||||
newCredentials.api_key = currentCredentials.api_key
|
newCredentials.api_key = currentCredentials.api_key
|
||||||
} else {
|
} else {
|
||||||
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
|
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
|
||||||
submitting.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1792,7 +1911,6 @@ const handleSubmit = async () => {
|
|||||||
// Add intercept warmup requests setting
|
// Add intercept warmup requests setting
|
||||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||||
if (!applyTempUnschedConfig(newCredentials)) {
|
if (!applyTempUnschedConfig(newCredentials)) {
|
||||||
submitting.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1811,7 +1929,6 @@ const handleSubmit = async () => {
|
|||||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||||
|
|
||||||
if (!applyTempUnschedConfig(newCredentials)) {
|
if (!applyTempUnschedConfig(newCredentials)) {
|
||||||
submitting.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1823,7 +1940,6 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||||
if (!applyTempUnschedConfig(newCredentials)) {
|
if (!applyTempUnschedConfig(newCredentials)) {
|
||||||
submitting.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,52 +2069,36 @@ const handleSubmit = async () => {
|
|||||||
updatePayload.extra = newExtra
|
updatePayload.extra = newExtra
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAccount = await adminAPI.accounts.update(props.account.id, updatePayload)
|
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
|
||||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
await submitUpdateAccount(accountID, updatePayload)
|
||||||
emit('updated', updatedAccount)
|
})
|
||||||
handleClose()
|
if (!canContinue) {
|
||||||
} catch (error: any) {
|
return
|
||||||
// Handle 409 mixed_channel_warning - show confirmation dialog
|
|
||||||
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') {
|
|
||||||
const details = error.response.data.details || {}
|
|
||||||
mixedChannelWarningDetails.value = {
|
|
||||||
groupName: details.group_name || 'Unknown',
|
|
||||||
currentPlatform: details.current_platform || 'Unknown',
|
|
||||||
otherPlatform: details.other_platform || 'Unknown'
|
|
||||||
}
|
|
||||||
pendingUpdatePayload.value = updatePayload
|
|
||||||
showMixedChannelWarning.value = true
|
|
||||||
} else {
|
|
||||||
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
await submitUpdateAccount(accountID, updatePayload)
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle mixed channel warning confirmation
|
// Handle mixed channel warning confirmation
|
||||||
const handleMixedChannelConfirm = async () => {
|
const handleMixedChannelConfirm = async () => {
|
||||||
showMixedChannelWarning.value = false
|
const action = mixedChannelWarningAction.value
|
||||||
if (pendingUpdatePayload.value && props.account) {
|
if (!action) {
|
||||||
pendingUpdatePayload.value.confirm_mixed_channel_risk = true
|
clearMixedChannelDialog()
|
||||||
submitting.value = true
|
return
|
||||||
try {
|
}
|
||||||
const updatedAccount = await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value)
|
clearMixedChannelDialog()
|
||||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
submitting.value = true
|
||||||
emit('updated', updatedAccount)
|
try {
|
||||||
handleClose()
|
await action()
|
||||||
} catch (error: any) {
|
} finally {
|
||||||
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
submitting.value = false
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
pendingUpdatePayload.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMixedChannelCancel = () => {
|
const handleMixedChannelCancel = () => {
|
||||||
showMixedChannelWarning.value = false
|
clearMixedChannelDialog()
|
||||||
pendingUpdatePayload.value = null
|
|
||||||
mixedChannelWarningDetails.value = null
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -766,6 +766,26 @@ export interface UpdateAccountRequest {
|
|||||||
confirm_mixed_channel_risk?: boolean
|
confirm_mixed_channel_risk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckMixedChannelRequest {
|
||||||
|
platform: AccountPlatform
|
||||||
|
group_ids: number[]
|
||||||
|
account_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixedChannelWarningDetails {
|
||||||
|
group_id: number
|
||||||
|
group_name: string
|
||||||
|
current_platform: string
|
||||||
|
other_platform: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckMixedChannelResponse {
|
||||||
|
has_risk: boolean
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
details?: MixedChannelWarningDetails
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProxyRequest {
|
export interface CreateProxyRequest {
|
||||||
name: string
|
name: string
|
||||||
protocol: ProxyProtocol
|
protocol: ProxyProtocol
|
||||||
|
|||||||
Reference in New Issue
Block a user