Unverified Commit 27cad10d authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

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
parents ff6fa020 1eca0343
......@@ -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)
}
# 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"
```
# 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'
})
})
})
<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__'
......
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')
})
})
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment