Merge pull request #2030 from KnowSky404/feature/account-bulk-edit-scope-and-compact

feat: support filtered account bulk edit and align compact OpenAI bulk fields
This commit is contained in:
Wesley Liddick
2026-04-29 20:56:43 +08:00
committed by GitHub
12 changed files with 1311 additions and 53 deletions

View File

@@ -141,7 +141,17 @@
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<AccountBulkActionsBar
:selected-ids="selIds"
@delete="handleBulkDelete"
@reset-status="handleBulkResetStatus"
@refresh-token="handleBulkRefreshToken"
@edit-selected="openBulkEditSelected"
@edit-filtered="openBulkEditFiltered"
@clear="clearSelection"
@select-page="selectPage"
@toggle-schedulable="handleBulkToggleSchedulable"
/>
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable
ref="dataTableRef"
@@ -303,7 +313,17 @@
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
<BulkEditAccountModal
:show="showBulkEdit"
:account-ids="selIds"
:selected-platforms="selPlatforms"
:selected-types="selTypes"
:target="bulkEditTarget ?? undefined"
:proxies="proxies"
:groups="groups"
@close="showBulkEdit = false"
@updated="handleBulkUpdated"
/>
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
@@ -364,6 +384,29 @@ const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([])
const accountTableRef = ref<HTMLElement | null>(null)
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
type AccountBulkEditTarget =
| {
mode: 'selected'
accountIds: number[]
selectedPlatforms: AccountPlatform[]
selectedTypes: AccountType[]
}
| {
mode: 'filtered'
filters: {
platform?: string
type?: string
status?: string
group?: string
search?: string
privacy_mode?: string
sort_by?: string
sort_order?: AccountSortOrder
}
previewCount: number
selectedPlatforms: AccountPlatform[]
selectedTypes: AccountType[]
}
const selPlatforms = computed<AccountPlatform[]>(() => {
const platforms = new Set(
accounts.value
@@ -387,6 +430,7 @@ const showImportData = ref(false)
const showExportDataDialog = ref(false)
const includeProxyOnExport = ref(true)
const showBulkEdit = ref(false)
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
const showTempUnsched = ref(false)
const showDeleteDialog = ref(false)
const showReAuth = ref(false)
@@ -1216,7 +1260,57 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
appStore.showError(t('common.error'))
}
}
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const buildBulkEditFilterSnapshot = () => {
const rawParams = toRaw(params) as Record<string, unknown>
const sortOrder: AccountSortOrder = rawParams.sort_order === 'desc' ? 'desc' : 'asc'
return {
platform: typeof rawParams.platform === 'string' ? rawParams.platform : '',
type: typeof rawParams.type === 'string' ? rawParams.type : '',
status: typeof rawParams.status === 'string' ? rawParams.status : '',
group: typeof rawParams.group === 'string' ? rawParams.group : '',
search: typeof rawParams.search === 'string' ? rawParams.search : '',
privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '',
sort_by: typeof rawParams.sort_by === 'string' ? rawParams.sort_by : '',
sort_order: sortOrder
}
}
const collectSelectionMetadata = (rows: Account[]) => {
const selectedPlatforms = Array.from(new Set(rows.map(account => account.platform)))
const selectedTypes = Array.from(new Set(rows.map(account => account.type)))
return { selectedPlatforms, selectedTypes }
}
const openBulkEditSelected = () => {
bulkEditTarget.value = {
mode: 'selected',
accountIds: [...selIds.value],
selectedPlatforms: [...selPlatforms.value],
selectedTypes: [...selTypes.value]
}
showBulkEdit.value = true
}
const openBulkEditFiltered = async () => {
const filters = buildBulkEditFilterSnapshot()
const preview = await adminAPI.accounts.list(1, 100, filters)
const { selectedPlatforms, selectedTypes } = collectSelectionMetadata(preview.items)
bulkEditTarget.value = {
mode: 'filtered',
filters,
previewCount: preview.total,
selectedPlatforms,
selectedTypes
}
showBulkEdit.value = true
}
const handleBulkUpdated = () => {
showBulkEdit.value = false
bulkEditTarget.value = null
clearSelection()
reload()
}
const handleDataImported = () => { showImportData.value = false; reload() }
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountsView from '../AccountsView.vue'
const {
listAccounts,
listWithEtag,
getBatchTodayStats,
getAllProxies,
getAllGroups
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
list: listAccounts,
listWithEtag,
getBatchTodayStats,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
toggleSchedulable: vi.fn()
},
proxies: {
getAll: getAllProxies
},
groups: {
getAll: getAllGroups
}
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
token: 'test-token'
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const DataTableStub = {
props: ['columns', 'data'],
template: '<div data-test="data-table"></div>'
}
const AccountBulkActionsBarStub = {
props: ['selectedIds'],
emits: ['edit-filtered'],
template: '<button data-test="edit-filtered" @click="$emit(\'edit-filtered\')">edit filtered</button>'
}
const BulkEditAccountModalStub = {
props: ['show', 'target'],
template: '<div data-test="bulk-edit-modal" :data-show="String(show)" :data-target-mode="target?.mode ?? \'\'"></div>'
}
describe('admin AccountsView bulk edit scope', () => {
beforeEach(() => {
localStorage.clear()
listAccounts.mockReset()
listWithEtag.mockReset()
getBatchTodayStats.mockReset()
getAllProxies.mockReset()
getAllGroups.mockReset()
listAccounts.mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
pages: 0
})
listWithEtag.mockResolvedValue({
notModified: true,
etag: null,
data: null
})
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
})
it('opens bulk edit in filtered-results mode from the bulk actions dropdown', async () => {
const wrapper = mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
DataTable: DataTableStub,
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
AccountTableFilters: { template: '<div></div>' },
AccountBulkActionsBar: AccountBulkActionsBarStub,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: BulkEditAccountModalStub,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
await flushPromises()
await wrapper.get('[data-test="edit-filtered"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-test="bulk-edit-modal"]').attributes('data-show')).toBe('true')
expect(wrapper.get('[data-test="bulk-edit-modal"]').attributes('data-target-mode')).toBe('filtered')
})
})