mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
Merge pull request #2030 from KnowSky404/feature/account-bulk-edit-scope-and-compact
feat: support filtered account bulk edit and align compact OpenAI bulk fields
This commit is contained in:
@@ -134,19 +134,29 @@ type UpdateAccountRequest struct {
|
|||||||
|
|
||||||
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
|
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
|
||||||
type BulkUpdateAccountsRequest struct {
|
type BulkUpdateAccountsRequest struct {
|
||||||
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
AccountIDs []int64 `json:"account_ids"`
|
||||||
Name string `json:"name"`
|
Filters *BulkUpdateAccountFilters `json:"filters"`
|
||||||
ProxyID *int64 `json:"proxy_id"`
|
Name string `json:"name"`
|
||||||
Concurrency *int `json:"concurrency"`
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
Priority *int `json:"priority"`
|
Concurrency *int `json:"concurrency"`
|
||||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
Priority *int `json:"priority"`
|
||||||
LoadFactor *int `json:"load_factor"`
|
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
LoadFactor *int `json:"load_factor"`
|
||||||
Schedulable *bool `json:"schedulable"`
|
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||||||
GroupIDs *[]int64 `json:"group_ids"`
|
Schedulable *bool `json:"schedulable"`
|
||||||
Credentials map[string]any `json:"credentials"`
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
Extra map[string]any `json:"extra"`
|
Credentials map[string]any `json:"credentials"`
|
||||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
Extra map[string]any `json:"extra"`
|
||||||
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkUpdateAccountFilters struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Search string `json:"search"`
|
||||||
|
PrivacyMode string `json:"privacy_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMixedChannelRequest represents check mixed channel risk request
|
// CheckMixedChannelRequest represents check mixed channel risk request
|
||||||
@@ -1369,6 +1379,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
|||||||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(req.AccountIDs) == 0 && req.Filters == nil {
|
||||||
|
response.BadRequest(c, "account_ids or filters is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||||
sanitizeExtraBaseRPM(req.Extra)
|
sanitizeExtraBaseRPM(req.Extra)
|
||||||
|
|
||||||
@@ -1394,6 +1408,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
|||||||
|
|
||||||
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
||||||
AccountIDs: req.AccountIDs,
|
AccountIDs: req.AccountIDs,
|
||||||
|
Filters: toServiceBulkUpdateAccountFilters(req.Filters),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
ProxyID: req.ProxyID,
|
ProxyID: req.ProxyID,
|
||||||
Concurrency: req.Concurrency,
|
Concurrency: req.Concurrency,
|
||||||
@@ -1429,6 +1444,20 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toServiceBulkUpdateAccountFilters(filters *BulkUpdateAccountFilters) *service.BulkUpdateAccountFilters {
|
||||||
|
if filters == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &service.BulkUpdateAccountFilters{
|
||||||
|
Platform: filters.Platform,
|
||||||
|
Type: filters.Type,
|
||||||
|
Status: filters.Status,
|
||||||
|
Group: filters.Group,
|
||||||
|
Search: filters.Search,
|
||||||
|
PrivacyMode: filters.PrivacyMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== OAuth Handlers ==========
|
// ========== OAuth Handlers ==========
|
||||||
|
|
||||||
// GenerateAuthURLRequest represents the request for generating auth URL
|
// GenerateAuthURLRequest represents the request for generating auth URL
|
||||||
|
|||||||
@@ -196,3 +196,29 @@ func TestAccountHandlerBulkUpdateMixedChannelConfirmSkips(t *testing.T) {
|
|||||||
require.Equal(t, float64(2), data["success"])
|
require.Equal(t, float64(2), data["success"])
|
||||||
require.Equal(t, float64(0), data["failed"])
|
require.Equal(t, float64(0), data["failed"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBulkUpdateAcceptsFilterTargetRequest(t *testing.T) {
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
router := setupAccountMixedChannelRouter(adminSvc)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"filters": map[string]any{
|
||||||
|
"platform": "openai",
|
||||||
|
"type": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"group": "12",
|
||||||
|
"privacy_mode": "blocked",
|
||||||
|
"search": "bulk-target",
|
||||||
|
},
|
||||||
|
"schedulable": 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"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ type UpdateAccountInput struct {
|
|||||||
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
|
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
|
||||||
type BulkUpdateAccountsInput struct {
|
type BulkUpdateAccountsInput struct {
|
||||||
AccountIDs []int64
|
AccountIDs []int64
|
||||||
|
Filters *BulkUpdateAccountFilters
|
||||||
Name string
|
Name string
|
||||||
ProxyID *int64
|
ProxyID *int64
|
||||||
Concurrency *int
|
Concurrency *int
|
||||||
@@ -307,6 +309,15 @@ type BulkUpdateAccountsInput struct {
|
|||||||
SkipMixedChannelCheck bool
|
SkipMixedChannelCheck bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BulkUpdateAccountFilters struct {
|
||||||
|
Platform string
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
Group string
|
||||||
|
Search string
|
||||||
|
PrivacyMode string
|
||||||
|
}
|
||||||
|
|
||||||
// BulkUpdateAccountResult captures the result for a single account update.
|
// BulkUpdateAccountResult captures the result for a single account update.
|
||||||
type BulkUpdateAccountResult struct {
|
type BulkUpdateAccountResult struct {
|
||||||
AccountID int64 `json:"account_id"`
|
AccountID int64 `json:"account_id"`
|
||||||
@@ -2286,6 +2297,14 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
|||||||
// BulkUpdateAccounts updates multiple accounts in one request.
|
// BulkUpdateAccounts updates multiple accounts in one request.
|
||||||
// It merges credentials/extra keys instead of overwriting the whole object.
|
// It merges credentials/extra keys instead of overwriting the whole object.
|
||||||
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
|
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
|
||||||
|
if len(input.AccountIDs) == 0 && input.Filters != nil {
|
||||||
|
accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
input.AccountIDs = accountIDs
|
||||||
|
}
|
||||||
|
|
||||||
result := &BulkUpdateAccountsResult{
|
result := &BulkUpdateAccountsResult{
|
||||||
SuccessIDs: make([]int64, 0, len(input.AccountIDs)),
|
SuccessIDs: make([]int64, 0, len(input.AccountIDs)),
|
||||||
FailedIDs: make([]int64, 0, len(input.AccountIDs)),
|
FailedIDs: make([]int64, 0, len(input.AccountIDs)),
|
||||||
@@ -2401,6 +2420,55 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) {
|
||||||
|
if filters == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID := int64(0)
|
||||||
|
switch strings.TrimSpace(filters.Group) {
|
||||||
|
case "":
|
||||||
|
case "ungrouped":
|
||||||
|
groupID = AccountListGroupUngrouped
|
||||||
|
default:
|
||||||
|
parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid group filter: %w", err)
|
||||||
|
}
|
||||||
|
groupID = parsedGroupID
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 500
|
||||||
|
page := 1
|
||||||
|
accountIDs := make([]int64, 0, pageSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
accounts, total, err := s.ListAccounts(
|
||||||
|
ctx,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
filters.Platform,
|
||||||
|
filters.Type,
|
||||||
|
filters.Status,
|
||||||
|
filters.Search,
|
||||||
|
groupID,
|
||||||
|
filters.PrivacyMode,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, account := range accounts {
|
||||||
|
accountIDs = append(accountIDs, account.ID)
|
||||||
|
}
|
||||||
|
if int64(len(accountIDs)) >= total || len(accounts) == 0 {
|
||||||
|
return accountIDs, nil
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
|
func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
|
||||||
if err := s.accountRepo.Delete(ctx, id); err != nil {
|
if err := s.accountRepo.Delete(ctx, id); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +27,19 @@ type accountRepoStubForBulkUpdate struct {
|
|||||||
getByIDCalled []int64
|
getByIDCalled []int64
|
||||||
listByGroupData map[int64][]Account
|
listByGroupData map[int64][]Account
|
||||||
listByGroupErr map[int64]error
|
listByGroupErr map[int64]error
|
||||||
|
listData []Account
|
||||||
|
listResult *pagination.PaginationResult
|
||||||
|
listErr error
|
||||||
|
listCalled bool
|
||||||
|
lastListParams pagination.PaginationParams
|
||||||
|
lastListFilters struct {
|
||||||
|
platform string
|
||||||
|
accountType string
|
||||||
|
status string
|
||||||
|
search string
|
||||||
|
groupID int64
|
||||||
|
privacyMode string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) {
|
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) {
|
||||||
@@ -73,6 +88,24 @@ func (s *accountRepoStubForBulkUpdate) ListByGroup(_ context.Context, groupID in
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *accountRepoStubForBulkUpdate) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) {
|
||||||
|
s.listCalled = true
|
||||||
|
s.lastListParams = params
|
||||||
|
s.lastListFilters.platform = platform
|
||||||
|
s.lastListFilters.accountType = accountType
|
||||||
|
s.lastListFilters.status = status
|
||||||
|
s.lastListFilters.search = search
|
||||||
|
s.lastListFilters.groupID = groupID
|
||||||
|
s.lastListFilters.privacyMode = privacyMode
|
||||||
|
if s.listErr != nil {
|
||||||
|
return nil, nil, s.listErr
|
||||||
|
}
|
||||||
|
if s.listResult != nil {
|
||||||
|
return s.listData, s.listResult, nil
|
||||||
|
}
|
||||||
|
return s.listData, &pagination.PaginationResult{Total: int64(len(s.listData))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminService_BulkUpdateAccounts_AllSuccessIDs 验证批量更新成功时返回 success_ids/failed_ids。
|
// TestAdminService_BulkUpdateAccounts_AllSuccessIDs 验证批量更新成功时返回 success_ids/failed_ids。
|
||||||
func TestAdminService_BulkUpdateAccounts_AllSuccessIDs(t *testing.T) {
|
func TestAdminService_BulkUpdateAccounts_AllSuccessIDs(t *testing.T) {
|
||||||
repo := &accountRepoStubForBulkUpdate{}
|
repo := &accountRepoStubForBulkUpdate{}
|
||||||
@@ -170,3 +203,46 @@ func TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingCon
|
|||||||
// No BindGroups should have been called since the check runs before any write.
|
// No BindGroups should have been called since the check runs before any write.
|
||||||
require.Empty(t, repo.bindGroupsCalls)
|
require.Empty(t, repo.bindGroupsCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters(t *testing.T) {
|
||||||
|
repo := &accountRepoStubForBulkUpdate{
|
||||||
|
listData: []Account{
|
||||||
|
{ID: 7},
|
||||||
|
{ID: 11},
|
||||||
|
},
|
||||||
|
listResult: &pagination.PaginationResult{Total: 2},
|
||||||
|
}
|
||||||
|
svc := &adminServiceImpl{accountRepo: repo}
|
||||||
|
|
||||||
|
schedulable := true
|
||||||
|
input := &BulkUpdateAccountsInput{
|
||||||
|
Schedulable: &schedulable,
|
||||||
|
}
|
||||||
|
|
||||||
|
filtersField := reflect.ValueOf(input).Elem().FieldByName("Filters")
|
||||||
|
require.True(t, filtersField.IsValid(), "BulkUpdateAccountsInput should expose Filters for filter-target bulk update")
|
||||||
|
require.Equal(t, reflect.Ptr, filtersField.Kind(), "BulkUpdateAccountsInput.Filters should be a pointer field")
|
||||||
|
|
||||||
|
filtersValue := reflect.New(filtersField.Type().Elem())
|
||||||
|
filtersValue.Elem().FieldByName("Platform").SetString(PlatformOpenAI)
|
||||||
|
filtersValue.Elem().FieldByName("Type").SetString(AccountTypeOAuth)
|
||||||
|
filtersValue.Elem().FieldByName("Status").SetString(StatusActive)
|
||||||
|
filtersValue.Elem().FieldByName("Group").SetString("12")
|
||||||
|
filtersValue.Elem().FieldByName("PrivacyMode").SetString(PrivacyModeCFBlocked)
|
||||||
|
filtersValue.Elem().FieldByName("Search").SetString("bulk-target")
|
||||||
|
filtersField.Set(filtersValue)
|
||||||
|
|
||||||
|
result, err := svc.BulkUpdateAccounts(context.Background(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, repo.listCalled, "expected filter-target bulk update to resolve matching IDs via account list filters")
|
||||||
|
require.Equal(t, PlatformOpenAI, repo.lastListFilters.platform)
|
||||||
|
require.Equal(t, AccountTypeOAuth, repo.lastListFilters.accountType)
|
||||||
|
require.Equal(t, StatusActive, repo.lastListFilters.status)
|
||||||
|
require.Equal(t, "bulk-target", repo.lastListFilters.search)
|
||||||
|
require.Equal(t, int64(12), repo.lastListFilters.groupID)
|
||||||
|
require.Equal(t, PrivacyModeCFBlocked, repo.lastListFilters.privacyMode)
|
||||||
|
require.Equal(t, []int64{7, 11}, repo.bulkUpdateIDs)
|
||||||
|
require.Equal(t, 2, result.Success)
|
||||||
|
require.Equal(t, 0, result.Failed)
|
||||||
|
require.Equal(t, []int64{7, 11}, result.SuccessIDs)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Account Bulk Edit Scope And Compact Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add filter-result bulk edit to admin accounts, unify the table-level bulk-edit entry, and align OpenAI bulk-edit controls with the existing compact-related single-account settings.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing `/admin/accounts/bulk-update` flow to accept either explicit account IDs or a server-resolved filter target. Reuse the current account-list filter contract for scope resolution, then update the accounts view and bulk-edit modal so the UI can launch either selected-account edits or current-filter-result edits from one compact dropdown. Keep the existing bulk-edit form, but expand its target contract and OpenAI-specific field coverage.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3, TypeScript, Vitest, Gin, Go service/repository layer, existing admin accounts API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add backend test coverage for filter-target bulk update
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/handler/admin/account_handler_mixed_channel_test.go`
|
||||||
|
- Modify: `backend/internal/service/admin_service_bulk_update_test.go`
|
||||||
|
- Test: `backend/internal/handler/admin/account_handler_mixed_channel_test.go`
|
||||||
|
- Test: `backend/internal/service/admin_service_bulk_update_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing handler test for filter-target request acceptance**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestBulkUpdateAcceptsFilterTargetRequest(t *testing.T) {
|
||||||
|
// add a request body that omits account_ids and submits filters instead
|
||||||
|
// assert the route does not reject the request as malformed once service stubs are wired
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./backend/internal/handler/admin -run TestBulkUpdateAcceptsFilterTargetRequest -count=1`
|
||||||
|
Expected: FAIL because `BulkUpdateAccountsRequest` does not yet support `filters`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the failing service test for resolving IDs from filters**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters(t *testing.T) {
|
||||||
|
// construct BulkUpdateAccountsInput with Filters and no AccountIDs
|
||||||
|
// stub repository list/search path to return matching IDs
|
||||||
|
// assert BulkUpdate is called with all matching account IDs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./backend/internal/service -run TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters -count=1`
|
||||||
|
Expected: FAIL because `BulkUpdateAccountsInput` and service logic only use explicit `AccountIDs`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/handler/admin/account_handler_mixed_channel_test.go backend/internal/service/admin_service_bulk_update_test.go
|
||||||
|
git commit -m "test: cover filter-target account bulk update"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Implement backend filter-target bulk update
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/handler/admin/account_handler.go`
|
||||||
|
- Modify: `backend/internal/service/admin_service.go`
|
||||||
|
- Modify: `backend/internal/repository/account_repo.go`
|
||||||
|
- Modify: `backend/internal/service/account_service.go`
|
||||||
|
- Test: `backend/internal/handler/admin/account_handler_mixed_channel_test.go`
|
||||||
|
- Test: `backend/internal/service/admin_service_bulk_update_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement request structs and validation for filter targets**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BulkUpdateAccountFilters struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Search string `json:"search"`
|
||||||
|
PrivacyMode string `json:"privacy_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkUpdateAccountsRequest struct {
|
||||||
|
AccountIDs []int64 `json:"account_ids"`
|
||||||
|
Filters *BulkUpdateAccountFilters `json:"filters"`
|
||||||
|
// existing fields remain unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Resolve filter targets in the service layer with one canonical path**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BulkUpdateAccountsInput struct {
|
||||||
|
AccountIDs []int64
|
||||||
|
Filters *BulkUpdateAccountFilters
|
||||||
|
// existing fields remain unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.AccountIDs) == 0 && input.Filters != nil {
|
||||||
|
ids, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
input.AccountIDs = ids
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reuse existing account-search/repository logic to resolve all matching IDs**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *AdminService) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) {
|
||||||
|
// call the existing repository list/search path with the submitted filters
|
||||||
|
// page through all matching rows or use a dedicated ID-only query helper
|
||||||
|
// return unique IDs in stable order
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run targeted backend tests**
|
||||||
|
|
||||||
|
Run: `GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./backend/internal/handler/admin ./backend/internal/service -run 'TestBulkUpdateAcceptsFilterTargetRequest|TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters' -count=1`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/handler/admin/account_handler.go backend/internal/service/admin_service.go backend/internal/repository/account_repo.go backend/internal/service/account_service.go backend/internal/handler/admin/account_handler_mixed_channel_test.go backend/internal/service/admin_service_bulk_update_test.go
|
||||||
|
git commit -m "feat: support filter-target account bulk update"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Add frontend API and modal tests for target scope
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
- Create: `frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
- Modify: `frontend/src/api/admin/accounts.ts`
|
||||||
|
- Test: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
- Test: `frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing modal test for filter-target payload submission**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('submits bulk edit using current filters when target mode is filtered-results', async () => {
|
||||||
|
// mount BulkEditAccountModal with targetMode='filtered'
|
||||||
|
// submit a minimal change
|
||||||
|
// expect adminAPI.accounts.bulkUpdate to receive { filters: ... } rather than account_ids
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/components/account/__tests__/BulkEditAccountModal.spec.ts -t "filtered-results"`
|
||||||
|
Expected: FAIL because the modal only accepts `accountIds`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the failing accounts-view test for dropdown launch actions**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('opens bulk edit for current filtered results from the table action dropdown', async () => {
|
||||||
|
// mount AccountsView with filters set
|
||||||
|
// click Bulk edit > current filtered results
|
||||||
|
// assert modal props contain filter target metadata
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
Expected: FAIL because the dropdown action and target scope state do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts frontend/src/api/admin/accounts.ts
|
||||||
|
git commit -m "test: cover account bulk edit target scopes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Implement unified frontend bulk-edit target scope flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/admin/AccountsView.vue`
|
||||||
|
- Modify: `frontend/src/components/admin/account/AccountBulkActionsBar.vue`
|
||||||
|
- Modify: `frontend/src/components/account/BulkEditAccountModal.vue`
|
||||||
|
- Modify: `frontend/src/api/admin/accounts.ts`
|
||||||
|
- Modify: `frontend/src/i18n/locales/zh.ts`
|
||||||
|
- Modify: `frontend/src/i18n/locales/en.ts`
|
||||||
|
- Test: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
- Test: `frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a typed frontend target contract for bulk edit**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type AccountBulkEditTarget =
|
||||||
|
| { mode: 'selected'; accountIds: number[]; selectedPlatforms: AccountPlatform[]; selectedTypes: AccountType[] }
|
||||||
|
| { mode: 'filtered'; filters: AccountListFilters; previewCount: number; selectedPlatforms: AccountPlatform[]; selectedTypes: AccountType[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the single selected-row edit button with one dropdown**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<BulkEditDropdown
|
||||||
|
:has-selection="selectedIds.length > 0"
|
||||||
|
@edit-selected="openBulkEditSelected"
|
||||||
|
@edit-filtered="openBulkEditFiltered"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Snapshot current filters and preview count when launching filtered mode**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const openBulkEditFiltered = async () => {
|
||||||
|
const filters = toBulkEditFilterSnapshot(params)
|
||||||
|
const preview = await adminAPI.accounts.list(1, 1, filters)
|
||||||
|
bulkEditTarget.value = {
|
||||||
|
mode: 'filtered',
|
||||||
|
filters,
|
||||||
|
previewCount: preview.pagination.total,
|
||||||
|
selectedPlatforms: collectPlatforms(preview.data),
|
||||||
|
selectedTypes: collectTypes(preview.data)
|
||||||
|
}
|
||||||
|
showBulkEdit.value = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update modal submission to call `bulkUpdate` with either `account_ids` or `filters`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (props.target.mode === 'selected') {
|
||||||
|
await adminAPI.accounts.bulkUpdate({ account_ids: props.target.accountIds, ...updates })
|
||||||
|
} else {
|
||||||
|
await adminAPI.accounts.bulkUpdate({ filters: props.target.filters, ...updates })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run targeted frontend tests**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/components/account/__tests__/BulkEditAccountModal.spec.ts src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/views/admin/AccountsView.vue frontend/src/components/admin/account/AccountBulkActionsBar.vue frontend/src/components/account/BulkEditAccountModal.vue frontend/src/api/admin/accounts.ts frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
|
||||||
|
git commit -m "feat: add filtered-result account bulk edit"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Add failing tests for missing OpenAI bulk-edit fields
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
- Test: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing OAuth test for `codex_cli_only`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('OpenAI OAuth bulk edit can submit codex_cli_only', async () => {
|
||||||
|
// enable the toggle and submit
|
||||||
|
// expect extra.codex_cli_only to be sent
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/components/account/__tests__/BulkEditAccountModal.spec.ts -t "codex_cli_only"`
|
||||||
|
Expected: FAIL because the modal has no such control or payload mapping.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the failing API key test for API key WS mode**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('OpenAI API key bulk edit submits API key WS mode fields', async () => {
|
||||||
|
// enable the API key WS mode selector and submit
|
||||||
|
// expect openai_apikey_responses_websockets_v2_mode and enabled flag
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/components/account/__tests__/BulkEditAccountModal.spec.ts -t "API key WS mode"`
|
||||||
|
Expected: FAIL because the modal only submits OAuth WS mode.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
|
||||||
|
git commit -m "test: cover missing OpenAI bulk edit fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Implement missing OpenAI bulk-edit controls and payload wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/account/BulkEditAccountModal.vue`
|
||||||
|
- Modify: `frontend/src/i18n/locales/zh.ts`
|
||||||
|
- Modify: `frontend/src/i18n/locales/en.ts`
|
||||||
|
- Test: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add UI controls for OAuth `codex_cli_only` and API key WS mode**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="allOpenAIOAuth">
|
||||||
|
<!-- existing OAuth WS mode -->
|
||||||
|
<!-- add codex_cli_only toggle -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="allOpenAIAPIKey">
|
||||||
|
<!-- add API key WS mode selector -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Mirror single-account payload semantics in the bulk-edit submit builder**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (enableCodexCLIOnly.value) {
|
||||||
|
const extra = ensureExtra()
|
||||||
|
extra.codex_cli_only = codexCLIOnlyEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableOpenAIAPIKeyWSMode.value) {
|
||||||
|
const extra = ensureExtra()
|
||||||
|
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||||
|
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run focused modal tests**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/account/BulkEditAccountModal.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
|
||||||
|
git commit -m "feat: align OpenAI bulk edit compact settings"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Final regression verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: none expected
|
||||||
|
- Test: `frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts`
|
||||||
|
- Test: `frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
- Test: `backend/internal/handler/admin/account_handler_mixed_channel_test.go`
|
||||||
|
- Test: `backend/internal/service/admin_service_bulk_update_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run frontend typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused frontend test suite**
|
||||||
|
|
||||||
|
Run: `pnpm -C frontend test:run src/components/account/__tests__/BulkEditAccountModal.spec.ts src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run focused backend test suite**
|
||||||
|
|
||||||
|
Run: `GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./backend/internal/handler/admin ./backend/internal/service -run 'BulkUpdate|bulk update' -count=1`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit final integration fixes if needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/account/BulkEditAccountModal.vue frontend/src/views/admin/AccountsView.vue frontend/src/components/admin/account/AccountBulkActionsBar.vue frontend/src/api/admin/accounts.ts frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts backend/internal/handler/admin/account_handler.go backend/internal/service/admin_service.go backend/internal/repository/account_repo.go backend/internal/service/account_service.go frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts backend/internal/handler/admin/account_handler_mixed_channel_test.go backend/internal/service/admin_service_bulk_update_test.go
|
||||||
|
git commit -m "feat: finish account bulk edit scope and compact support"
|
||||||
|
```
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Account Bulk Edit Scope And Compact Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This change expands admin account bulk edit in two directions:
|
||||||
|
|
||||||
|
1. Add a second bulk-edit target scope based on the current filter result set, so operators do not need to manually select every account.
|
||||||
|
2. Align OpenAI bulk-edit fields with single-account create/edit for the compact-related settings that are already supported elsewhere.
|
||||||
|
|
||||||
|
The design keeps the existing selected-row workflow intact and adds a unified bulk-edit entry with two explicit actions:
|
||||||
|
|
||||||
|
- `Bulk edit selected accounts`
|
||||||
|
- `Bulk edit current filtered results`
|
||||||
|
|
||||||
|
`Current filtered results` reuses the existing account-list filters. That means:
|
||||||
|
|
||||||
|
- with no filters, it targets the whole account inventory
|
||||||
|
- with a group filter, it targets all accounts in that group
|
||||||
|
- with combined filters, it targets all matching accounts
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Preserve the current selected-account bulk edit flow.
|
||||||
|
- Let operators bulk edit the full current filtered result set without manual row selection.
|
||||||
|
- Show the user the exact target scope before applying changes.
|
||||||
|
- Reuse the current list filter semantics instead of inventing a separate "all accounts" or "by group" API.
|
||||||
|
- Add the missing OpenAI bulk-edit fields:
|
||||||
|
- OAuth `codex_cli_only`
|
||||||
|
- API key `openai_apikey_responses_websockets_v2_mode`
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No new standalone "edit all accounts" route that ignores filters.
|
||||||
|
- No new dedicated "edit group" route separate from list filters.
|
||||||
|
- No change to the backend merge semantics for other bulk-edit fields.
|
||||||
|
- No attempt in this change to refactor all account form components into a shared schema system.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### Bulk edit entry
|
||||||
|
|
||||||
|
The account list currently exposes bulk edit only through selected-row actions. `AccountsView.vue` passes `selIds`, `selPlatforms`, and `selTypes` into `BulkEditAccountModal.vue`.
|
||||||
|
|
||||||
|
### Filter state
|
||||||
|
|
||||||
|
The account page already keeps a central `params` object for current filters and reloads the table from that state. Group filtering already exists in `AccountTableFilters.vue`.
|
||||||
|
|
||||||
|
### Bulk edit payload
|
||||||
|
|
||||||
|
`BulkEditAccountModal.vue` builds a bulk update request around explicit account IDs.
|
||||||
|
|
||||||
|
### OpenAI field gap
|
||||||
|
|
||||||
|
Single-account create/edit already supports:
|
||||||
|
|
||||||
|
- `openai_passthrough`
|
||||||
|
- OAuth WS mode
|
||||||
|
- API key WS mode
|
||||||
|
- OAuth `codex_cli_only`
|
||||||
|
|
||||||
|
Bulk edit currently supports:
|
||||||
|
|
||||||
|
- `openai_passthrough`
|
||||||
|
- OAuth WS mode only
|
||||||
|
|
||||||
|
That leaves a real capability gap for operators managing large OpenAI account sets.
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
|
||||||
|
Use one compact `Bulk edit` dropdown button in the table-level bulk actions area above the grid.
|
||||||
|
|
||||||
|
The dropdown contains:
|
||||||
|
|
||||||
|
- `Bulk edit selected accounts`
|
||||||
|
- `Bulk edit current filtered results`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- If there is no row selection, the `selected accounts` action is disabled.
|
||||||
|
- `Current filtered results` is always available.
|
||||||
|
- The existing separate immediate `Edit` action in the selected-row bar is replaced by this unified dropdown to avoid duplicate buttons that mean different scopes.
|
||||||
|
|
||||||
|
### Modal scope messaging
|
||||||
|
|
||||||
|
The bulk edit modal gets a required scope descriptor prop.
|
||||||
|
|
||||||
|
For `selected accounts`:
|
||||||
|
|
||||||
|
- show the existing count-based info banner
|
||||||
|
- keep using explicit selected account metadata for platform/type compatibility checks
|
||||||
|
|
||||||
|
For `current filtered results`:
|
||||||
|
|
||||||
|
- show a banner stating that edits apply to the current filtered result set
|
||||||
|
- show the matched account count from a preview query
|
||||||
|
- show a short summary of active filters when practical, especially group/search/platform/type/status filters
|
||||||
|
|
||||||
|
### Safety
|
||||||
|
|
||||||
|
For filtered-result mode:
|
||||||
|
|
||||||
|
- disable submit if the preview count is `0`
|
||||||
|
- refresh the target count when the modal opens
|
||||||
|
- keep the final success toast count aligned with the backend result
|
||||||
|
|
||||||
|
The modal should not silently fall back from filtered mode to selected mode.
|
||||||
|
|
||||||
|
## Backend/API Design
|
||||||
|
|
||||||
|
### Request model
|
||||||
|
|
||||||
|
Extend bulk update to support two target modes:
|
||||||
|
|
||||||
|
- explicit IDs
|
||||||
|
- filter-based query
|
||||||
|
|
||||||
|
The request shape should keep backward compatibility for the selected-ID path while allowing a filter target. The backend handler can accept a payload that contains either:
|
||||||
|
|
||||||
|
- `account_ids`
|
||||||
|
- or `filters`
|
||||||
|
|
||||||
|
but not neither.
|
||||||
|
|
||||||
|
The `filters` payload should reuse the existing account-list query semantics already used by `/admin/accounts` and `/admin/accounts/data`, including:
|
||||||
|
|
||||||
|
- `search`
|
||||||
|
- `platform`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `privacy_mode`
|
||||||
|
- `group`
|
||||||
|
- existing sort fields may be ignored for mutation targeting if not needed
|
||||||
|
|
||||||
|
### Preview count
|
||||||
|
|
||||||
|
The frontend needs an accurate target count before submit in filtered-result mode. The simplest compatible approach is:
|
||||||
|
|
||||||
|
- call the existing account list endpoint with the current filters and a minimal page size strategy sufficient to obtain total count
|
||||||
|
|
||||||
|
If the current API makes that awkward, add a narrow preview/count helper for bulk edit target resolution. Prefer reusing the existing listing contract first.
|
||||||
|
|
||||||
|
### Target resolution
|
||||||
|
|
||||||
|
For filtered-result mode, the backend must resolve matching account IDs server-side from the submitted filters rather than trusting only currently loaded page data. This is required so filtered-result mode can act on the full result set across pagination.
|
||||||
|
|
||||||
|
### Compatibility metadata
|
||||||
|
|
||||||
|
The frontend still needs platform/type compatibility to determine which fields to show. For filtered-result mode, derive this from the preview result set returned from the same query used to show count. If the preview spans mixed incompatible account types, show the same warnings/conditional UI that selected mode already uses.
|
||||||
|
|
||||||
|
## Frontend Design
|
||||||
|
|
||||||
|
### Accounts view
|
||||||
|
|
||||||
|
`AccountsView.vue` will:
|
||||||
|
|
||||||
|
- replace the direct selected-only bulk edit trigger with a dropdown action model
|
||||||
|
- keep a reactive description of the pending bulk edit scope
|
||||||
|
- pass either selected IDs or current filter params into the modal
|
||||||
|
|
||||||
|
The "current filtered results" action uses the live `params` object snapshot at open time, not a mutable live subscription while the modal is already open.
|
||||||
|
|
||||||
|
### Bulk edit modal
|
||||||
|
|
||||||
|
`BulkEditAccountModal.vue` will accept a richer target contract, for example:
|
||||||
|
|
||||||
|
- target mode
|
||||||
|
- selected IDs or filter snapshot
|
||||||
|
- preview count
|
||||||
|
- preview platform/type coverage if needed
|
||||||
|
|
||||||
|
The modal remains one form; only the scope banner and submission target differ.
|
||||||
|
|
||||||
|
### OpenAI field alignment
|
||||||
|
|
||||||
|
Add the missing OpenAI controls to bulk edit:
|
||||||
|
|
||||||
|
- OAuth `codex_cli_only`
|
||||||
|
- API key WS mode selector
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- OAuth accounts show OAuth WS mode and `codex_cli_only`
|
||||||
|
- API key accounts show API key WS mode
|
||||||
|
- mixed OpenAI OAuth/API key selections continue to show only fields that are safe for the entire target set
|
||||||
|
|
||||||
|
The payload builder must write:
|
||||||
|
|
||||||
|
- `extra.codex_cli_only`
|
||||||
|
- `extra.openai_apikey_responses_websockets_v2_mode`
|
||||||
|
- `extra.openai_apikey_responses_websockets_v2_enabled`
|
||||||
|
|
||||||
|
with the same enable/disable semantics already used by single-account forms.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Frontend tests
|
||||||
|
|
||||||
|
Add or extend tests for:
|
||||||
|
|
||||||
|
- bulk edit dropdown actions in the accounts view
|
||||||
|
- selected-account mode still calling bulk update by IDs
|
||||||
|
- filtered-result mode calling bulk update with filter target
|
||||||
|
- filtered-result mode showing preview count and blocking submit on zero matches
|
||||||
|
- OAuth bulk edit supporting `codex_cli_only`
|
||||||
|
- API key bulk edit supporting API key WS mode
|
||||||
|
- no regression for existing passthrough and OAuth WS mode tests
|
||||||
|
|
||||||
|
### Backend tests
|
||||||
|
|
||||||
|
Add or extend tests for:
|
||||||
|
|
||||||
|
- bulk update request validation for IDs vs filters
|
||||||
|
- filtered-result mode resolving all matching accounts across pagination semantics
|
||||||
|
- mixed-channel risk checks still running for filter-target updates if applicable
|
||||||
|
- backward compatibility for the existing selected-ID request path
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Filter semantics can drift if bulk edit reimplements list-filter parsing differently from the listing endpoints.
|
||||||
|
- Filtered-result mode can surprise users if the active scope is not shown clearly enough.
|
||||||
|
- Large filtered updates may affect many rows; success/error messaging must stay explicit.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Implement this as a targeted extension of the existing bulk edit flow:
|
||||||
|
|
||||||
|
- unify the entry point in the table action area
|
||||||
|
- add filter-target bulk update support
|
||||||
|
- align the missing OpenAI compact-related fields
|
||||||
|
|
||||||
|
This keeps the mental model simple and solves the large-account-management pain without introducing a second parallel batch-edit system.
|
||||||
@@ -370,8 +370,8 @@ export async function batchUpdateCredentials(request: {
|
|||||||
* @returns Success confirmation
|
* @returns Success confirmation
|
||||||
*/
|
*/
|
||||||
export async function bulkUpdate(
|
export async function bulkUpdate(
|
||||||
accountIds: number[],
|
accountIdsOrPayload: number[] | Record<string, unknown>,
|
||||||
updates: Record<string, unknown>
|
updates?: Record<string, unknown>
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: number
|
success: number
|
||||||
failed: number
|
failed: number
|
||||||
@@ -379,16 +379,19 @@ export async function bulkUpdate(
|
|||||||
failed_ids?: number[]
|
failed_ids?: number[]
|
||||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||||
}> {
|
}> {
|
||||||
|
const payload = Array.isArray(accountIdsOrPayload)
|
||||||
|
? {
|
||||||
|
account_ids: accountIdsOrPayload,
|
||||||
|
...(updates ?? {})
|
||||||
|
}
|
||||||
|
: accountIdsOrPayload
|
||||||
const { data } = await apiClient.post<{
|
const { data } = await apiClient.post<{
|
||||||
success: number
|
success: number
|
||||||
failed: number
|
failed: number
|
||||||
success_ids?: number[]
|
success_ids?: number[]
|
||||||
failed_ids?: number[]
|
failed_ids?: number[]
|
||||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||||
}>('/admin/accounts/bulk-update', {
|
}>('/admin/accounts/bulk-update', payload)
|
||||||
account_ids: accountIds,
|
|
||||||
...updates
|
|
||||||
})
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
|
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: targetMode === 'filtered' ? targetPreviewCount : accountIds.length }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
|
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: targetSelectedPlatforms.join(', ') }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
|
|
||||||
<ModelWhitelistSelector
|
<ModelWhitelistSelector
|
||||||
v-model="allowedModels"
|
v-model="allowedModels"
|
||||||
:platforms="selectedPlatforms"
|
:platforms="targetSelectedPlatforms"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
@@ -698,6 +698,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- OpenAI OAuth Codex CLI only -->
|
||||||
|
<div v-if="allOpenAIOAuth" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
id="bulk-edit-openai-codex-cli-only-label"
|
||||||
|
class="input-label mb-0"
|
||||||
|
for="bulk-edit-openai-codex-cli-only-enabled"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.openai.codexCLIOnly') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="enableCodexCLIOnly"
|
||||||
|
id="bulk-edit-openai-codex-cli-only-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
aria-controls="bulk-edit-openai-codex-cli-only"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="bulk-edit-openai-codex-cli-only"
|
||||||
|
:class="!enableCodexCLIOnly && 'pointer-events-none opacity-50'"
|
||||||
|
>
|
||||||
|
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.openai.codexCLIOnlyDesc') }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
id="bulk-edit-openai-codex-cli-only-toggle"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
codexCLIOnlyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
@click="codexCLIOnlyEnabled = !codexCLIOnlyEnabled"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
codexCLIOnlyEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OpenAI API Key WS mode -->
|
||||||
|
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
id="bulk-edit-openai-apikey-ws-mode-label"
|
||||||
|
class="input-label mb-0"
|
||||||
|
for="bulk-edit-openai-apikey-ws-mode-enabled"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.openai.wsMode') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="enableOpenAIAPIKeyWSMode"
|
||||||
|
id="bulk-edit-openai-apikey-ws-mode-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
aria-controls="bulk-edit-openai-apikey-ws-mode"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="bulk-edit-openai-apikey-ws-mode"
|
||||||
|
:class="!enableOpenAIAPIKeyWSMode && 'pointer-events-none opacity-50'"
|
||||||
|
>
|
||||||
|
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.openai.wsModeDesc') }}
|
||||||
|
</p>
|
||||||
|
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t(openAIAPIKeyWSModeConcurrencyHintKey) }}
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
v-model="openaiAPIKeyResponsesWebSocketV2Mode"
|
||||||
|
data-testid="bulk-edit-openai-apikey-ws-mode-select"
|
||||||
|
:options="openAIWSModeOptions"
|
||||||
|
aria-labelledby="bulk-edit-openai-apikey-ws-mode-label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
|
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
|
||||||
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
@@ -933,6 +1014,13 @@ interface Props {
|
|||||||
accountIds: number[]
|
accountIds: number[]
|
||||||
selectedPlatforms: AccountPlatform[]
|
selectedPlatforms: AccountPlatform[]
|
||||||
selectedTypes: AccountType[]
|
selectedTypes: AccountType[]
|
||||||
|
target?: {
|
||||||
|
mode: 'selected' | 'filtered'
|
||||||
|
filters?: Record<string, unknown>
|
||||||
|
previewCount?: number
|
||||||
|
selectedPlatforms?: AccountPlatform[]
|
||||||
|
selectedTypes?: AccountType[]
|
||||||
|
}
|
||||||
proxies: ProxyConfig[]
|
proxies: ProxyConfig[]
|
||||||
groups: AdminGroup[]
|
groups: AdminGroup[]
|
||||||
}
|
}
|
||||||
@@ -947,40 +1035,53 @@ const { t } = useI18n()
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
// Platform awareness
|
// Platform awareness
|
||||||
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
const targetMode = computed(() => props.target?.mode ?? 'selected')
|
||||||
|
const targetPreviewCount = computed(() => props.target?.previewCount ?? props.accountIds.length)
|
||||||
|
const targetSelectedPlatforms = computed(() => props.target?.selectedPlatforms ?? props.selectedPlatforms)
|
||||||
|
const targetSelectedTypes = computed(() => props.target?.selectedTypes ?? props.selectedTypes)
|
||||||
|
const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1)
|
||||||
|
|
||||||
const allOpenAIPassthroughCapable = computed(() => {
|
const allOpenAIPassthroughCapable = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.selectedPlatforms.length === 1 &&
|
targetSelectedPlatforms.value.length === 1 &&
|
||||||
props.selectedPlatforms[0] === 'openai' &&
|
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||||
props.selectedTypes.length > 0 &&
|
targetSelectedTypes.value.length > 0 &&
|
||||||
props.selectedTypes.every(t => t === 'oauth' || t === 'apikey')
|
targetSelectedTypes.value.every(t => t === 'oauth' || t === 'apikey')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const allOpenAIOAuth = computed(() => {
|
const allOpenAIOAuth = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.selectedPlatforms.length === 1 &&
|
targetSelectedPlatforms.value.length === 1 &&
|
||||||
props.selectedPlatforms[0] === 'openai' &&
|
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||||
props.selectedTypes.length > 0 &&
|
targetSelectedTypes.value.length > 0 &&
|
||||||
props.selectedTypes.every(t => t === 'oauth')
|
targetSelectedTypes.value.every(t => t === 'oauth')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const allOpenAIAPIKey = computed(() => {
|
||||||
|
return (
|
||||||
|
targetSelectedPlatforms.value.length === 1 &&
|
||||||
|
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||||
|
targetSelectedTypes.value.length > 0 &&
|
||||||
|
targetSelectedTypes.value.every(t => t === 'apikey')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
|
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
|
||||||
const allAnthropicOAuthOrSetupToken = computed(() => {
|
const allAnthropicOAuthOrSetupToken = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.selectedPlatforms.length === 1 &&
|
targetSelectedPlatforms.value.length === 1 &&
|
||||||
props.selectedPlatforms[0] === 'anthropic' &&
|
targetSelectedPlatforms.value[0] === 'anthropic' &&
|
||||||
props.selectedTypes.every(t => t === 'oauth' || t === 'setup-token')
|
targetSelectedTypes.value.every(t => t === 'oauth' || t === 'setup-token')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredPresets = computed(() => {
|
const filteredPresets = computed(() => {
|
||||||
if (props.selectedPlatforms.length === 0) return []
|
if (targetSelectedPlatforms.value.length === 0) return []
|
||||||
|
|
||||||
const dedupedPresets = new Map<string, ReturnType<typeof getPresetMappingsByPlatform>[number]>()
|
const dedupedPresets = new Map<string, ReturnType<typeof getPresetMappingsByPlatform>[number]>()
|
||||||
for (const platform of props.selectedPlatforms) {
|
for (const platform of targetSelectedPlatforms.value) {
|
||||||
for (const preset of getPresetMappingsByPlatform(platform)) {
|
for (const preset of getPresetMappingsByPlatform(platform)) {
|
||||||
const key = `${preset.from}=>${preset.to}`
|
const key = `${preset.from}=>${preset.to}`
|
||||||
if (!dedupedPresets.has(key)) {
|
if (!dedupedPresets.has(key)) {
|
||||||
@@ -1012,6 +1113,8 @@ const enableStatus = ref(false)
|
|||||||
const enableGroups = ref(false)
|
const enableGroups = ref(false)
|
||||||
const enableOpenAIPassthrough = ref(false)
|
const enableOpenAIPassthrough = ref(false)
|
||||||
const enableOpenAIWSMode = ref(false)
|
const enableOpenAIWSMode = ref(false)
|
||||||
|
const enableOpenAIAPIKeyWSMode = ref(false)
|
||||||
|
const enableCodexCLIOnly = ref(false)
|
||||||
const enableRpmLimit = ref(false)
|
const enableRpmLimit = ref(false)
|
||||||
|
|
||||||
// State - field values
|
// State - field values
|
||||||
@@ -1035,6 +1138,8 @@ const status = ref<'active' | 'inactive'>('active')
|
|||||||
const groupIds = ref<number[]>([])
|
const groupIds = ref<number[]>([])
|
||||||
const openaiPassthroughEnabled = ref(false)
|
const openaiPassthroughEnabled = ref(false)
|
||||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
|
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
|
const codexCLIOnlyEnabled = ref(false)
|
||||||
const rpmLimitEnabled = ref(false)
|
const rpmLimitEnabled = ref(false)
|
||||||
const bulkBaseRpm = ref<number | null>(null)
|
const bulkBaseRpm = ref<number | null>(null)
|
||||||
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||||
@@ -1076,6 +1181,9 @@ const openAIWSModeOptions = computed(() => [
|
|||||||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||||||
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
|
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
|
||||||
)
|
)
|
||||||
|
const openAIAPIKeyWSModeConcurrencyHintKey = computed(() =>
|
||||||
|
resolveOpenAIWSModeConcurrencyHintKey(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||||||
|
)
|
||||||
|
|
||||||
// Model mapping helpers
|
// Model mapping helpers
|
||||||
const addModelMapping = () => {
|
const addModelMapping = () => {
|
||||||
@@ -1254,6 +1362,19 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (enableOpenAIAPIKeyWSMode.value) {
|
||||||
|
const extra = ensureExtra()
|
||||||
|
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||||
|
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(
|
||||||
|
openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableCodexCLIOnly.value) {
|
||||||
|
const extra = ensureExtra()
|
||||||
|
extra.codex_cli_only = codexCLIOnlyEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
// RPM limit settings (写入 extra 字段)
|
// RPM limit settings (写入 extra 字段)
|
||||||
if (enableRpmLimit.value) {
|
if (enableRpmLimit.value) {
|
||||||
const extra = ensureExtra()
|
const extra = ensureExtra()
|
||||||
@@ -1291,8 +1412,8 @@ const mixedChannelConfirmed = ref(false)
|
|||||||
const canPreCheck = () =>
|
const canPreCheck = () =>
|
||||||
enableGroups.value &&
|
enableGroups.value &&
|
||||||
groupIds.value.length > 0 &&
|
groupIds.value.length > 0 &&
|
||||||
props.selectedPlatforms.length === 1 &&
|
targetSelectedPlatforms.value.length === 1 &&
|
||||||
(props.selectedPlatforms[0] === 'antigravity' || props.selectedPlatforms[0] === 'anthropic')
|
(targetSelectedPlatforms.value[0] === 'antigravity' || targetSelectedPlatforms.value[0] === 'anthropic')
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
showMixedChannelWarning.value = false
|
showMixedChannelWarning.value = false
|
||||||
@@ -1309,7 +1430,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||||||
platform: props.selectedPlatforms[0],
|
platform: targetSelectedPlatforms.value[0],
|
||||||
group_ids: groupIds.value
|
group_ids: groupIds.value
|
||||||
})
|
})
|
||||||
if (!result.has_risk) return true
|
if (!result.has_risk) return true
|
||||||
@@ -1325,7 +1446,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (props.accountIds.length === 0) {
|
if (targetMode.value === 'selected' && props.accountIds.length === 0) {
|
||||||
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1344,6 +1465,8 @@ const handleSubmit = async () => {
|
|||||||
enableStatus.value ||
|
enableStatus.value ||
|
||||||
enableGroups.value ||
|
enableGroups.value ||
|
||||||
enableOpenAIWSMode.value ||
|
enableOpenAIWSMode.value ||
|
||||||
|
enableOpenAIAPIKeyWSMode.value ||
|
||||||
|
enableCodexCLIOnly.value ||
|
||||||
enableRpmLimit.value ||
|
enableRpmLimit.value ||
|
||||||
userMsgQueueMode.value !== null
|
userMsgQueueMode.value !== null
|
||||||
|
|
||||||
@@ -1373,7 +1496,12 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
|
const res = targetMode.value === 'filtered' && props.target?.filters
|
||||||
|
? await adminAPI.accounts.bulkUpdate({
|
||||||
|
filters: props.target.filters,
|
||||||
|
...updates
|
||||||
|
})
|
||||||
|
: await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
|
||||||
const success = res.success || 0
|
const success = res.success || 0
|
||||||
const failed = res.failed || 0
|
const failed = res.failed || 0
|
||||||
|
|
||||||
@@ -1437,6 +1565,8 @@ watch(
|
|||||||
enableGroups.value = false
|
enableGroups.value = false
|
||||||
enableOpenAIPassthrough.value = false
|
enableOpenAIPassthrough.value = false
|
||||||
enableOpenAIWSMode.value = false
|
enableOpenAIWSMode.value = false
|
||||||
|
enableOpenAIAPIKeyWSMode.value = false
|
||||||
|
enableCodexCLIOnly.value = false
|
||||||
enableRpmLimit.value = false
|
enableRpmLimit.value = false
|
||||||
|
|
||||||
// Reset all values
|
// Reset all values
|
||||||
@@ -1456,6 +1586,8 @@ watch(
|
|||||||
status.value = 'active'
|
status.value = 'active'
|
||||||
groupIds.value = []
|
groupIds.value = []
|
||||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
|
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
|
codexCLIOnlyEnabled.value = false
|
||||||
rpmLimitEnabled.value = false
|
rpmLimitEnabled.value = false
|
||||||
bulkBaseRpm.value = null
|
bulkBaseRpm.value = null
|
||||||
bulkRpmStrategy.value = 'tiered'
|
bulkRpmStrategy.value = 'tiered'
|
||||||
|
|||||||
@@ -178,6 +178,45 @@ describe('BulkEditAccountModal', () => {
|
|||||||
expect(wrapper.find('#bulk-edit-openai-ws-mode-enabled').exists()).toBe(false)
|
expect(wrapper.find('#bulk-edit-openai-ws-mode-enabled').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('OpenAI OAuth 批量编辑应提交 codex_cli_only 字段', async () => {
|
||||||
|
const wrapper = mountModal({
|
||||||
|
selectedPlatforms: ['openai'],
|
||||||
|
selectedTypes: ['oauth']
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('#bulk-edit-openai-codex-cli-only-enabled').setValue(true)
|
||||||
|
await wrapper.get('#bulk-edit-openai-codex-cli-only-toggle').trigger('click')
|
||||||
|
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||||
|
extra: {
|
||||||
|
codex_cli_only: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('OpenAI API Key 批量编辑应提交 API Key 专属 WS mode 字段', async () => {
|
||||||
|
const wrapper = mountModal({
|
||||||
|
selectedPlatforms: ['openai'],
|
||||||
|
selectedTypes: ['apikey']
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('#bulk-edit-openai-apikey-ws-mode-enabled').setValue(true)
|
||||||
|
await wrapper.get('[data-testid="bulk-edit-openai-apikey-ws-mode-select"]').setValue('ctx_pool')
|
||||||
|
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||||
|
extra: {
|
||||||
|
openai_apikey_responses_websockets_v2_mode: 'ctx_pool',
|
||||||
|
openai_apikey_responses_websockets_v2_enabled: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('OpenAI 账号批量编辑可关闭自动透传', async () => {
|
it('OpenAI 账号批量编辑可关闭自动透传', async () => {
|
||||||
const wrapper = mountModal({
|
const wrapper = mountModal({
|
||||||
selectedPlatforms: ['openai'],
|
selectedPlatforms: ['openai'],
|
||||||
@@ -217,4 +256,41 @@ describe('BulkEditAccountModal', () => {
|
|||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('admin.accounts.openai.modelRestrictionDisabledByPassthrough')
|
expect(wrapper.text()).toContain('admin.accounts.openai.modelRestrictionDisabledByPassthrough')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filtered-results 模式下应提交 filters 而不是 account_ids', async () => {
|
||||||
|
const wrapper = mountModal({
|
||||||
|
accountIds: [],
|
||||||
|
target: {
|
||||||
|
mode: 'filtered',
|
||||||
|
filters: {
|
||||||
|
platform: 'openai',
|
||||||
|
type: 'oauth',
|
||||||
|
status: 'active',
|
||||||
|
group: '12',
|
||||||
|
search: 'bulk-target',
|
||||||
|
privacy_mode: 'training_set_cf_blocked'
|
||||||
|
},
|
||||||
|
previewCount: 5,
|
||||||
|
selectedPlatforms: ['openai'],
|
||||||
|
selectedTypes: ['oauth']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('#bulk-edit-status-enabled').setValue(true)
|
||||||
|
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({
|
||||||
|
filters: {
|
||||||
|
platform: 'openai',
|
||||||
|
type: 'oauth',
|
||||||
|
status: 'active',
|
||||||
|
group: '12',
|
||||||
|
search: 'bulk-target',
|
||||||
|
privacy_mode: 'training_set_cf_blocked'
|
||||||
|
},
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg dark:bg-primary-900/20">
|
<div class="mb-4 flex items-center justify-between rounded-lg bg-primary-50 p-3 dark:bg-primary-900/20">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
<span v-if="selectedIds.length > 0" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
|
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||||
|
{{ t('admin.accounts.bulkEdit.title') }}
|
||||||
|
</span>
|
||||||
|
<template v-if="selectedIds.length > 0">
|
||||||
<button
|
<button
|
||||||
@click="$emit('select-page')"
|
@click="$emit('select-page')"
|
||||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||||
@@ -17,19 +21,25 @@
|
|||||||
>
|
>
|
||||||
{{ t('admin.accounts.bulkActions.clear') }}
|
{{ t('admin.accounts.bulkActions.clear') }}
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
<template v-if="selectedIds.length > 0">
|
||||||
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||||
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||||
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
||||||
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
||||||
|
<button @click="$emit('edit-selected')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
||||||
|
</template>
|
||||||
|
<button @click="$emit('edit-filtered')" class="btn btn-primary btn-sm">
|
||||||
|
{{ t('admin.accounts.bulkEdit.submit') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
|
defineProps(['selectedIds']); defineEmits(['delete', 'edit-selected', 'edit-filtered', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -141,7 +141,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
<AccountBulkActionsBar
|
||||||
|
:selected-ids="selIds"
|
||||||
|
@delete="handleBulkDelete"
|
||||||
|
@reset-status="handleBulkResetStatus"
|
||||||
|
@refresh-token="handleBulkRefreshToken"
|
||||||
|
@edit-selected="openBulkEditSelected"
|
||||||
|
@edit-filtered="openBulkEditFiltered"
|
||||||
|
@clear="clearSelection"
|
||||||
|
@select-page="selectPage"
|
||||||
|
@toggle-schedulable="handleBulkToggleSchedulable"
|
||||||
|
/>
|
||||||
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<DataTable
|
<DataTable
|
||||||
ref="dataTableRef"
|
ref="dataTableRef"
|
||||||
@@ -303,7 +313,17 @@
|
|||||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" />
|
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" />
|
||||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
<BulkEditAccountModal
|
||||||
|
:show="showBulkEdit"
|
||||||
|
:account-ids="selIds"
|
||||||
|
:selected-platforms="selPlatforms"
|
||||||
|
:selected-types="selTypes"
|
||||||
|
:target="bulkEditTarget ?? undefined"
|
||||||
|
:proxies="proxies"
|
||||||
|
:groups="groups"
|
||||||
|
@close="showBulkEdit = false"
|
||||||
|
@updated="handleBulkUpdated"
|
||||||
|
/>
|
||||||
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
||||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||||
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
||||||
@@ -364,6 +384,29 @@ const proxies = ref<AccountProxy[]>([])
|
|||||||
const groups = ref<AdminGroup[]>([])
|
const groups = ref<AdminGroup[]>([])
|
||||||
const accountTableRef = ref<HTMLElement | null>(null)
|
const accountTableRef = ref<HTMLElement | null>(null)
|
||||||
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
|
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
|
||||||
|
type AccountBulkEditTarget =
|
||||||
|
| {
|
||||||
|
mode: 'selected'
|
||||||
|
accountIds: number[]
|
||||||
|
selectedPlatforms: AccountPlatform[]
|
||||||
|
selectedTypes: AccountType[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: 'filtered'
|
||||||
|
filters: {
|
||||||
|
platform?: string
|
||||||
|
type?: string
|
||||||
|
status?: string
|
||||||
|
group?: string
|
||||||
|
search?: string
|
||||||
|
privacy_mode?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: AccountSortOrder
|
||||||
|
}
|
||||||
|
previewCount: number
|
||||||
|
selectedPlatforms: AccountPlatform[]
|
||||||
|
selectedTypes: AccountType[]
|
||||||
|
}
|
||||||
const selPlatforms = computed<AccountPlatform[]>(() => {
|
const selPlatforms = computed<AccountPlatform[]>(() => {
|
||||||
const platforms = new Set(
|
const platforms = new Set(
|
||||||
accounts.value
|
accounts.value
|
||||||
@@ -387,6 +430,7 @@ const showImportData = ref(false)
|
|||||||
const showExportDataDialog = ref(false)
|
const showExportDataDialog = ref(false)
|
||||||
const includeProxyOnExport = ref(true)
|
const includeProxyOnExport = ref(true)
|
||||||
const showBulkEdit = ref(false)
|
const showBulkEdit = ref(false)
|
||||||
|
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
|
||||||
const showTempUnsched = ref(false)
|
const showTempUnsched = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
const showReAuth = ref(false)
|
const showReAuth = ref(false)
|
||||||
@@ -1216,7 +1260,57 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
|||||||
appStore.showError(t('common.error'))
|
appStore.showError(t('common.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
|
const buildBulkEditFilterSnapshot = () => {
|
||||||
|
const rawParams = toRaw(params) as Record<string, unknown>
|
||||||
|
const sortOrder: AccountSortOrder = rawParams.sort_order === 'desc' ? 'desc' : 'asc'
|
||||||
|
return {
|
||||||
|
platform: typeof rawParams.platform === 'string' ? rawParams.platform : '',
|
||||||
|
type: typeof rawParams.type === 'string' ? rawParams.type : '',
|
||||||
|
status: typeof rawParams.status === 'string' ? rawParams.status : '',
|
||||||
|
group: typeof rawParams.group === 'string' ? rawParams.group : '',
|
||||||
|
search: typeof rawParams.search === 'string' ? rawParams.search : '',
|
||||||
|
privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '',
|
||||||
|
sort_by: typeof rawParams.sort_by === 'string' ? rawParams.sort_by : '',
|
||||||
|
sort_order: sortOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectSelectionMetadata = (rows: Account[]) => {
|
||||||
|
const selectedPlatforms = Array.from(new Set(rows.map(account => account.platform)))
|
||||||
|
const selectedTypes = Array.from(new Set(rows.map(account => account.type)))
|
||||||
|
return { selectedPlatforms, selectedTypes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBulkEditSelected = () => {
|
||||||
|
bulkEditTarget.value = {
|
||||||
|
mode: 'selected',
|
||||||
|
accountIds: [...selIds.value],
|
||||||
|
selectedPlatforms: [...selPlatforms.value],
|
||||||
|
selectedTypes: [...selTypes.value]
|
||||||
|
}
|
||||||
|
showBulkEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBulkEditFiltered = async () => {
|
||||||
|
const filters = buildBulkEditFilterSnapshot()
|
||||||
|
const preview = await adminAPI.accounts.list(1, 100, filters)
|
||||||
|
const { selectedPlatforms, selectedTypes } = collectSelectionMetadata(preview.items)
|
||||||
|
bulkEditTarget.value = {
|
||||||
|
mode: 'filtered',
|
||||||
|
filters,
|
||||||
|
previewCount: preview.total,
|
||||||
|
selectedPlatforms,
|
||||||
|
selectedTypes
|
||||||
|
}
|
||||||
|
showBulkEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkUpdated = () => {
|
||||||
|
showBulkEdit.value = false
|
||||||
|
bulkEditTarget.value = null
|
||||||
|
clearSelection()
|
||||||
|
reload()
|
||||||
|
}
|
||||||
const handleDataImported = () => { showImportData.value = false; reload() }
|
const handleDataImported = () => { showImportData.value = false; reload() }
|
||||||
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
|
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
|
||||||
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
|
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
|
||||||
|
|||||||
152
frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
Normal file
152
frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import AccountsView from '../AccountsView.vue'
|
||||||
|
|
||||||
|
const {
|
||||||
|
listAccounts,
|
||||||
|
listWithEtag,
|
||||||
|
getBatchTodayStats,
|
||||||
|
getAllProxies,
|
||||||
|
getAllGroups
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
listAccounts: vi.fn(),
|
||||||
|
listWithEtag: vi.fn(),
|
||||||
|
getBatchTodayStats: vi.fn(),
|
||||||
|
getAllProxies: vi.fn(),
|
||||||
|
getAllGroups: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin', () => ({
|
||||||
|
adminAPI: {
|
||||||
|
accounts: {
|
||||||
|
list: listAccounts,
|
||||||
|
listWithEtag,
|
||||||
|
getBatchTodayStats,
|
||||||
|
delete: vi.fn(),
|
||||||
|
batchClearError: vi.fn(),
|
||||||
|
batchRefresh: vi.fn(),
|
||||||
|
toggleSchedulable: vi.fn()
|
||||||
|
},
|
||||||
|
proxies: {
|
||||||
|
getAll: getAllProxies
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
getAll: getAllGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
showError: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showInfo: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth', () => ({
|
||||||
|
useAuthStore: () => ({
|
||||||
|
token: 'test-token'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = {
|
||||||
|
props: ['columns', 'data'],
|
||||||
|
template: '<div data-test="data-table"></div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountBulkActionsBarStub = {
|
||||||
|
props: ['selectedIds'],
|
||||||
|
emits: ['edit-filtered'],
|
||||||
|
template: '<button data-test="edit-filtered" @click="$emit(\'edit-filtered\')">edit filtered</button>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const BulkEditAccountModalStub = {
|
||||||
|
props: ['show', 'target'],
|
||||||
|
template: '<div data-test="bulk-edit-modal" :data-show="String(show)" :data-target-mode="target?.mode ?? \'\'"></div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin AccountsView bulk edit scope', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
|
||||||
|
listAccounts.mockReset()
|
||||||
|
listWithEtag.mockReset()
|
||||||
|
getBatchTodayStats.mockReset()
|
||||||
|
getAllProxies.mockReset()
|
||||||
|
getAllGroups.mockReset()
|
||||||
|
|
||||||
|
listAccounts.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
pages: 0
|
||||||
|
})
|
||||||
|
listWithEtag.mockResolvedValue({
|
||||||
|
notModified: true,
|
||||||
|
etag: null,
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
getBatchTodayStats.mockResolvedValue({ stats: {} })
|
||||||
|
getAllProxies.mockResolvedValue([])
|
||||||
|
getAllGroups.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens bulk edit in filtered-results mode from the bulk actions dropdown', async () => {
|
||||||
|
const wrapper = mount(AccountsView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AppLayout: { template: '<div><slot /></div>' },
|
||||||
|
TablePageLayout: {
|
||||||
|
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||||
|
},
|
||||||
|
DataTable: DataTableStub,
|
||||||
|
Pagination: true,
|
||||||
|
ConfirmDialog: true,
|
||||||
|
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
|
||||||
|
AccountTableFilters: { template: '<div></div>' },
|
||||||
|
AccountBulkActionsBar: AccountBulkActionsBarStub,
|
||||||
|
AccountActionMenu: true,
|
||||||
|
ImportDataModal: true,
|
||||||
|
ReAuthAccountModal: true,
|
||||||
|
AccountTestModal: true,
|
||||||
|
AccountStatsModal: true,
|
||||||
|
ScheduledTestsPanel: true,
|
||||||
|
SyncFromCrsModal: true,
|
||||||
|
TempUnschedStatusModal: true,
|
||||||
|
ErrorPassthroughRulesModal: true,
|
||||||
|
TLSFingerprintProfilesModal: true,
|
||||||
|
CreateAccountModal: true,
|
||||||
|
EditAccountModal: true,
|
||||||
|
BulkEditAccountModal: BulkEditAccountModalStub,
|
||||||
|
PlatformTypeBadge: true,
|
||||||
|
AccountCapacityCell: true,
|
||||||
|
AccountStatusIndicator: true,
|
||||||
|
AccountTodayStatsCell: true,
|
||||||
|
AccountGroupsCell: true,
|
||||||
|
AccountUsageCell: true,
|
||||||
|
Icon: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('[data-test="edit-filtered"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="bulk-edit-modal"]').attributes('data-show')).toBe('true')
|
||||||
|
expect(wrapper.get('[data-test="bulk-edit-modal"]').attributes('data-target-mode')).toBe('filtered')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user