mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #5101 from Tongzai123/feat/admin-select-all-filtered-results
feat(admin): 支持按筛选结果全选账号
This commit is contained in:
@@ -1531,6 +1531,141 @@ func (h *AccountHandler) RevertProxyFallback(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "reverted"})
|
||||
}
|
||||
|
||||
// BatchDelete handles deleting multiple accounts with bounded concurrency.
|
||||
// POST /api/v1/admin/accounts/batch-delete
|
||||
func (h *AccountHandler) BatchDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
AccountIDs []int64 `json:"account_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||||
if len(accountIDs) == 0 {
|
||||
response.BadRequest(c, "account_ids is required")
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
type deleteError struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
requestedIDs := make(map[int64]struct{}, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
requestedIDs[accountID] = struct{}{}
|
||||
}
|
||||
accountsByID := make(map[int64]*service.Account, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if account != nil {
|
||||
accountsByID[account.ID] = account
|
||||
}
|
||||
}
|
||||
|
||||
rootIDs := make([]int64, 0, len(accountIDs))
|
||||
dependentIDs := make(map[int64][]int64)
|
||||
failedIDs := make([]int64, 0)
|
||||
errorsByAccount := make([]deleteError, 0)
|
||||
for _, accountID := range accountIDs {
|
||||
account := accountsByID[accountID]
|
||||
if account == nil {
|
||||
failedIDs = append(failedIDs, accountID)
|
||||
errorsByAccount = append(errorsByAccount, deleteError{
|
||||
AccountID: accountID,
|
||||
Error: "account not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
rootID := accountID
|
||||
visited := map[int64]struct{}{accountID: {}}
|
||||
for {
|
||||
current := accountsByID[rootID]
|
||||
if current == nil || current.ParentAccountID == nil {
|
||||
break
|
||||
}
|
||||
parentID := *current.ParentAccountID
|
||||
if _, selected := requestedIDs[parentID]; !selected {
|
||||
break
|
||||
}
|
||||
if _, exists := accountsByID[parentID]; !exists {
|
||||
break
|
||||
}
|
||||
if _, cyclic := visited[parentID]; cyclic {
|
||||
rootID = accountID
|
||||
break
|
||||
}
|
||||
visited[parentID] = struct{}{}
|
||||
rootID = parentID
|
||||
}
|
||||
|
||||
if rootID != accountID {
|
||||
dependentIDs[rootID] = append(dependentIDs[rootID], accountID)
|
||||
continue
|
||||
}
|
||||
rootIDs = append(rootIDs, accountID)
|
||||
}
|
||||
|
||||
const maxConcurrency = 5
|
||||
g, gctx := errgroup.WithContext(c.Request.Context())
|
||||
g.SetLimit(maxConcurrency)
|
||||
|
||||
var mu sync.Mutex
|
||||
successIDs := make([]int64, 0, len(accountIDs))
|
||||
|
||||
// Every worker returns nil so one account failure does not cancel the remaining deletions.
|
||||
for _, id := range rootIDs {
|
||||
accountID := id
|
||||
g.Go(func() error {
|
||||
err := h.adminService.DeleteAccount(gctx, accountID)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...)
|
||||
if err != nil {
|
||||
for _, affectedID := range affectedIDs {
|
||||
failedIDs = append(failedIDs, affectedID)
|
||||
errorsByAccount = append(errorsByAccount, deleteError{
|
||||
AccountID: affectedID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
successIDs = append(successIDs, affectedIDs...)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] })
|
||||
sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] })
|
||||
sort.Slice(errorsByAccount, func(i, j int) bool {
|
||||
return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID
|
||||
})
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"total": len(accountIDs),
|
||||
"success": len(successIDs),
|
||||
"failed": len(failedIDs),
|
||||
"success_ids": successIDs,
|
||||
"failed_ids": failedIDs,
|
||||
"errors": errorsByAccount,
|
||||
})
|
||||
}
|
||||
|
||||
// BatchClearError handles batch clearing account errors
|
||||
// POST /api/v1/admin/accounts/batch-clear-error
|
||||
func (h *AccountHandler) BatchClearError(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type batchDeleteAdminService struct {
|
||||
*stubAdminService
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
deletedIDs []int64
|
||||
deleteErrorsByID map[int64]error
|
||||
accountsByID map[int64]*service.Account
|
||||
}
|
||||
|
||||
func (s *batchDeleteAdminService) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) {
|
||||
accounts := make([]*service.Account, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if s.accountsByID != nil {
|
||||
if account, ok := s.accountsByID[id]; ok {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, &service.Account{ID: id})
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (s *batchDeleteAdminService) DeleteAccount(ctx context.Context, id int64) error {
|
||||
s.mu.Lock()
|
||||
s.active++
|
||||
if s.active > s.maxActive {
|
||||
s.maxActive = s.active
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.active--
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
return s.deleteErrorsByID[id]
|
||||
}
|
||||
|
||||
func setupAccountBatchDeleteRouter(adminSvc *batchDeleteAdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/batch-delete", handler.BatchDelete)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestAccountHandlerBatchDeleteReturnsStablePerAccountResults(t *testing.T) {
|
||||
adminSvc := &batchDeleteAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
deleteErrorsByID: map[int64]error{
|
||||
3: errors.New("delete failed"),
|
||||
},
|
||||
}
|
||||
router := setupAccountBatchDeleteRouter(adminSvc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/batch-delete",
|
||||
bytes.NewBufferString(`{"account_ids":[5,4,3,2,1,2,0,-1]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Total int `json:"total"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
SuccessIDs []int64 `json:"success_ids"`
|
||||
FailedIDs []int64 `json:"failed_ids"`
|
||||
Errors []struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Error string `json:"error"`
|
||||
} `json:"errors"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, 5, payload.Data.Total)
|
||||
require.Equal(t, 4, payload.Data.Success)
|
||||
require.Equal(t, 1, payload.Data.Failed)
|
||||
require.Equal(t, []int64{1, 2, 4, 5}, payload.Data.SuccessIDs)
|
||||
require.Equal(t, []int64{3}, payload.Data.FailedIDs)
|
||||
require.Equal(t, int64(3), payload.Data.Errors[0].AccountID)
|
||||
require.Equal(t, "delete failed", payload.Data.Errors[0].Error)
|
||||
require.LessOrEqual(t, adminSvc.maxActive, 5)
|
||||
require.Greater(t, adminSvc.maxActive, 1)
|
||||
}
|
||||
|
||||
func TestAccountHandlerBatchDeleteDoesNotRaceSelectedShadowWithParent(t *testing.T) {
|
||||
parentID := int64(1)
|
||||
adminSvc := &batchDeleteAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
accountsByID: map[int64]*service.Account{
|
||||
1: {ID: 1},
|
||||
2: {ID: 2, ParentAccountID: &parentID},
|
||||
3: {ID: 3},
|
||||
},
|
||||
}
|
||||
router := setupAccountBatchDeleteRouter(adminSvc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/batch-delete",
|
||||
bytes.NewBufferString(`{"account_ids":[1,2,3]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
SuccessIDs []int64 `json:"success_ids"`
|
||||
FailedIDs []int64 `json:"failed_ids"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, []int64{1, 2, 3}, payload.Data.SuccessIDs)
|
||||
require.Empty(t, payload.Data.FailedIDs)
|
||||
require.ElementsMatch(t, []int64{1, 3}, adminSvc.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAccountHandlerBatchDeleteRejectsEmptyNormalizedIDs(t *testing.T) {
|
||||
adminSvc := &batchDeleteAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
}
|
||||
router := setupAccountBatchDeleteRouter(adminSvc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/batch-delete",
|
||||
bytes.NewBufferString(`{"account_ids":[0,-1]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
@@ -395,6 +395,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
||||
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
|
||||
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
||||
accounts.POST("/batch-delete", h.Admin.Account.BatchDelete)
|
||||
accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError)
|
||||
accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh)
|
||||
|
||||
|
||||
@@ -715,6 +715,8 @@ export interface BatchOperationResult {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
errors?: Array<{ account_id: number; error: string }>
|
||||
warnings?: Array<{ account_id: number; warning: string }>
|
||||
}
|
||||
@@ -729,6 +731,16 @@ export async function revertProxyFallback(id: number): Promise<{ message: string
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple accounts with bounded server-side concurrency.
|
||||
*/
|
||||
export async function batchDelete(accountIds: number[]): Promise<BatchOperationResult> {
|
||||
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-delete', {
|
||||
account_ids: accountIds
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch clear account errors
|
||||
* @param accountIds - Array of account IDs
|
||||
@@ -968,6 +980,7 @@ export const accountsAPI = {
|
||||
importCodexSession,
|
||||
createOpenAICodexPAT,
|
||||
getAntigravityDefaultModelMapping,
|
||||
batchDelete,
|
||||
batchClearError,
|
||||
batchRefresh,
|
||||
setPrivacy,
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
<template>
|
||||
<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 v-if="selectedIds.length > 0" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
<span v-if="allResultsSelected" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selectedAll', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<span v-else-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"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="$emit('clear')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="!allResultsSelected && totalResults > selectedIds.length">
|
||||
<span v-if="selectedIds.length > 0" class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
:disabled="selectingAll"
|
||||
@click="$emit('select-all-results')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 disabled:cursor-not-allowed disabled:opacity-60 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{
|
||||
selectingAll
|
||||
? t('admin.accounts.bulkActions.selectingAll')
|
||||
: t('admin.accounts.bulkActions.selectAllResults', { count: totalResults })
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="$emit('clear')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -43,13 +62,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{ selectedIds: number[] }>()
|
||||
defineProps<{
|
||||
selectedIds: number[]
|
||||
totalResults: number
|
||||
selectingAll: boolean
|
||||
allResultsSelected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits([
|
||||
'delete',
|
||||
'edit-selected',
|
||||
'edit-filtered',
|
||||
'clear',
|
||||
'select-page',
|
||||
'select-all-results',
|
||||
'toggle-schedulable',
|
||||
'reset-status',
|
||||
'refresh-token',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import AccountBulkActionsBar from '../AccountBulkActionsBar.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('AccountBulkActionsBar', () => {
|
||||
it('allows selecting all results before any row is selected', async () => {
|
||||
const wrapper = mount(AccountBulkActionsBar, {
|
||||
props: {
|
||||
selectedIds: [],
|
||||
totalResults: 45,
|
||||
selectingAll: false,
|
||||
allResultsSelected: false
|
||||
}
|
||||
})
|
||||
|
||||
const button = wrapper.findAll('button').find(item =>
|
||||
item.text().includes('admin.accounts.bulkActions.selectAllResults')
|
||||
)
|
||||
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
expect(wrapper.emitted('select-all-results')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves the upstream billing probe action from v0.1.166', async () => {
|
||||
const wrapper = mount(AccountBulkActionsBar, {
|
||||
props: {
|
||||
selectedIds: [1],
|
||||
totalResults: 45,
|
||||
selectingAll: false,
|
||||
allResultsSelected: false
|
||||
}
|
||||
})
|
||||
|
||||
const button = wrapper.findAll('button').find(item =>
|
||||
item.text().includes('admin.accounts.bulkActions.probeUpstreamBilling')
|
||||
)
|
||||
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
expect(wrapper.emitted('probe-upstream-billing')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -388,10 +388,16 @@ export default {
|
||||
bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.',
|
||||
bulkActions: {
|
||||
selected: '{count} account(s) selected',
|
||||
selectedAll: 'All {count} account(s) selected',
|
||||
selectCurrentPage: 'Select this page',
|
||||
selectAllResults: 'Select all results ({count})',
|
||||
selectingAll: 'Selecting all results...',
|
||||
selectAllFailed: 'Failed to load all accounts. The previous selection was kept.',
|
||||
clear: 'Clear selection',
|
||||
edit: 'Bulk Edit',
|
||||
delete: 'Bulk Delete',
|
||||
confirmDelete: 'Delete the selected {count} account(s)? This action cannot be undone.',
|
||||
deleteSuccess: 'Deleted {count} account(s)',
|
||||
enableScheduling: 'Enable Scheduling',
|
||||
disableScheduling: 'Disable Scheduling',
|
||||
resetStatus: 'Reset Status',
|
||||
|
||||
@@ -457,10 +457,16 @@ export default {
|
||||
bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表',
|
||||
bulkActions: {
|
||||
selected: '已选择 {count} 个账号',
|
||||
selectedAll: '已选择全部 {count} 个账号',
|
||||
selectCurrentPage: '本页全选',
|
||||
selectAllResults: '全选所有结果({count})',
|
||||
selectingAll: '正在选择全部结果...',
|
||||
selectAllFailed: '获取全部账号失败,原有选择未改变',
|
||||
clear: '清除选择',
|
||||
edit: '批量编辑账号',
|
||||
delete: '批量删除',
|
||||
confirmDelete: '确认删除选中的 {count} 个账号吗?此操作不可恢复。',
|
||||
deleteSuccess: '已成功删除 {count} 个账号',
|
||||
enableScheduling: '批量启用调度',
|
||||
disableScheduling: '批量停止调度',
|
||||
resetStatus: '批量重置状态',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchAllAccountIds } from '../accountSelection'
|
||||
|
||||
describe('fetchAllAccountIds', () => {
|
||||
it('loads every page with the same filter snapshot and returns unique account IDs', async () => {
|
||||
const fetchPage = vi.fn(async (page: number, pageSize: number, _filters: Record<string, unknown>) => {
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, 2505)
|
||||
return {
|
||||
items: Array.from({ length: end - start + 1 }, (_, index) => ({ id: start + index })),
|
||||
total: 2505,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
pages: 3
|
||||
}
|
||||
})
|
||||
|
||||
const filters = {
|
||||
platform: 'grok',
|
||||
status: 'active',
|
||||
search: 'example'
|
||||
}
|
||||
|
||||
const ids = await fetchAllAccountIds(fetchPage, filters)
|
||||
|
||||
expect(ids).toHaveLength(2505)
|
||||
expect(ids[0]).toBe(1)
|
||||
expect(ids[2504]).toBe(2505)
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3)
|
||||
expect(fetchPage).toHaveBeenNthCalledWith(1, 1, 1000, {
|
||||
...filters,
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
})
|
||||
expect(fetchPage).toHaveBeenNthCalledWith(3, 3, 1000, {
|
||||
...filters,
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an incomplete or duplicated result instead of returning a partial selection', async () => {
|
||||
const fetchPage = vi.fn().mockResolvedValue({
|
||||
items: [{ id: 1 }, { id: 1 }],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 1
|
||||
})
|
||||
|
||||
await expect(fetchAllAccountIds(fetchPage, {})).rejects.toThrow('账号列表结果不完整')
|
||||
})
|
||||
|
||||
it('propagates a later page failure', async () => {
|
||||
const fetchPage = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 1000 }, (_, index) => ({ id: index + 1 })),
|
||||
total: 1001,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 2
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('page 2 failed'))
|
||||
|
||||
await expect(fetchAllAccountIds(fetchPage, { group: '7' })).rejects.toThrow('page 2 failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
interface AccountIDRow {
|
||||
id: number
|
||||
}
|
||||
|
||||
interface AccountListPage {
|
||||
items: AccountIDRow[]
|
||||
total: number
|
||||
pages?: number
|
||||
}
|
||||
|
||||
type AccountPageFetcher = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
filters: Record<string, unknown>
|
||||
) => Promise<AccountListPage>
|
||||
|
||||
const SELECT_ALL_PAGE_SIZE = 1000
|
||||
|
||||
export async function fetchAllAccountIds(
|
||||
fetchPage: AccountPageFetcher,
|
||||
filters: Record<string, unknown>
|
||||
): Promise<number[]> {
|
||||
const requestFilters = {
|
||||
...filters,
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
}
|
||||
const firstPage = await fetchPage(1, SELECT_ALL_PAGE_SIZE, requestFilters)
|
||||
const pageCount = Math.max(
|
||||
firstPage.pages ?? 0,
|
||||
Math.ceil(firstPage.total / SELECT_ALL_PAGE_SIZE)
|
||||
)
|
||||
const ids = firstPage.items.map(account => account.id)
|
||||
|
||||
for (let page = 2; page <= pageCount; page++) {
|
||||
const result = await fetchPage(page, SELECT_ALL_PAGE_SIZE, requestFilters)
|
||||
ids.push(...result.items.map(account => account.id))
|
||||
}
|
||||
|
||||
const uniqueIDs = Array.from(new Set(ids))
|
||||
if (uniqueIDs.length !== firstPage.total) {
|
||||
throw new Error('账号列表结果不完整')
|
||||
}
|
||||
return uniqueIDs
|
||||
}
|
||||
@@ -177,6 +177,9 @@
|
||||
<template #table>
|
||||
<AccountBulkActionsBar
|
||||
:selected-ids="selIds"
|
||||
:total-results="pagination.total"
|
||||
:selecting-all="selectingAllResults"
|
||||
:all-results-selected="allResultsSelected"
|
||||
@delete="handleBulkDelete"
|
||||
@reset-status="handleBulkResetStatus"
|
||||
@refresh-token="handleBulkRefreshToken"
|
||||
@@ -185,6 +188,7 @@
|
||||
@edit-filtered="openBulkEditFiltered"
|
||||
@clear="clearSelection"
|
||||
@select-page="selectPage"
|
||||
@select-all-results="handleSelectAllResults"
|
||||
@toggle-schedulable="handleBulkToggleSchedulable"
|
||||
/>
|
||||
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -505,6 +509,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
|
||||
import { fetchAllAccountIds } from '@/utils/accountSelection'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
@@ -913,6 +918,7 @@ const {
|
||||
})
|
||||
|
||||
const {
|
||||
selectedSet,
|
||||
selectedIds: selIds,
|
||||
allVisibleSelected,
|
||||
isSelected,
|
||||
@@ -920,16 +926,36 @@ const {
|
||||
select,
|
||||
deselect,
|
||||
toggle: toggleSel,
|
||||
clear: clearSelection,
|
||||
clear: clearSelectedIds,
|
||||
removeMany: removeSelectedAccounts,
|
||||
toggleVisible,
|
||||
selectVisible: selectPage,
|
||||
selectVisible: selectCurrentPage,
|
||||
batchUpdate
|
||||
} = useTableSelection<Account>({
|
||||
rows: accounts,
|
||||
getId: (account) => account.id
|
||||
})
|
||||
|
||||
const selectingAllResults = ref(false)
|
||||
const selectedAllResultIDs = ref<Set<number> | null>(null)
|
||||
const selectionRequestVersion = ref(0)
|
||||
const allResultsSelected = computed(() => {
|
||||
const snapshot = selectedAllResultIDs.value
|
||||
if (!snapshot || snapshot.size === 0 || snapshot.size !== selectedSet.value.size) return false
|
||||
return Array.from(snapshot).every(id => selectedSet.value.has(id))
|
||||
})
|
||||
|
||||
const clearSelection = () => {
|
||||
selectionRequestVersion.value++
|
||||
selectingAllResults.value = false
|
||||
selectedAllResultIDs.value = null
|
||||
clearSelectedIds()
|
||||
}
|
||||
|
||||
const selectPage = () => {
|
||||
selectCurrentPage()
|
||||
}
|
||||
|
||||
const swipeVirtualContext: SwipeSelectVirtualContext = {
|
||||
getVirtualizer: () => dataTableRef.value?.virtualizer ?? null,
|
||||
getSortedData: () => dataTableRef.value?.sortedData ?? accounts.value,
|
||||
@@ -997,6 +1023,7 @@ const refreshUpstreamBillingSortedList = async (force = false) => {
|
||||
}
|
||||
|
||||
const debouncedReload = () => {
|
||||
clearSelection()
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
@@ -1491,7 +1518,27 @@ const toggleSelectAllVisible = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
toggleVisible(target.checked)
|
||||
}
|
||||
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
||||
const handleBulkDelete = async () => {
|
||||
const accountIds = [...selIds.value]
|
||||
if (!confirm(t('admin.accounts.bulkActions.confirmDelete', { count: accountIds.length }))) return
|
||||
try {
|
||||
const result = await adminAPI.accounts.batchDelete(accountIds)
|
||||
if (result.failed > 0) {
|
||||
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', {
|
||||
success: result.success,
|
||||
failed: result.failed
|
||||
}))
|
||||
setSelectedIds(result.failed_ids?.length ? result.failed_ids : accountIds)
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.bulkActions.deleteSuccess', { count: result.success }))
|
||||
clearSelection()
|
||||
}
|
||||
await reload()
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk delete accounts:', error)
|
||||
appStore.showError(String(error))
|
||||
}
|
||||
}
|
||||
const handleBulkResetStatus = async () => {
|
||||
if (!confirm(t('common.confirm'))) return
|
||||
try {
|
||||
@@ -1675,6 +1722,32 @@ const buildBulkEditFilterSnapshot = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAllResults = async () => {
|
||||
if (selectingAllResults.value || pagination.total === 0) return
|
||||
|
||||
const requestVersion = ++selectionRequestVersion.value
|
||||
const filters = buildBulkEditFilterSnapshot()
|
||||
selectingAllResults.value = true
|
||||
try {
|
||||
const ids = await fetchAllAccountIds(
|
||||
(page, pageSize, requestFilters) => adminAPI.accounts.list(page, pageSize, requestFilters),
|
||||
filters
|
||||
)
|
||||
if (requestVersion !== selectionRequestVersion.value) return
|
||||
|
||||
setSelectedIds(ids)
|
||||
selectedAllResultIDs.value = new Set(ids)
|
||||
} catch (error) {
|
||||
if (requestVersion !== selectionRequestVersion.value) return
|
||||
console.error('Failed to select all account results:', error)
|
||||
appStore.showError(t('admin.accounts.bulkActions.selectAllFailed'))
|
||||
} finally {
|
||||
if (requestVersion === selectionRequestVersion.value) {
|
||||
selectingAllResults.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import AccountsView from '../AccountsView.vue'
|
||||
|
||||
const {
|
||||
listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
getAllProxies,
|
||||
getAllGroups,
|
||||
showError
|
||||
} = vi.hoisted(() => ({
|
||||
listAccounts: vi.fn(),
|
||||
listWithEtag: vi.fn(),
|
||||
getBatchTodayStats: vi.fn(),
|
||||
getUpstreamBillingProbeSettings: vi.fn(),
|
||||
getAllProxies: vi.fn(),
|
||||
getAllGroups: vi.fn(),
|
||||
showError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
list: listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
batchDelete: vi.fn(),
|
||||
batchClearError: vi.fn(),
|
||||
batchRefresh: vi.fn(),
|
||||
bulkUpdate: vi.fn()
|
||||
},
|
||||
proxies: {
|
||||
getAll: getAllProxies
|
||||
},
|
||||
groups: {
|
||||
getAll: getAllGroups
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
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 makeAccounts = (count: number) => Array.from({ length: count }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `account-${index + 1}`,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
status: 'active',
|
||||
schedulable: true,
|
||||
created_at: '2026-07-23T00:00:00Z',
|
||||
updated_at: '2026-07-23T00:00:00Z'
|
||||
}))
|
||||
|
||||
const AccountBulkActionsBarStub = {
|
||||
props: ['selectedIds', 'totalResults', 'selectingAll', 'allResultsSelected'],
|
||||
emits: ['select-all-results', 'select-page', 'clear'],
|
||||
template: `
|
||||
<div>
|
||||
<span data-test="selected-count">{{ selectedIds.length }}</span>
|
||||
<span data-test="total-results">{{ totalResults }}</span>
|
||||
<span data-test="all-results-selected">{{ String(allResultsSelected) }}</span>
|
||||
<button data-test="select-page" @click="$emit('select-page')">select page</button>
|
||||
<button data-test="select-all-results" @click="$emit('select-all-results')">select all</button>
|
||||
<button data-test="clear" @click="$emit('clear')">clear</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const AccountTableFiltersStub = {
|
||||
emits: ['change'],
|
||||
template: '<button data-test="change-filter" @click="$emit(\'change\')">change filter</button>'
|
||||
}
|
||||
|
||||
const mountView = () => mount(AccountsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
},
|
||||
DataTable: { props: ['data'], template: '<div data-test="data-table"></div>' },
|
||||
Pagination: true,
|
||||
ConfirmDialog: true,
|
||||
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
|
||||
AccountTableFilters: AccountTableFiltersStub,
|
||||
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: true,
|
||||
PlatformTypeBadge: true,
|
||||
AccountCapacityCell: true,
|
||||
AccountStatusIndicator: true,
|
||||
AccountTodayStatsCell: true,
|
||||
AccountGroupsCell: true,
|
||||
AccountUsageCell: true,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin AccountsView select all filtered results', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
listAccounts.mockReset()
|
||||
listWithEtag.mockReset()
|
||||
getBatchTodayStats.mockReset()
|
||||
getUpstreamBillingProbeSettings.mockReset()
|
||||
getAllProxies.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
showError.mockReset()
|
||||
|
||||
listWithEtag.mockResolvedValue({
|
||||
notModified: true,
|
||||
etag: null,
|
||||
data: null
|
||||
})
|
||||
getBatchTodayStats.mockResolvedValue({ stats: {} })
|
||||
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
|
||||
getAllProxies.mockResolvedValue([])
|
||||
getAllGroups.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('selects all matching IDs in one commit and clears the selection when filters change', async () => {
|
||||
const allAccounts = makeAccounts(45)
|
||||
listAccounts.mockImplementation(async (_page: number, pageSize: number) => {
|
||||
if (pageSize === 1000) {
|
||||
return {
|
||||
items: allAccounts,
|
||||
total: 45,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: allAccounts.slice(0, 20),
|
||||
total: 45,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 3
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="select-all-results"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('45')
|
||||
expect(wrapper.get('[data-test="total-results"]').text()).toBe('45')
|
||||
expect(wrapper.get('[data-test="all-results-selected"]').text()).toBe('true')
|
||||
expect(listAccounts).toHaveBeenCalledWith(1, 1000, expect.objectContaining({
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
}))
|
||||
|
||||
await wrapper.get('[data-test="change-filter"]').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('0')
|
||||
expect(wrapper.get('[data-test="all-results-selected"]').text()).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps the original page selection when loading all results fails', async () => {
|
||||
const currentPage = makeAccounts(20)
|
||||
listAccounts.mockImplementation(async (_page: number, pageSize: number) => {
|
||||
if (pageSize === 1000) {
|
||||
throw new Error('load all failed')
|
||||
}
|
||||
return {
|
||||
items: currentPage,
|
||||
total: 45,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 3
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="select-page"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('20')
|
||||
|
||||
await wrapper.get('[data-test="select-all-results"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('20')
|
||||
expect(wrapper.get('[data-test="all-results-selected"]').text()).toBe('false')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.bulkActions.selectAllFailed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user