diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index e69e056f..2d00ccc6 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -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
diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
index 24ec5bcf..929dc240 100644
--- a/backend/internal/handler/admin/account_handler_mixed_channel_test.go
+++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
@@ -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"])
+}
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index 434f1f38..86777dc9 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -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
diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go
index 4845d87c..df415295 100644
--- a/backend/internal/service/admin_service_bulk_update_test.go
+++ b/backend/internal/service/admin_service_bulk_update_test.go
@@ -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)
+}
diff --git a/docs/superpowers/plans/2026-04-27-account-bulk-edit-scope-and-compact.md b/docs/superpowers/plans/2026-04-27-account-bulk-edit-scope-and-compact.md
new file mode 100644
index 00000000..42b76664
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-27-account-bulk-edit-scope-and-compact.md
@@ -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
+
+```
+
+- [ ] **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
+
+
+
+
+
+
+
+
+```
+
+- [ ] **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"
+```
diff --git a/docs/superpowers/specs/2026-04-27-account-bulk-edit-scope-and-compact-design.md b/docs/superpowers/specs/2026-04-27-account-bulk-edit-scope-and-compact-design.md
new file mode 100644
index 00000000..3a1dc5ac
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-27-account-bulk-edit-scope-and-compact-design.md
@@ -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.
diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts
index a146f1f7..8a127793 100644
--- a/frontend/src/api/admin/accounts.ts
+++ b/frontend/src/api/admin/accounts.ts
@@ -370,8 +370,8 @@ export async function batchUpdateCredentials(request: {
* @returns Success confirmation
*/
export async function bulkUpdate(
- accountIds: number[],
- updates: Record
+ accountIdsOrPayload: number[] | Record,
+ updates?: Record
): 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
}
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 13c30cf9..05016a6d 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -17,7 +17,7 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
- {{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
+ {{ t('admin.accounts.bulkEdit.selectionInfo', { count: targetMode === 'filtered' ? targetPreviewCount : accountIds.length }) }}
@@ -27,7 +27,7 @@
- {{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
+ {{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: targetSelectedPlatforms.join(', ') }) }}
@@ -227,7 +227,7 @@