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
@@ -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/SetupTokenRPM 配置仅在此条件下显示)
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'
})
})
})