feat(pool): add table-driven account batch actions

This commit is contained in:
elky
2026-07-19 16:09:20 +08:00
parent 95053f9502
commit 644ae9c1bf
20 changed files with 1403 additions and 1095 deletions
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,19 @@
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 xl:hidden">
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ legacyT('号池管理') }}
</h3>
<div class="flex min-w-0 items-center gap-2">
<h3 class="text-base font-semibold">
{{ legacyT('号池管理') }}
</h3>
<span
v-if="selectedCount > 0"
class="shrink-0 text-xs font-medium tabular-nums text-primary"
aria-live="polite"
data-testid="pool-selected-count-mobile"
>
{{ selectedCountLabel }}
</span>
</div>
<p
v-if="metaText"
class="mt-1 text-xs text-muted-foreground"
@@ -76,12 +86,9 @@
class="min-w-0 flex-1 flex justify-center"
>
<Button
v-if="action.key !== 'providerProxy' && action.key !== 'refresh'"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="action.key === 'toggleProvider' ? providerToggleButtonClass : ''"
:disabled="action.key === 'toggleProvider' ? togglingProviderStatus : false"
:title="action.title"
@click="emit(action.event)"
>
@@ -90,20 +97,61 @@
class="w-3.5 h-3.5"
/>
</Button>
</div>
<ProviderProxyPopover
v-else-if="action.key === 'providerProxy'"
:open="providerProxyMobileOpen"
:node-id="providerProxyNodeId"
:saving="savingProviderProxy"
:title="providerProxyButtonTitle"
@update:open="emit('update:providerProxyMobileOpen', $event)"
@select="emit('selectProviderProxy', $event)"
@clear="emit('clearProviderProxy')"
/>
<div class="min-w-0 flex-1 flex justify-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="isAllFilteredSelected ? 'bg-primary/10 text-primary' : ''"
:disabled="selectionDisabled"
:aria-pressed="isAllFilteredSelected"
:title="legacyT(isAllFilteredSelected ? '取消全选' : '全选')"
data-testid="pool-select-all-mobile"
@click="emit('toggleSelectAll')"
>
<SquareCheckBig class="h-3.5 w-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<DropdownMenu :modal="false">
<DropdownMenuTrigger
as-child
:disabled="batchActionsDisabled"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="batchActionsDisabled"
:title="legacyT('选择执行动作')"
:aria-label="legacyT('选择执行动作')"
data-testid="pool-batch-actions-mobile"
>
<ListChecks class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-48"
>
<DropdownMenuItem
v-for="action in POOL_BATCH_ACTION_OPTIONS"
:key="`mobile-${action.value}`"
:class="action.destructive ? 'text-destructive focus:text-destructive' : ''"
:data-testid="`pool-batch-action-${action.value}-mobile`"
@select="emit('batchAction', action.value)"
>
{{ legacyT(action.label) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<RefreshButton
v-else
:loading="refreshLoading"
:title="refreshTitle"
@click="emit('refresh')"
@@ -193,36 +241,12 @@
/>
<Button
v-if="hasSelectedProvider"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('添加账号')"
@click="emit('import')"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<ProviderProxyPopover
v-if="hasSelectedProvider"
:open="providerProxyDesktopOpen"
:node-id="providerProxyNodeId"
:saving="savingProviderProxy"
:title="providerProxyButtonTitle"
@update:open="emit('update:providerProxyDesktopOpen', $event)"
@select="emit('selectProviderProxy', $event)"
@clear="emit('clearProviderProxy')"
/>
<Button
v-for="action in desktopPostProxyActions"
v-for="action in desktopActions"
v-show="hasSelectedProvider"
:key="action.key"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="action.key === 'toggleProvider' ? providerToggleButtonClass : ''"
:disabled="action.key === 'toggleProvider' ? togglingProviderStatus : false"
:data-testid="action.key === 'demandMetrics' ? 'pool-demand-metrics-button' : undefined"
:title="action.title"
@click="emit(action.event)"
@@ -233,6 +257,57 @@
/>
</Button>
<Button
v-if="hasSelectedProvider"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="isAllFilteredSelected ? 'bg-primary/10 text-primary' : ''"
:disabled="selectionDisabled"
:aria-pressed="isAllFilteredSelected"
:title="legacyT(isAllFilteredSelected ? '取消全选' : '全选')"
data-testid="pool-select-all-desktop"
@click="emit('toggleSelectAll')"
>
<SquareCheckBig class="h-3.5 w-3.5" />
</Button>
<DropdownMenu
v-if="hasSelectedProvider"
:modal="false"
>
<DropdownMenuTrigger
as-child
:disabled="batchActionsDisabled"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="batchActionsDisabled"
:title="legacyT('选择执行动作')"
:aria-label="legacyT('选择执行动作')"
data-testid="pool-batch-actions-desktop"
>
<ListChecks class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-48"
>
<DropdownMenuItem
v-for="action in POOL_BATCH_ACTION_OPTIONS"
:key="`desktop-${action.value}`"
:class="action.destructive ? 'text-destructive focus:text-destructive' : ''"
:data-testid="`pool-batch-action-${action.value}-desktop`"
@select="emit('batchAction', action.value)"
>
{{ legacyT(action.label) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RefreshButton
:loading="refreshLoading"
:title="refreshTitle"
@@ -248,17 +323,19 @@ import { computed } from 'vue'
import {
Activity,
ChevronDown,
Edit,
Plug,
Power,
Eye,
ListChecks,
Search,
Settings2,
SlidersHorizontal,
Upload,
Users,
SquareCheckBig,
} from 'lucide-vue-next'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Select,
SelectContent,
@@ -267,31 +344,24 @@ import {
SelectValue,
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue'
import { useI18n } from '@/i18n'
import type { PoolOverviewItem } from '@/api/endpoints/pool'
import {
POOL_BATCH_ACTION_OPTIONS,
type PoolBatchActionValue,
} from '@/features/pool/utils/poolBatchActions'
type HeaderActionEvent =
| 'import'
| 'scheduling'
| 'accountBatch'
| 'editProvider'
| 'editEndpoint'
| 'viewProvider'
| 'demandMetrics'
| 'advanced'
| 'toggleProvider'
type HeaderActionKey =
| 'import'
| 'providerProxy'
| 'scheduling'
| 'accountBatch'
| 'editProvider'
| 'editEndpoint'
| 'viewProvider'
| 'demandMetrics'
| 'advanced'
| 'toggleProvider'
| 'refresh'
interface HeaderAction {
key: HeaderActionKey
@@ -308,41 +378,30 @@ const props = withDefaults(defineProps<{
statusOptions: Array<{ value: string, label: string }>
search: string
metaText?: string
providerProxyNodeId?: string | null
providerProxyMobileOpen: boolean
providerProxyDesktopOpen: boolean
providerProxyButtonTitle: string
savingProviderProxy: boolean
poolSchedulingLabel: string
showAdaptiveHotPoolMetricsButton: boolean
providerToggleButtonTitle: string
providerToggleButtonClass?: string
togglingProviderStatus: boolean
selectedCount?: number
isAllFilteredSelected: boolean
selectionDisabled: boolean
batchActionsDisabled: boolean
refreshLoading: boolean
refreshTitle: string
}>(), {
metaText: '',
providerProxyNodeId: null,
providerToggleButtonClass: '',
selectedCount: 0,
})
const emit = defineEmits<{
'update:providerId': [value: string]
'update:status': [value: string]
'update:search': [value: string]
'update:providerProxyMobileOpen': [value: boolean]
'update:providerProxyDesktopOpen': [value: boolean]
import: []
scheduling: []
accountBatch: []
editProvider: []
editEndpoint: []
viewProvider: []
demandMetrics: []
advanced: []
toggleProvider: []
toggleSelectAll: []
batchAction: [action: PoolBatchActionValue]
refresh: []
selectProviderProxy: [nodeId: string]
clearProviderProxy: []
}>()
const { legacyT } = useI18n()
@@ -364,38 +423,31 @@ const searchModel = computed({
const hasSelectedProvider = computed(() => Boolean(props.providerId))
const mobileActions = computed(() => {
const actions: Array<HeaderAction | { key: 'providerProxy' | 'refresh' }> = [
{ key: 'import', title: legacyT('添加账号'), event: 'import', icon: Upload },
{ key: 'providerProxy' },
const selectedCountLabel = computed(() => legacyT(`已选 ${Math.max(0, props.selectedCount)} 个`))
const mobileActions = computed<HeaderAction[]>(() => {
const actions: HeaderAction[] = [
{ key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye },
{ key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
]
if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
}
actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
{ key: 'refresh' },
)
return actions
})
const desktopPostProxyActions = computed<HeaderAction[]>(() => {
const desktopActions = computed<HeaderAction[]>(() => {
const actions: HeaderAction[] = [
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
{ key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye },
]
if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
}
actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
)
return actions
})
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { PoolKeyDetail } from '@/api/endpoints/pool'
import PoolAccountBatchDialog from '../PoolAccountBatchDialog.vue'
const apiMocks = vi.hoisted(() => ({
batchActionPoolKeys: vi.fn(),
getPoolBatchDeleteTask: vi.fn(),
resolvePoolKeySelection: vi.fn(),
exportKey: vi.fn(),
refreshProviderQuota: vi.fn(),
refreshProviderOAuth: vi.fn(),
}))
vi.mock('@/api/endpoints/pool', () => ({
batchActionPoolKeys: apiMocks.batchActionPoolKeys,
getPoolBatchDeleteTask: apiMocks.getPoolBatchDeleteTask,
resolvePoolKeySelection: apiMocks.resolvePoolKeySelection,
}))
vi.mock('@/api/endpoints/keys', () => ({
exportKey: apiMocks.exportKey,
refreshProviderQuota: apiMocks.refreshProviderQuota,
}))
vi.mock('@/api/endpoints/provider_oauth', () => ({
refreshProviderOAuth: apiMocks.refreshProviderOAuth,
}))
vi.mock('@/stores/proxy-nodes', () => ({
useProxyNodesStore: () => ({ nodes: [], ensureLoaded: vi.fn() }),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), warning: vi.fn(), error: vi.fn() }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }),
}))
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
const { defineComponent } = await import('vue')
return { default: defineComponent({ name: 'ProxyNodeSelectStub', render: () => null }) }
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function createKey(id: string, authType: PoolKeyDetail['auth_type']): PoolKeyDetail {
return {
key_id: id,
key_name: id,
is_active: true,
auth_type: authType,
api_formats: ['openai:chat'],
} as PoolKeyDetail
}
async function settle(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
apiMocks.refreshProviderQuota.mockReset().mockResolvedValue({
success: 2,
failed: 0,
total: 2,
results: [],
})
apiMocks.resolvePoolKeySelection.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('PoolAccountBatchDialog initial selection', () => {
it('runs the action selected from the header menu when the dialog opens', async () => {
const selectedKeys = [createKey('oauth-key', 'oauth'), createKey('api-key', 'api_key')]
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'codex',
selectedKeys,
selectAllFiltered: false,
selectedCount: selectedKeys.length,
selectionFilters: { status: 'all' },
initialAction: 'refresh_quota',
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await settle()
expect(apiMocks.refreshProviderQuota).toHaveBeenCalledTimes(1)
expect(apiMocks.refreshProviderQuota).toHaveBeenCalledWith('provider-1', ['oauth-key', 'api-key'])
})
it('opens required configuration instead of executing an incomplete menu action', async () => {
const selectedKeys = [createKey('api-key', 'api_key')]
const open = ref(true)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'openai',
selectedKeys,
selectAllFiltered: false,
selectedCount: selectedKeys.length,
selectionFilters: { status: 'all' },
initialAction: 'set_proxy',
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
expect(document.body.textContent).toContain('选择要绑定的代理节点')
expect(apiMocks.refreshProviderQuota).not.toHaveBeenCalled()
})
it('uses explicit table selections without loading a second account list', async () => {
const selectedKeys = [createKey('oauth-key', 'oauth'), createKey('api-key', 'api_key')]
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'codex',
selectedKeys,
selectAllFiltered: false,
selectedCount: selectedKeys.length,
selectionFilters: { status: 'all' },
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await settle()
expect(document.body.textContent).toContain('已选择 2 个账号')
expect(apiMocks.resolvePoolKeySelection).not.toHaveBeenCalled()
const actionButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.includes('刷新额度'))
actionButton?.click()
await settle()
expect(apiMocks.refreshProviderQuota).toHaveBeenCalledWith('provider-1', ['oauth-key', 'api-key'])
})
it('resolves all filtered table results with the supplied filter snapshot', async () => {
const selectedKeys = [createKey('visible-key', 'api_key')]
apiMocks.resolvePoolKeySelection.mockResolvedValue({
total: 2,
items: [
{ key_id: 'visible-key', key_name: 'visible-key', auth_type: 'api_key' },
{ key_id: 'hidden-page-key', key_name: 'hidden-page-key', auth_type: 'api_key' },
],
})
const open = ref(true)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'codex',
selectedKeys,
selectAllFiltered: true,
selectedCount: 2,
selectionFilters: { search: 'inactive', status: 'inactive' },
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
const actionButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.includes('刷新额度'))
expect(actionButton).not.toBeUndefined()
actionButton?.click()
await settle()
expect(apiMocks.resolvePoolKeySelection).toHaveBeenCalledWith('provider-1', {
search: 'inactive',
status: 'inactive',
})
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import { createApp, defineComponent, h, nextTick } from 'vue'
import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
import { createI18n } from '@/i18n'
@@ -14,7 +14,7 @@ const provider = {
} as PoolOverviewItem
describe('PoolManagementHeader', () => {
it('keeps page actions wired through component events', () => {
it('keeps page actions wired through component events', async () => {
const events: string[] = []
const Probe = defineComponent({
setup() {
@@ -26,21 +26,20 @@ describe('PoolManagementHeader', () => {
statusOptions: [{ value: 'all', label: '全部状态' }],
search: '',
metaText: 'codex | 启用',
providerProxyNodeId: null,
providerProxyMobileOpen: false,
providerProxyDesktopOpen: false,
providerProxyButtonTitle: '提供商代理(未设置)',
savingProviderProxy: false,
poolSchedulingLabel: '2 维度',
showAdaptiveHotPoolMetricsButton: true,
providerToggleButtonTitle: '当前状态:已启用,点击禁用提供商',
togglingProviderStatus: false,
selectedCount: 2,
isAllFilteredSelected: false,
selectionDisabled: false,
batchActionsDisabled: false,
refreshLoading: false,
refreshTitle: '刷新',
onImport: () => events.push('import'),
onViewProvider: () => events.push('viewProvider'),
onScheduling: () => events.push('scheduling'),
onAccountBatch: () => events.push('accountBatch'),
onDemandMetrics: () => events.push('demandMetrics'),
onAdvanced: () => events.push('advanced'),
onToggleSelectAll: () => events.push('toggleSelectAll'),
onBatchAction: (action: string) => events.push(`batchAction:${action}`),
onRefresh: () => events.push('refresh'),
})
},
@@ -52,14 +51,44 @@ describe('PoolManagementHeader', () => {
app.use(createI18n())
app.mount(root)
root.querySelector<HTMLButtonElement>('[title="添加账号"]')?.click()
root.querySelector<HTMLButtonElement>('[title="查看详情"]')?.click()
root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click()
root.querySelector<HTMLButtonElement>('[title="账号批量操作"]')?.click()
root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click()
const desktopActions = root.querySelector('[data-testid="pool-header-actions"]')
const advancedButton = desktopActions?.querySelector<HTMLButtonElement>('[title="高级设置"]')
const selectAllButton = desktopActions?.querySelector<HTMLButtonElement>('[data-testid="pool-select-all-desktop"]')
const batchActionsButton = desktopActions?.querySelector<HTMLButtonElement>('[data-testid="pool-batch-actions-desktop"]')
advancedButton?.click()
selectAllButton?.click()
batchActionsButton?.click()
await nextTick()
document.body.querySelector<HTMLElement>('[data-testid="pool-batch-action-refresh_quota-desktop"]')?.click()
await nextTick()
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
expect(events).toEqual(['import', 'scheduling', 'accountBatch', 'demandMetrics', 'refresh'])
expect(events).toEqual([
'viewProvider',
'scheduling',
'demandMetrics',
'advanced',
'toggleSelectAll',
'batchAction:refresh_quota',
'refresh',
])
expect(selectAllButton?.textContent?.trim()).toBe('')
expect(selectAllButton?.getAttribute('title')).toBe('全选')
expect(advancedButton?.nextElementSibling).toBe(selectAllButton)
expect(selectAllButton?.nextElementSibling).toBe(batchActionsButton)
expect(batchActionsButton?.getAttribute('title')).toBe('选择执行动作')
expect(root.querySelector('[title="账号批量操作"]')).toBeNull()
expect(root.querySelector('[title="添加账号"]')).toBeNull()
expect(root.querySelector('[title="提供商代理(未设置)"]')).toBeNull()
expect(root.querySelector('[title="编辑端点"]')).toBeNull()
expect(root.querySelector('[title="编辑提供商"]')).toBeNull()
expect(root.querySelector('[title="当前状态:已启用,点击禁用提供商"]')).toBeNull()
expect(root.textContent).toContain('2 维度')
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-selected-count-mobile"]')?.textContent).toContain('已选 2 个')
app.unmount()
root.remove()
@@ -0,0 +1,31 @@
export type PoolBatchActionValue =
| 'edit_config'
| 'export'
| 'delete'
| 'refresh_oauth'
| 'refresh_quota'
| 'clear_proxy'
| 'set_proxy'
| 'update_settings'
| 'enable'
| 'disable'
export interface PoolBatchActionOption {
value: PoolBatchActionValue
label: string
hint: string
destructive?: boolean
}
export const POOL_BATCH_ACTION_OPTIONS: readonly PoolBatchActionOption[] = [
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与自动获取模型设置。' },
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
{ value: 'update_settings', label: '更多设置', hint: '选择性修改 RPM、并发、熔断、备注和代理等配置。' },
{ value: 'clear_proxy', label: '清除代理', hint: '移除账号独立代理,回退到提供商默认代理。' },
{ value: 'enable', label: '启用', hint: '批量启用账号,恢复可调度状态。' },
{ value: 'disable', label: '禁用', hint: '批量禁用账号,保留数据但停止调度。' },
{ value: 'export', label: '导出凭据', hint: '仅导出 OAuth 凭据,其他类型账号将被跳过。' },
{ value: 'delete', label: '删除账号', hint: '永久删除账号数据,执行后不可恢复。', destructive: true },
]