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
|
||||
type BulkUpdateAccountsRequest struct {
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
||||
Name string `json:"name"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
LoadFactor *int `json:"load_factor"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||||
Schedulable *bool `json:"schedulable"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
AccountIDs []int64 `json:"account_ids"`
|
||||
Filters *BulkUpdateAccountFilters `json:"filters"`
|
||||
Name string `json:"name"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
LoadFactor *int `json:"load_factor"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||||
Schedulable *bool `json:"schedulable"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
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
|
||||
@@ -1369,6 +1379,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||||
return
|
||||
}
|
||||
if len(req.AccountIDs) == 0 && req.Filters == nil {
|
||||
response.BadRequest(c, "account_ids or filters is required")
|
||||
return
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
|
||||
@@ -1394,6 +1408,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
|
||||
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
||||
AccountIDs: req.AccountIDs,
|
||||
Filters: toServiceBulkUpdateAccountFilters(req.Filters),
|
||||
Name: req.Name,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
@@ -1429,6 +1444,20 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
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 ==========
|
||||
|
||||
// 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(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"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -291,6 +292,7 @@ type UpdateAccountInput struct {
|
||||
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
|
||||
type BulkUpdateAccountsInput struct {
|
||||
AccountIDs []int64
|
||||
Filters *BulkUpdateAccountFilters
|
||||
Name string
|
||||
ProxyID *int64
|
||||
Concurrency *int
|
||||
@@ -307,6 +309,15 @@ type BulkUpdateAccountsInput struct {
|
||||
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.
|
||||
type BulkUpdateAccountResult struct {
|
||||
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.
|
||||
// It merges credentials/extra keys instead of overwriting the whole object.
|
||||
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{
|
||||
SuccessIDs: 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := s.accountRepo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,8 +5,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -25,6 +27,19 @@ type accountRepoStubForBulkUpdate struct {
|
||||
getByIDCalled []int64
|
||||
listByGroupData map[int64][]Account
|
||||
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) {
|
||||
@@ -73,6 +88,24 @@ func (s *accountRepoStubForBulkUpdate) ListByGroup(_ context.Context, groupID in
|
||||
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。
|
||||
func TestAdminService_BulkUpdateAccounts_AllSuccessIDs(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{}
|
||||
@@ -170,3 +203,46 @@ func TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingCon
|
||||
// No BindGroups should have been called since the check runs before any write.
|
||||
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
|
||||
*/
|
||||
export async function bulkUpdate(
|
||||
accountIds: number[],
|
||||
updates: Record<string, unknown>
|
||||
accountIdsOrPayload: number[] | Record<string, unknown>,
|
||||
updates?: Record<string, unknown>
|
||||
): Promise<{
|
||||
success: number
|
||||
failed: number
|
||||
@@ -379,16 +379,19 @@ export async function bulkUpdate(
|
||||
failed_ids?: number[]
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}> {
|
||||
const payload = Array.isArray(accountIdsOrPayload)
|
||||
? {
|
||||
account_ids: accountIdsOrPayload,
|
||||
...(updates ?? {})
|
||||
}
|
||||
: accountIdsOrPayload
|
||||
const { data } = await apiClient.post<{
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}>('/admin/accounts/bulk-update', {
|
||||
account_ids: accountIds,
|
||||
...updates
|
||||
})
|
||||
}>('/admin/accounts/bulk-update', payload)
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
|
||||
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: targetMode === 'filtered' ? targetPreviewCount : accountIds.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<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" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
|
||||
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: targetSelectedPlatforms.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:platforms="selectedPlatforms"
|
||||
:platforms="targetSelectedPlatforms"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -698,6 +698,87 @@
|
||||
</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 时显示) -->
|
||||
<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">
|
||||
@@ -933,6 +1014,13 @@ interface Props {
|
||||
accountIds: number[]
|
||||
selectedPlatforms: AccountPlatform[]
|
||||
selectedTypes: AccountType[]
|
||||
target?: {
|
||||
mode: 'selected' | 'filtered'
|
||||
filters?: Record<string, unknown>
|
||||
previewCount?: number
|
||||
selectedPlatforms?: AccountPlatform[]
|
||||
selectedTypes?: AccountType[]
|
||||
}
|
||||
proxies: ProxyConfig[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
@@ -947,40 +1035,53 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 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(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'openai' &&
|
||||
props.selectedTypes.length > 0 &&
|
||||
props.selectedTypes.every(t => t === 'oauth' || t === 'apikey')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth' || t === 'apikey')
|
||||
)
|
||||
})
|
||||
|
||||
const allOpenAIOAuth = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'openai' &&
|
||||
props.selectedTypes.length > 0 &&
|
||||
props.selectedTypes.every(t => t === 'oauth')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
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 配置仅在此条件下显示)
|
||||
const allAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'anthropic' &&
|
||||
props.selectedTypes.every(t => t === 'oauth' || t === 'setup-token')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'anthropic' &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth' || t === 'setup-token')
|
||||
)
|
||||
})
|
||||
|
||||
const filteredPresets = computed(() => {
|
||||
if (props.selectedPlatforms.length === 0) return []
|
||||
if (targetSelectedPlatforms.value.length === 0) return []
|
||||
|
||||
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)) {
|
||||
const key = `${preset.from}=>${preset.to}`
|
||||
if (!dedupedPresets.has(key)) {
|
||||
@@ -1012,6 +1113,8 @@ const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableOpenAIPassthrough = ref(false)
|
||||
const enableOpenAIWSMode = ref(false)
|
||||
const enableOpenAIAPIKeyWSMode = ref(false)
|
||||
const enableCodexCLIOnly = ref(false)
|
||||
const enableRpmLimit = ref(false)
|
||||
|
||||
// State - field values
|
||||
@@ -1035,6 +1138,8 @@ const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
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 bulkBaseRpm = ref<number | null>(null)
|
||||
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||
@@ -1076,6 +1181,9 @@ const openAIWSModeOptions = computed(() => [
|
||||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
const openAIAPIKeyWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
|
||||
// Model mapping helpers
|
||||
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 字段)
|
||||
if (enableRpmLimit.value) {
|
||||
const extra = ensureExtra()
|
||||
@@ -1291,8 +1412,8 @@ const mixedChannelConfirmed = ref(false)
|
||||
const canPreCheck = () =>
|
||||
enableGroups.value &&
|
||||
groupIds.value.length > 0 &&
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
(props.selectedPlatforms[0] === 'antigravity' || props.selectedPlatforms[0] === 'anthropic')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
(targetSelectedPlatforms.value[0] === 'antigravity' || targetSelectedPlatforms.value[0] === 'anthropic')
|
||||
|
||||
const handleClose = () => {
|
||||
showMixedChannelWarning.value = false
|
||||
@@ -1309,7 +1430,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||||
platform: props.selectedPlatforms[0],
|
||||
platform: targetSelectedPlatforms.value[0],
|
||||
group_ids: groupIds.value
|
||||
})
|
||||
if (!result.has_risk) return true
|
||||
@@ -1325,7 +1446,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (props.accountIds.length === 0) {
|
||||
if (targetMode.value === 'selected' && props.accountIds.length === 0) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||
return
|
||||
}
|
||||
@@ -1344,6 +1465,8 @@ const handleSubmit = async () => {
|
||||
enableStatus.value ||
|
||||
enableGroups.value ||
|
||||
enableOpenAIWSMode.value ||
|
||||
enableOpenAIAPIKeyWSMode.value ||
|
||||
enableCodexCLIOnly.value ||
|
||||
enableRpmLimit.value ||
|
||||
userMsgQueueMode.value !== null
|
||||
|
||||
@@ -1373,7 +1496,12 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
submitting.value = true
|
||||
|
||||
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 failed = res.failed || 0
|
||||
|
||||
@@ -1437,6 +1565,8 @@ watch(
|
||||
enableGroups.value = false
|
||||
enableOpenAIPassthrough.value = false
|
||||
enableOpenAIWSMode.value = false
|
||||
enableOpenAIAPIKeyWSMode.value = false
|
||||
enableCodexCLIOnly.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
// Reset all values
|
||||
@@ -1456,6 +1586,8 @@ watch(
|
||||
status.value = 'active'
|
||||
groupIds.value = []
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
rpmLimitEnabled.value = false
|
||||
bulkBaseRpm.value = null
|
||||
bulkRpmStrategy.value = 'tiered'
|
||||
|
||||
@@ -178,6 +178,45 @@ describe('BulkEditAccountModal', () => {
|
||||
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 () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
@@ -217,4 +256,41 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
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>
|
||||
<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">
|
||||
<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 }) }}
|
||||
</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
|
||||
@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"
|
||||
@@ -17,19 +21,25 @@
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
||||
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
@@ -141,7 +141,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
<DataTable
|
||||
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" />
|
||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||
<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" />
|
||||
<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">
|
||||
@@ -364,6 +384,29 @@ const proxies = ref<AccountProxy[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const accountTableRef = ref<HTMLElement | 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 platforms = new Set(
|
||||
accounts.value
|
||||
@@ -387,6 +430,7 @@ const showImportData = ref(false)
|
||||
const showExportDataDialog = ref(false)
|
||||
const includeProxyOnExport = ref(true)
|
||||
const showBulkEdit = ref(false)
|
||||
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
|
||||
const showTempUnsched = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showReAuth = ref(false)
|
||||
@@ -1216,7 +1260,57 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
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 ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
|
||||
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