From 65c27d2c6948898322dcc26af0d9b968616ed5a0 Mon Sep 17 00:00:00 2001 From: KnowSky404 Date: Mon, 27 Apr 2026 17:21:11 +0800 Subject: [PATCH 01/10] docs: add account bulk edit scope design --- ...ount-bulk-edit-scope-and-compact-design.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-account-bulk-edit-scope-and-compact-design.md 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. From 54de4e008cb3e33d54242473ba19618b1d9fb575 Mon Sep 17 00:00:00 2001 From: KnowSky404 Date: Mon, 27 Apr 2026 17:26:57 +0800 Subject: [PATCH 02/10] docs: add account bulk edit implementation plan --- ...-27-account-bulk-edit-scope-and-compact.md | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-account-bulk-edit-scope-and-compact.md 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" +``` From f422ac6dccf27a2310d510be1fc4c8b8a7a1e78a Mon Sep 17 00:00:00 2001 From: KnowSky404 Date: Mon, 27 Apr 2026 17:32:34 +0800 Subject: [PATCH 03/10] test: cover filter-target account bulk update --- .../account_handler_mixed_channel_test.go | 26 +++++++ .../service/admin_service_bulk_update_test.go | 76 +++++++++++++++++++ 2 files changed, 102 insertions(+) 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_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) +} From 25c7b0d9f40e2609683702ebe4ac2a04d392a225 Mon Sep 17 00:00:00 2001 From: KnowSky404 Date: Mon, 27 Apr 2026 17:59:49 +0800 Subject: [PATCH 04/10] feat: support filter-target account bulk update --- .../internal/handler/admin/account_handler.go | 31 ++++++++- backend/internal/service/admin_service.go | 68 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 7454451a..3c97c753 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -134,7 +134,8 @@ type UpdateAccountRequest struct { // BulkUpdateAccountsRequest represents the payload for bulk editing accounts type BulkUpdateAccountsRequest struct { - AccountIDs []int64 `json:"account_ids" binding:"required,min=1"` + AccountIDs []int64 `json:"account_ids"` + Filters *BulkUpdateAccountFilters `json:"filters"` Name string `json:"name"` ProxyID *int64 `json:"proxy_id"` Concurrency *int `json:"concurrency"` @@ -149,6 +150,15 @@ type BulkUpdateAccountsRequest struct { 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 type CheckMixedChannelRequest struct { Platform string `json:"platform" binding:"required"` @@ -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/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 From 764afbe37a9115279fd68f67ef85e02fe35244ff Mon Sep 17 00:00:00 2001 From: KnowSky404 Date: Mon, 27 Apr 2026 18:08:22 +0800 Subject: [PATCH 05/10] test: cover account bulk edit target scopes --- .../__tests__/BulkEditAccountModal.spec.ts | 37 +++++ .../__tests__/AccountsView.bulkEdit.spec.ts | 152 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 7390e723..2e360978 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -217,4 +217,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' + }) + }) }) diff --git a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts new file mode 100644 index 00000000..112baf22 --- /dev/null +++ b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import AccountsView from '../AccountsView.vue' + +const { + listAccounts, + listWithEtag, + getBatchTodayStats, + getAllProxies, + getAllGroups +} = vi.hoisted(() => ({ + listAccounts: vi.fn(), + listWithEtag: vi.fn(), + getBatchTodayStats: vi.fn(), + getAllProxies: vi.fn(), + getAllGroups: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + list: listAccounts, + listWithEtag, + getBatchTodayStats, + delete: vi.fn(), + batchClearError: vi.fn(), + batchRefresh: vi.fn(), + toggleSchedulable: vi.fn() + }, + proxies: { + getAll: getAllProxies + }, + groups: { + getAll: getAllGroups + } + } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn() + }) +})) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + token: 'test-token' + }) +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +const DataTableStub = { + props: ['columns', 'data'], + template: '
' +} + +const AccountBulkActionsBarStub = { + props: ['selectedIds'], + emits: ['edit-filtered'], + template: '' +} + +const BulkEditAccountModalStub = { + props: ['show', 'target'], + template: '
' +} + +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: '
' }, + TablePageLayout: { + template: '
' + }, + DataTable: DataTableStub, + Pagination: true, + ConfirmDialog: true, + AccountTableActions: { template: '
' }, + AccountTableFilters: { template: '
' }, + 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') + }) +}) From 2ab6b34fd1f980588582d961917bbcb4c64c4823 Mon Sep 17 00:00:00 2001 From: KnowSky404 Date: Mon, 27 Apr 2026 18:12:24 +0800 Subject: [PATCH 06/10] feat: add filtered-result account bulk edit --- frontend/src/api/admin/accounts.ts | 15 +-- .../account/BulkEditAccountModal.vue | 69 ++++++++----- .../admin/account/AccountBulkActionsBar.vue | 28 ++++-- frontend/src/views/admin/AccountsView.vue | 99 ++++++++++++++++++- 4 files changed, 171 insertions(+), 40 deletions(-) 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..b55456ff 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 @@

@@ -933,6 +933,13 @@ interface Props { accountIds: number[] selectedPlatforms: AccountPlatform[] selectedTypes: AccountType[] + target?: { + mode: 'selected' | 'filtered' + filters?: Record + previewCount?: number + selectedPlatforms?: AccountPlatform[] + selectedTypes?: AccountType[] + } proxies: ProxyConfig[] groups: AdminGroup[] } @@ -947,40 +954,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[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)) { @@ -1291,8 +1311,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 +1329,7 @@ const preCheckMixedChannelRisk = async (built: Record): 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 +1345,7 @@ const preCheckMixedChannelRisk = async (built: Record): 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 } @@ -1373,7 +1393,12 @@ const submitBulkUpdate = async (baseUpdates: Record) => { 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 diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue index 3b987bd0..a632bdd4 100644 --- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue +++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue @@ -1,9 +1,13 @@ diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index bc4c6215..2f061118 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -141,7 +141,17 @@