mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
feat(pool): add table-driven account batch actions
This commit is contained in:
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
|
||||
})
|
||||
|
||||
+224
@@ -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 },
|
||||
]
|
||||
+3
-2
@@ -37,7 +37,8 @@ describe('provider key batch import UI contract', () => {
|
||||
|
||||
expect(source).toContain("selectedAction === 'update_settings'")
|
||||
expect(source).toContain('buildPoolKeySettingsPatch')
|
||||
expect(source).toContain("confirmAndExecuteAction('update_settings')")
|
||||
expect(source).toContain('confirmAndExecuteAction(selectedAction)')
|
||||
expect(source).toContain('仅更新已勾选字段')
|
||||
expect(source).not.toContain('v-for="item in ACTION_OPTIONS"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user