mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
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:
@@ -370,8 +370,8 @@ export async function batchUpdateCredentials(request: {
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function bulkUpdate(
|
||||
accountIds: number[],
|
||||
updates: Record<string, unknown>
|
||||
accountIdsOrPayload: number[] | Record<string, unknown>,
|
||||
updates?: Record<string, unknown>
|
||||
): Promise<{
|
||||
success: number
|
||||
failed: number
|
||||
@@ -379,16 +379,19 @@ export async function bulkUpdate(
|
||||
failed_ids?: number[]
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}> {
|
||||
const payload = Array.isArray(accountIdsOrPayload)
|
||||
? {
|
||||
account_ids: accountIdsOrPayload,
|
||||
...(updates ?? {})
|
||||
}
|
||||
: accountIdsOrPayload
|
||||
const { data } = await apiClient.post<{
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}>('/admin/accounts/bulk-update', {
|
||||
account_ids: accountIds,
|
||||
...updates
|
||||
})
|
||||
}>('/admin/accounts/bulk-update', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
|
||||
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: targetMode === 'filtered' ? targetPreviewCount : accountIds.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
|
||||
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: targetSelectedPlatforms.join(', ') }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:platforms="selectedPlatforms"
|
||||
:platforms="targetSelectedPlatforms"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -698,6 +698,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Codex CLI only -->
|
||||
<div v-if="allOpenAIOAuth" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label
|
||||
id="bulk-edit-openai-codex-cli-only-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-codex-cli-only-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.codexCLIOnly') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="enableCodexCLIOnly"
|
||||
id="bulk-edit-openai-codex-cli-only-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-codex-cli-only"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-codex-cli-only"
|
||||
:class="!enableCodexCLIOnly && 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.codexCLIOnlyDesc') }}
|
||||
</p>
|
||||
<button
|
||||
id="bulk-edit-openai-codex-cli-only-toggle"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
codexCLIOnlyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="codexCLIOnlyEnabled = !codexCLIOnlyEnabled"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
codexCLIOnlyEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI API Key WS mode -->
|
||||
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label
|
||||
id="bulk-edit-openai-apikey-ws-mode-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-apikey-ws-mode-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.wsMode') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="enableOpenAIAPIKeyWSMode"
|
||||
id="bulk-edit-openai-apikey-ws-mode-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-apikey-ws-mode"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-apikey-ws-mode"
|
||||
:class="!enableOpenAIAPIKeyWSMode && 'pointer-events-none opacity-50'"
|
||||
>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.wsModeDesc') }}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(openAIAPIKeyWSModeConcurrencyHintKey) }}
|
||||
</p>
|
||||
<Select
|
||||
v-model="openaiAPIKeyResponsesWebSocketV2Mode"
|
||||
data-testid="bulk-edit-openai-apikey-ws-mode-select"
|
||||
:options="openAIWSModeOptions"
|
||||
aria-labelledby="bulk-edit-openai-apikey-ws-mode-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
|
||||
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -933,6 +1014,13 @@ interface Props {
|
||||
accountIds: number[]
|
||||
selectedPlatforms: AccountPlatform[]
|
||||
selectedTypes: AccountType[]
|
||||
target?: {
|
||||
mode: 'selected' | 'filtered'
|
||||
filters?: Record<string, unknown>
|
||||
previewCount?: number
|
||||
selectedPlatforms?: AccountPlatform[]
|
||||
selectedTypes?: AccountType[]
|
||||
}
|
||||
proxies: ProxyConfig[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
@@ -947,40 +1035,53 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Platform awareness
|
||||
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
||||
const targetMode = computed(() => props.target?.mode ?? 'selected')
|
||||
const targetPreviewCount = computed(() => props.target?.previewCount ?? props.accountIds.length)
|
||||
const targetSelectedPlatforms = computed(() => props.target?.selectedPlatforms ?? props.selectedPlatforms)
|
||||
const targetSelectedTypes = computed(() => props.target?.selectedTypes ?? props.selectedTypes)
|
||||
const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1)
|
||||
|
||||
const allOpenAIPassthroughCapable = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'openai' &&
|
||||
props.selectedTypes.length > 0 &&
|
||||
props.selectedTypes.every(t => t === 'oauth' || t === 'apikey')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth' || t === 'apikey')
|
||||
)
|
||||
})
|
||||
|
||||
const allOpenAIOAuth = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'openai' &&
|
||||
props.selectedTypes.length > 0 &&
|
||||
props.selectedTypes.every(t => t === 'oauth')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth')
|
||||
)
|
||||
})
|
||||
|
||||
const allOpenAIAPIKey = computed(() => {
|
||||
return (
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'apikey')
|
||||
)
|
||||
})
|
||||
|
||||
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
|
||||
const allAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'anthropic' &&
|
||||
props.selectedTypes.every(t => t === 'oauth' || t === 'setup-token')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'anthropic' &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth' || t === 'setup-token')
|
||||
)
|
||||
})
|
||||
|
||||
const filteredPresets = computed(() => {
|
||||
if (props.selectedPlatforms.length === 0) return []
|
||||
if (targetSelectedPlatforms.value.length === 0) return []
|
||||
|
||||
const dedupedPresets = new Map<string, ReturnType<typeof getPresetMappingsByPlatform>[number]>()
|
||||
for (const platform of props.selectedPlatforms) {
|
||||
for (const platform of targetSelectedPlatforms.value) {
|
||||
for (const preset of getPresetMappingsByPlatform(platform)) {
|
||||
const key = `${preset.from}=>${preset.to}`
|
||||
if (!dedupedPresets.has(key)) {
|
||||
@@ -1012,6 +1113,8 @@ const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableOpenAIPassthrough = ref(false)
|
||||
const enableOpenAIWSMode = ref(false)
|
||||
const enableOpenAIAPIKeyWSMode = ref(false)
|
||||
const enableCodexCLIOnly = ref(false)
|
||||
const enableRpmLimit = ref(false)
|
||||
|
||||
// State - field values
|
||||
@@ -1035,6 +1138,8 @@ const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const rpmLimitEnabled = ref(false)
|
||||
const bulkBaseRpm = ref<number | null>(null)
|
||||
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||
@@ -1076,6 +1181,9 @@ const openAIWSModeOptions = computed(() => [
|
||||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
const openAIAPIKeyWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
@@ -1254,6 +1362,19 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
)
|
||||
}
|
||||
|
||||
if (enableOpenAIAPIKeyWSMode.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||
)
|
||||
}
|
||||
|
||||
if (enableCodexCLIOnly.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.codex_cli_only = codexCLIOnlyEnabled.value
|
||||
}
|
||||
|
||||
// RPM limit settings (写入 extra 字段)
|
||||
if (enableRpmLimit.value) {
|
||||
const extra = ensureExtra()
|
||||
@@ -1291,8 +1412,8 @@ const mixedChannelConfirmed = ref(false)
|
||||
const canPreCheck = () =>
|
||||
enableGroups.value &&
|
||||
groupIds.value.length > 0 &&
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
(props.selectedPlatforms[0] === 'antigravity' || props.selectedPlatforms[0] === 'anthropic')
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
(targetSelectedPlatforms.value[0] === 'antigravity' || targetSelectedPlatforms.value[0] === 'anthropic')
|
||||
|
||||
const handleClose = () => {
|
||||
showMixedChannelWarning.value = false
|
||||
@@ -1309,7 +1430,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||||
platform: props.selectedPlatforms[0],
|
||||
platform: targetSelectedPlatforms.value[0],
|
||||
group_ids: groupIds.value
|
||||
})
|
||||
if (!result.has_risk) return true
|
||||
@@ -1325,7 +1446,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (props.accountIds.length === 0) {
|
||||
if (targetMode.value === 'selected' && props.accountIds.length === 0) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||
return
|
||||
}
|
||||
@@ -1344,6 +1465,8 @@ const handleSubmit = async () => {
|
||||
enableStatus.value ||
|
||||
enableGroups.value ||
|
||||
enableOpenAIWSMode.value ||
|
||||
enableOpenAIAPIKeyWSMode.value ||
|
||||
enableCodexCLIOnly.value ||
|
||||
enableRpmLimit.value ||
|
||||
userMsgQueueMode.value !== null
|
||||
|
||||
@@ -1373,7 +1496,12 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
const res = await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
|
||||
const res = targetMode.value === 'filtered' && props.target?.filters
|
||||
? await adminAPI.accounts.bulkUpdate({
|
||||
filters: props.target.filters,
|
||||
...updates
|
||||
})
|
||||
: await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
|
||||
const success = res.success || 0
|
||||
const failed = res.failed || 0
|
||||
|
||||
@@ -1437,6 +1565,8 @@ watch(
|
||||
enableGroups.value = false
|
||||
enableOpenAIPassthrough.value = false
|
||||
enableOpenAIWSMode.value = false
|
||||
enableOpenAIAPIKeyWSMode.value = false
|
||||
enableCodexCLIOnly.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
// Reset all values
|
||||
@@ -1456,6 +1586,8 @@ watch(
|
||||
status.value = 'active'
|
||||
groupIds.value = []
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
rpmLimitEnabled.value = false
|
||||
bulkBaseRpm.value = null
|
||||
bulkRpmStrategy.value = 'tiered'
|
||||
|
||||
@@ -178,6 +178,45 @@ describe('BulkEditAccountModal', () => {
|
||||
expect(wrapper.find('#bulk-edit-openai-ws-mode-enabled').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 批量编辑应提交 codex_cli_only 字段', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-codex-cli-only-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-codex-cli-only-toggle').trigger('click')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
codex_cli_only: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI API Key 批量编辑应提交 API Key 专属 WS mode 字段', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-apikey-ws-mode-enabled').setValue(true)
|
||||
await wrapper.get('[data-testid="bulk-edit-openai-apikey-ws-mode-select"]').setValue('ctx_pool')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
openai_apikey_responses_websockets_v2_mode: 'ctx_pool',
|
||||
openai_apikey_responses_websockets_v2_enabled: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI 账号批量编辑可关闭自动透传', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
@@ -217,4 +256,41 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.modelRestrictionDisabledByPassthrough')
|
||||
})
|
||||
|
||||
it('filtered-results 模式下应提交 filters 而不是 account_ids', async () => {
|
||||
const wrapper = mountModal({
|
||||
accountIds: [],
|
||||
target: {
|
||||
mode: 'filtered',
|
||||
filters: {
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
status: 'active',
|
||||
group: '12',
|
||||
search: 'bulk-target',
|
||||
privacy_mode: 'training_set_cf_blocked'
|
||||
},
|
||||
previewCount: 5,
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-status-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({
|
||||
filters: {
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
status: 'active',
|
||||
group: '12',
|
||||
search: 'bulk-target',
|
||||
privacy_mode: 'training_set_cf_blocked'
|
||||
},
|
||||
status: 'active'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg dark:bg-primary-900/20">
|
||||
<div class="mb-4 flex items-center justify-between rounded-lg bg-primary-50 p-3 dark:bg-primary-900/20">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
<span v-if="selectedIds.length > 0" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<span v-else class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkEdit.title') }}
|
||||
</span>
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<button
|
||||
@click="$emit('select-page')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
@@ -17,19 +21,25 @@
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
||||
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
||||
<button @click="$emit('edit-selected')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
||||
</template>
|
||||
<button @click="$emit('edit-filtered')" class="btn btn-primary btn-sm">
|
||||
{{ t('admin.accounts.bulkEdit.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
|
||||
defineProps(['selectedIds']); defineEmits(['delete', 'edit-selected', 'edit-filtered', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
@@ -141,7 +141,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
<AccountBulkActionsBar
|
||||
:selected-ids="selIds"
|
||||
@delete="handleBulkDelete"
|
||||
@reset-status="handleBulkResetStatus"
|
||||
@refresh-token="handleBulkRefreshToken"
|
||||
@edit-selected="openBulkEditSelected"
|
||||
@edit-filtered="openBulkEditFiltered"
|
||||
@clear="clearSelection"
|
||||
@select-page="selectPage"
|
||||
@toggle-schedulable="handleBulkToggleSchedulable"
|
||||
/>
|
||||
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<DataTable
|
||||
ref="dataTableRef"
|
||||
@@ -303,7 +313,17 @@
|
||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" />
|
||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
||||
<BulkEditAccountModal
|
||||
:show="showBulkEdit"
|
||||
:account-ids="selIds"
|
||||
:selected-platforms="selPlatforms"
|
||||
:selected-types="selTypes"
|
||||
:target="bulkEditTarget ?? undefined"
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="showBulkEdit = false"
|
||||
@updated="handleBulkUpdated"
|
||||
/>
|
||||
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
||||
@@ -364,6 +384,29 @@ const proxies = ref<AccountProxy[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const accountTableRef = ref<HTMLElement | null>(null)
|
||||
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
|
||||
type AccountBulkEditTarget =
|
||||
| {
|
||||
mode: 'selected'
|
||||
accountIds: number[]
|
||||
selectedPlatforms: AccountPlatform[]
|
||||
selectedTypes: AccountType[]
|
||||
}
|
||||
| {
|
||||
mode: 'filtered'
|
||||
filters: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
group?: string
|
||||
search?: string
|
||||
privacy_mode?: string
|
||||
sort_by?: string
|
||||
sort_order?: AccountSortOrder
|
||||
}
|
||||
previewCount: number
|
||||
selectedPlatforms: AccountPlatform[]
|
||||
selectedTypes: AccountType[]
|
||||
}
|
||||
const selPlatforms = computed<AccountPlatform[]>(() => {
|
||||
const platforms = new Set(
|
||||
accounts.value
|
||||
@@ -387,6 +430,7 @@ const showImportData = ref(false)
|
||||
const showExportDataDialog = ref(false)
|
||||
const includeProxyOnExport = ref(true)
|
||||
const showBulkEdit = ref(false)
|
||||
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
|
||||
const showTempUnsched = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showReAuth = ref(false)
|
||||
@@ -1216,7 +1260,57 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
appStore.showError(t('common.error'))
|
||||
}
|
||||
}
|
||||
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
|
||||
const buildBulkEditFilterSnapshot = () => {
|
||||
const rawParams = toRaw(params) as Record<string, unknown>
|
||||
const sortOrder: AccountSortOrder = rawParams.sort_order === 'desc' ? 'desc' : 'asc'
|
||||
return {
|
||||
platform: typeof rawParams.platform === 'string' ? rawParams.platform : '',
|
||||
type: typeof rawParams.type === 'string' ? rawParams.type : '',
|
||||
status: typeof rawParams.status === 'string' ? rawParams.status : '',
|
||||
group: typeof rawParams.group === 'string' ? rawParams.group : '',
|
||||
search: typeof rawParams.search === 'string' ? rawParams.search : '',
|
||||
privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '',
|
||||
sort_by: typeof rawParams.sort_by === 'string' ? rawParams.sort_by : '',
|
||||
sort_order: sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
const collectSelectionMetadata = (rows: Account[]) => {
|
||||
const selectedPlatforms = Array.from(new Set(rows.map(account => account.platform)))
|
||||
const selectedTypes = Array.from(new Set(rows.map(account => account.type)))
|
||||
return { selectedPlatforms, selectedTypes }
|
||||
}
|
||||
|
||||
const openBulkEditSelected = () => {
|
||||
bulkEditTarget.value = {
|
||||
mode: 'selected',
|
||||
accountIds: [...selIds.value],
|
||||
selectedPlatforms: [...selPlatforms.value],
|
||||
selectedTypes: [...selTypes.value]
|
||||
}
|
||||
showBulkEdit.value = true
|
||||
}
|
||||
|
||||
const openBulkEditFiltered = async () => {
|
||||
const filters = buildBulkEditFilterSnapshot()
|
||||
const preview = await adminAPI.accounts.list(1, 100, filters)
|
||||
const { selectedPlatforms, selectedTypes } = collectSelectionMetadata(preview.items)
|
||||
bulkEditTarget.value = {
|
||||
mode: 'filtered',
|
||||
filters,
|
||||
previewCount: preview.total,
|
||||
selectedPlatforms,
|
||||
selectedTypes
|
||||
}
|
||||
showBulkEdit.value = true
|
||||
}
|
||||
|
||||
const handleBulkUpdated = () => {
|
||||
showBulkEdit.value = false
|
||||
bulkEditTarget.value = null
|
||||
clearSelection()
|
||||
reload()
|
||||
}
|
||||
const handleDataImported = () => { showImportData.value = false; reload() }
|
||||
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
|
||||
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
|
||||
|
||||
152
frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
Normal file
152
frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user