feat(frontend): improve provider detail drawer and pool actions

This commit is contained in:
elky
2026-07-25 11:16:59 +08:00
parent 387134ca87
commit 764e9fd131
10 changed files with 158 additions and 31 deletions
@@ -8,7 +8,7 @@ vi.mock('@/api/client', () => ({
}, },
})) }))
import { getProviderMappingPreview, getProvidersSummary } from '@/api/endpoints/providers' import { getProvider, getProviderMappingPreview, getProvidersSummary } from '@/api/endpoints/providers'
const provider = { const provider = {
id: 'provider-1', id: 'provider-1',
@@ -45,6 +45,30 @@ describe('getProvidersSummary', () => {
}) })
}) })
describe('getProvider', () => {
beforeEach(() => {
getMock.mockReset()
})
it('deduplicates concurrent detail requests without caching the settled result', async () => {
let resolveRequest: ((value: { data: typeof provider }) => void) | undefined
getMock.mockImplementation(() => new Promise((resolve) => {
resolveRequest = resolve
}))
const firstRequest = getProvider('provider-1')
const secondRequest = getProvider('provider-1')
expect(getMock).toHaveBeenCalledTimes(1)
resolveRequest?.({ data: provider })
await expect(Promise.all([firstRequest, secondRequest])).resolves.toHaveLength(2)
getMock.mockResolvedValue({ data: provider })
await getProvider('provider-1')
expect(getMock).toHaveBeenCalledTimes(2)
})
})
describe('getProviderMappingPreview', () => { describe('getProviderMappingPreview', () => {
beforeEach(() => { beforeEach(() => {
getMock.mockReset() getMock.mockReset()
+4 -2
View File
@@ -94,8 +94,10 @@ export async function getProvidersSummary(
* 获取单个 Provider 的详细信息 * 获取单个 Provider 的详细信息
*/ */
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> { export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`) return dedupedRequest(`providers:detail:${providerId}`, async () => {
return normalizeProviderSummary(response.data) const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`)
return normalizeProviderSummary(response.data)
})
} }
/** /**
@@ -90,6 +90,8 @@
size="icon" size="icon"
class="h-8 w-8 shrink-0" class="h-8 w-8 shrink-0"
:title="action.title" :title="action.title"
@pointerenter="action.key === 'viewProvider' && emit('prefetchProvider')"
@focus="action.key === 'viewProvider' && emit('prefetchProvider')"
@click="emit(action.event)" @click="emit(action.event)"
> >
<component <component
@@ -249,6 +251,8 @@
class="h-8 w-8" class="h-8 w-8"
:data-testid="action.key === 'demandMetrics' ? 'pool-demand-metrics-button' : undefined" :data-testid="action.key === 'demandMetrics' ? 'pool-demand-metrics-button' : undefined"
:title="action.title" :title="action.title"
@pointerenter="action.key === 'viewProvider' && emit('prefetchProvider')"
@focus="action.key === 'viewProvider' && emit('prefetchProvider')"
@click="emit(action.event)" @click="emit(action.event)"
> >
<component <component
@@ -329,6 +333,7 @@ import {
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
SquareCheckBig, SquareCheckBig,
Upload,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
Button, Button,
@@ -352,12 +357,14 @@ import {
} from '@/features/pool/utils/poolBatchActions' } from '@/features/pool/utils/poolBatchActions'
type HeaderActionEvent = type HeaderActionEvent =
| 'import'
| 'scheduling' | 'scheduling'
| 'viewProvider' | 'viewProvider'
| 'demandMetrics' | 'demandMetrics'
| 'advanced' | 'advanced'
type HeaderActionKey = type HeaderActionKey =
| 'import'
| 'scheduling' | 'scheduling'
| 'viewProvider' | 'viewProvider'
| 'demandMetrics' | 'demandMetrics'
@@ -395,6 +402,8 @@ const emit = defineEmits<{
'update:providerId': [value: string] 'update:providerId': [value: string]
'update:status': [value: string] 'update:status': [value: string]
'update:search': [value: string] 'update:search': [value: string]
import: []
prefetchProvider: []
scheduling: [] scheduling: []
viewProvider: [] viewProvider: []
demandMetrics: [] demandMetrics: []
@@ -428,6 +437,7 @@ const selectedCountLabel = computed(() => legacyT(`已选 ${Math.max(0, props.se
const mobileActions = computed<HeaderAction[]>(() => { const mobileActions = computed<HeaderAction[]>(() => {
const actions: HeaderAction[] = [ const actions: HeaderAction[] = [
{ key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye }, { key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye },
{ key: 'import', title: legacyT('导入账号'), event: 'import', icon: Upload },
{ key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal }, { key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal },
] ]
if (props.showAdaptiveHotPoolMetricsButton) { if (props.showAdaptiveHotPoolMetricsButton) {
@@ -442,6 +452,7 @@ const mobileActions = computed<HeaderAction[]>(() => {
const desktopActions = computed<HeaderAction[]>(() => { const desktopActions = computed<HeaderAction[]>(() => {
const actions: HeaderAction[] = [ const actions: HeaderAction[] = [
{ key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye }, { key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye },
{ key: 'import', title: legacyT('导入账号'), event: 'import', icon: Upload },
] ]
if (props.showAdaptiveHotPoolMetricsButton) { if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity }) actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
@@ -35,6 +35,8 @@ describe('PoolManagementHeader', () => {
refreshLoading: false, refreshLoading: false,
refreshTitle: '刷新', refreshTitle: '刷新',
onViewProvider: () => events.push('viewProvider'), onViewProvider: () => events.push('viewProvider'),
onImport: () => events.push('import'),
onPrefetchProvider: () => events.push('prefetchProvider'),
onScheduling: () => events.push('scheduling'), onScheduling: () => events.push('scheduling'),
onDemandMetrics: () => events.push('demandMetrics'), onDemandMetrics: () => events.push('demandMetrics'),
onAdvanced: () => events.push('advanced'), onAdvanced: () => events.push('advanced'),
@@ -51,7 +53,10 @@ describe('PoolManagementHeader', () => {
app.use(createI18n()) app.use(createI18n())
app.mount(root) app.mount(root)
root.querySelector<HTMLButtonElement>('[title="查看详情"]')?.click() const mobileViewProviderButton = root.querySelector<HTMLButtonElement>('[title="查看详情"]')
mobileViewProviderButton?.dispatchEvent(new Event('pointerenter'))
mobileViewProviderButton?.click()
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 desktopActions = root.querySelector('[data-testid="pool-header-actions"]')
@@ -67,7 +72,9 @@ describe('PoolManagementHeader', () => {
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click() root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
expect(events).toEqual([ expect(events).toEqual([
'prefetchProvider',
'viewProvider', 'viewProvider',
'import',
'scheduling', 'scheduling',
'demandMetrics', 'demandMetrics',
'advanced', 'advanced',
@@ -75,8 +82,11 @@ describe('PoolManagementHeader', () => {
'batchAction:refresh_quota', 'batchAction:refresh_quota',
'refresh', 'refresh',
]) ])
const viewProviderButton = desktopActions?.querySelector<HTMLButtonElement>('[title="查看详情"]')
const importButton = desktopActions?.querySelector<HTMLButtonElement>('[title="导入账号"]')
expect(selectAllButton?.textContent?.trim()).toBe('') expect(selectAllButton?.textContent?.trim()).toBe('')
expect(selectAllButton?.getAttribute('title')).toBe('全选') expect(selectAllButton?.getAttribute('title')).toBe('全选')
expect(viewProviderButton?.nextElementSibling).toBe(importButton)
expect(advancedButton?.nextElementSibling).toBe(selectAllButton) expect(advancedButton?.nextElementSibling).toBe(selectAllButton)
expect(selectAllButton?.nextElementSibling).toBe(batchActionsButton) expect(selectAllButton?.nextElementSibling).toBe(batchActionsButton)
expect(batchActionsButton?.getAttribute('title')).toBe('选择执行动作') expect(batchActionsButton?.getAttribute('title')).toBe('选择执行动作')
@@ -1,7 +1,10 @@
<template> <template>
<!-- 自定义抽屉 --> <!-- 自定义抽屉 -->
<Teleport to="body"> <Teleport to="body">
<Transition name="drawer"> <Transition
name="drawer"
appear
>
<div <div
v-if="open && (loading || provider)" v-if="open && (loading || provider)"
class="fixed inset-0 z-50 flex justify-end" class="fixed inset-0 z-50 flex justify-end"
@@ -9,12 +12,12 @@
> >
<!-- 背景遮罩 --> <!-- 背景遮罩 -->
<div <div
class="absolute inset-0 bg-black/30 backdrop-blur-sm" class="absolute inset-0 bg-black/30"
@click="handleBackdropClick" @click="handleBackdropClick"
/> />
<!-- 抽屉内容 --> <!-- 抽屉内容 -->
<Card class="relative h-full w-full sm:w-[700px] sm:max-w-[90vw] rounded-none shadow-2xl overflow-y-auto"> <Card class="drawer-panel relative h-full w-full sm:w-[700px] sm:max-w-[90vw] rounded-none shadow-2xl overflow-y-auto">
<!-- 加载状态 --> <!-- 加载状态 -->
<div <div
v-if="loading" v-if="loading"
@@ -810,7 +813,7 @@
<!-- 端点表单对话框(管理/编辑) --> <!-- 端点表单对话框(管理/编辑) -->
<EndpointFormDialog <EndpointFormDialog
v-if="provider && open" v-if="provider && open && endpointDialogOpen"
v-model="endpointDialogOpen" v-model="endpointDialogOpen"
:provider="provider" :provider="provider"
:endpoints="endpoints" :endpoints="endpoints"
@@ -822,7 +825,7 @@
<!-- 密钥编辑对话框 --> <!-- 密钥编辑对话框 -->
<KeyFormDialog <KeyFormDialog
v-if="open" v-if="open && keyFormDialogOpen"
:open="keyFormDialogOpen" :open="keyFormDialogOpen"
:endpoint="currentEndpoint" :endpoint="currentEndpoint"
:editing-key="editingKey" :editing-key="editingKey"
@@ -834,7 +837,7 @@
/> />
<ProviderKeyBatchImportDialog <ProviderKeyBatchImportDialog
v-if="open && provider?.provider_type === 'custom'" v-if="open && keyBatchImportDialogOpen && provider?.provider_type === 'custom'"
:open="keyBatchImportDialogOpen" :open="keyBatchImportDialogOpen"
:provider-id="provider.id" :provider-id="provider.id"
:provider-name="provider.name" :provider-name="provider.name"
@@ -845,7 +848,7 @@
<!-- OAuth 账号对话框 --> <!-- OAuth 账号对话框 -->
<OAuthAccountDialog <OAuthAccountDialog
v-if="open && provider" v-if="open && oauthAccountDialogOpen && provider"
:open="oauthAccountDialogOpen" :open="oauthAccountDialogOpen"
:provider-id="provider.id" :provider-id="provider.id"
:provider-type="provider.provider_type" :provider-type="provider.provider_type"
@@ -855,7 +858,7 @@
<!-- OAuth 密钥编辑对话框 --> <!-- OAuth 密钥编辑对话框 -->
<OAuthKeyEditDialog <OAuthKeyEditDialog
v-if="open" v-if="open && oauthKeyEditDialogOpen"
:open="oauthKeyEditDialogOpen" :open="oauthKeyEditDialogOpen"
:editing-key="editingKey" :editing-key="editingKey"
@close="oauthKeyEditDialogOpen = false" @close="oauthKeyEditDialogOpen = false"
@@ -864,7 +867,7 @@
<!-- 模型权限对话框 --> <!-- 模型权限对话框 -->
<KeyAllowedModelsEditDialog <KeyAllowedModelsEditDialog
v-if="open" v-if="open && keyPermissionsDialogOpen"
:open="keyPermissionsDialogOpen" :open="keyPermissionsDialogOpen"
:api-key="editingKey" :api-key="editingKey"
:provider-id="providerId || ''" :provider-id="providerId || ''"
@@ -874,7 +877,7 @@
<!-- 删除密钥确认对话框 --> <!-- 删除密钥确认对话框 -->
<AlertDialog <AlertDialog
v-if="open" v-if="open && deleteKeyConfirmOpen"
:model-value="deleteKeyConfirmOpen" :model-value="deleteKeyConfirmOpen"
:title="legacyT('删除密钥')" :title="legacyT('删除密钥')"
:description="formatDeleteKeyConfirmDescription()" :description="formatDeleteKeyConfirmDescription()"
@@ -888,7 +891,7 @@
<!-- 添加/编辑模型对话框 --> <!-- 添加/编辑模型对话框 -->
<ProviderModelFormDialog <ProviderModelFormDialog
v-if="open && provider" v-if="open && modelFormDialogOpen && provider"
:open="modelFormDialogOpen" :open="modelFormDialogOpen"
:provider-id="provider.id" :provider-id="provider.id"
:provider-name="provider.name" :provider-name="provider.name"
@@ -899,7 +902,7 @@
<!-- 批量关联模型对话框 --> <!-- 批量关联模型对话框 -->
<BatchAssignModelsDialog <BatchAssignModelsDialog
v-if="open && provider" v-if="open && batchAssignDialogOpen && provider"
:open="batchAssignDialogOpen" :open="batchAssignDialogOpen"
:provider-id="provider.id" :provider-id="provider.id"
:provider-name="provider.name" :provider-name="provider.name"
@@ -921,6 +924,7 @@
<!-- 故障转移规则弹窗 --> <!-- 故障转移规则弹窗 -->
<FailoverRulesDialog <FailoverRulesDialog
v-if="open && failoverRulesDialogOpen"
:open="failoverRulesDialogOpen" :open="failoverRulesDialogOpen"
:provider="provider ?? null" :provider="provider ?? null"
@update:open="failoverRulesDialogOpen = $event" @update:open="failoverRulesDialogOpen = $event"
@@ -3908,8 +3912,8 @@ useEscapeKey(() => {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.drawer-enter-active .relative, .drawer-enter-active .drawer-panel,
.drawer-leave-active .relative { .drawer-leave-active .drawer-panel {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
@@ -3918,16 +3922,16 @@ useEscapeKey(() => {
opacity: 0; opacity: 0;
} }
.drawer-enter-from .relative { .drawer-enter-from .drawer-panel {
transform: translateX(100%); transform: translateX(100%);
} }
.drawer-leave-to .relative { .drawer-leave-to .drawer-panel {
transform: translateX(100%); transform: translateX(100%);
} }
.drawer-enter-to .relative, .drawer-enter-to .drawer-panel,
.drawer-leave-from .relative { .drawer-leave-from .drawer-panel {
transform: translateX(0); transform: translateX(0);
} }
@@ -8,6 +8,18 @@ const source = readFileSync(
) )
describe('ProviderDetailDrawer loading priorities', () => { describe('ProviderDetailDrawer loading priorities', () => {
it('plays the drawer transition when the lazily mounted component first appears', () => {
const transitionTemplate = source
.split('<Transition')[1]
?.split('>')[0]
expect(transitionTemplate).toContain('name="drawer"')
expect(transitionTemplate).toContain('appear')
expect(source).toContain('class="drawer-panel relative')
expect(source).toContain('.drawer-enter-from .drawer-panel')
expect(source).not.toContain('.drawer-enter-from .relative')
})
it('loads mapping preview after first-screen provider data', () => { it('loads mapping preview after first-screen provider data', () => {
const openWatcher = source const openWatcher = source
.split('// 合并监听 providerId 和 open')[1] .split('// 合并监听 providerId 和 open')[1]
@@ -29,4 +41,17 @@ describe('ProviderDetailDrawer loading priorities', () => {
expect(source).not.toContain(':loading="loadingProviderModels || loadingProviderKeys"') expect(source).not.toContain(':loading="loadingProviderModels || loadingProviderKeys"')
expect(source).not.toContain(':loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"') expect(source).not.toContain(':loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"')
}) })
it('does not mount closed child dialogs or blur the page behind the drawer', () => {
expect(source).toContain('class="absolute inset-0 bg-black/30"')
expect(source).not.toContain('backdrop-blur-sm')
expect(source).toContain('v-if="provider && open && endpointDialogOpen"')
expect(source).toContain('v-if="open && keyFormDialogOpen"')
expect(source).toContain('v-if="open && oauthAccountDialogOpen && provider"')
expect(source).toContain('v-if="open && oauthKeyEditDialogOpen"')
expect(source).toContain('v-if="open && keyPermissionsDialogOpen"')
expect(source).toContain('v-if="open && modelFormDialogOpen && provider"')
expect(source).toContain('v-if="open && batchAssignDialogOpen && provider"')
expect(source).toContain('v-if="open && failoverRulesDialogOpen"')
})
}) })
+48 -4
View File
@@ -21,6 +21,8 @@
:refresh-loading="refreshCurrentPageLoading" :refresh-loading="refreshCurrentPageLoading"
:refresh-title="refreshButtonTitle" :refresh-title="refreshButtonTitle"
@view-provider="openProviderDrawer" @view-provider="openProviderDrawer"
@prefetch-provider="prefetchProviderDetailDrawer"
@import="showImportDialog = true"
@scheduling="openSchedulingDialog" @scheduling="openSchedulingDialog"
@demand-metrics="showDemandMetricsDialog = true" @demand-metrics="showDemandMetricsDialog = true"
@advanced="showAdvancedDialog = true" @advanced="showAdvancedDialog = true"
@@ -949,7 +951,7 @@
:samples="providerDemandMetricSamples" :samples="providerDemandMetricSamples"
/> />
<ProviderDetailDrawer <ProviderDetailDrawer
v-if="providerDrawerOpen && selectedProviderId" v-if="providerDrawerMounted && selectedProviderId"
:open="providerDrawerOpen" :open="providerDrawerOpen"
:provider-id="selectedProviderId" :provider-id="selectedProviderId"
:initial-provider="selectedProviderData" :initial-provider="selectedProviderData"
@@ -1157,9 +1159,12 @@ import {
getQuotaDisplayText, getQuotaDisplayText,
} from '@/utils/providerKeyQuota' } from '@/utils/providerKeyQuota'
const ProviderDetailDrawer = defineAsyncComponent( const loadProviderDetailDrawer = () => import('@/features/providers/components/ProviderDetailDrawer.vue')
() => import('@/features/providers/components/ProviderDetailDrawer.vue'), const ProviderDetailDrawer = defineAsyncComponent(loadProviderDetailDrawer)
)
function prefetchProviderDetailDrawer(): void {
void loadProviderDetailDrawer().catch(() => {})
}
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']> type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
@@ -1193,6 +1198,8 @@ let selectProviderRequestId = 0
let providerDataRequestId = 0 let providerDataRequestId = 0
let keysRequestId = 0 let keysRequestId = 0
let keysSearchDebounceTimer: number | null = null let keysSearchDebounceTimer: number | null = null
let providerDetailDrawerPrefetchIdleId: number | null = null
let providerDetailDrawerPrefetchTimer: number | null = null
const keysSearchPending = ref(false) const keysSearchPending = ref(false)
let demandMetricsPollingTimer: number | null = null let demandMetricsPollingTimer: number | null = null
let demandMetricsRequestId = 0 let demandMetricsRequestId = 0
@@ -1204,6 +1211,38 @@ const POOL_SCHEDULING_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
const POOL_DEMAND_METRICS_SAMPLES_LIMIT = 120 const POOL_DEMAND_METRICS_SAMPLES_LIMIT = 120
const POOL_DEMAND_METRICS_POLL_INTERVAL_MS = 10 * 1000 const POOL_DEMAND_METRICS_POLL_INTERVAL_MS = 10 * 1000
type IdleCallbackWindow = typeof window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number
cancelIdleCallback?: (handle: number) => void
}
function scheduleProviderDetailDrawerPrefetch(): void {
const idleWindow = window as IdleCallbackWindow
if (idleWindow.requestIdleCallback) {
providerDetailDrawerPrefetchIdleId = idleWindow.requestIdleCallback(() => {
providerDetailDrawerPrefetchIdleId = null
prefetchProviderDetailDrawer()
}, { timeout: 1500 })
return
}
providerDetailDrawerPrefetchTimer = window.setTimeout(() => {
providerDetailDrawerPrefetchTimer = null
prefetchProviderDetailDrawer()
}, 800)
}
function cancelProviderDetailDrawerPrefetch(): void {
const idleWindow = window as IdleCallbackWindow
if (providerDetailDrawerPrefetchIdleId !== null) {
idleWindow.cancelIdleCallback?.(providerDetailDrawerPrefetchIdleId)
providerDetailDrawerPrefetchIdleId = null
}
if (providerDetailDrawerPrefetchTimer !== null) {
clearTimeout(providerDetailDrawerPrefetchTimer)
providerDetailDrawerPrefetchTimer = null
}
}
interface PoolDemandMetricSample { interface PoolDemandMetricSample {
providerId: string providerId: string
sampledAt: number sampledAt: number
@@ -2827,6 +2866,7 @@ const showImportDialog = ref(false)
const showSchedulingDialog = ref(false) const showSchedulingDialog = ref(false)
const showAdvancedDialog = ref(false) const showAdvancedDialog = ref(false)
const providerDrawerOpen = ref(false) const providerDrawerOpen = ref(false)
const providerDrawerMounted = ref(false)
const providerEditDialogOpen = ref(false) const providerEditDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null) const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const showAccountBatchDialog = ref(false) const showAccountBatchDialog = ref(false)
@@ -2848,7 +2888,9 @@ watch(showAccountBatchDialog, (open) => {
}) })
function openProviderDrawer(): void { function openProviderDrawer(): void {
prefetchProviderDetailDrawer()
if (!selectedProviderId.value) return if (!selectedProviderId.value) return
providerDrawerMounted.value = true
providerDrawerOpen.value = true providerDrawerOpen.value = true
} }
@@ -3914,9 +3956,11 @@ onMounted(() => {
startCountdownTimer() startCountdownTimer()
void loadSchedulingPresetMetas({ cacheTtlMs: POOL_SCHEDULING_PRESETS_CACHE_TTL_MS }) void loadSchedulingPresetMetas({ cacheTtlMs: POOL_SCHEDULING_PRESETS_CACHE_TTL_MS })
void loadOverview({ cacheTtlMs: POOL_OVERVIEW_CACHE_TTL_MS }) void loadOverview({ cacheTtlMs: POOL_OVERVIEW_CACHE_TTL_MS })
scheduleProviderDetailDrawerPrefetch()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelProviderDetailDrawerPrefetch()
stopDemandMetricsPolling() stopDemandMetricsPolling()
if (keysSearchDebounceTimer !== null) { if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer) clearTimeout(keysSearchDebounceTimer)
@@ -221,7 +221,7 @@
/> />
<ProviderDetailDrawer <ProviderDetailDrawer
v-if="providerDrawerOpen" v-if="providerDrawerMounted"
:open="providerDrawerOpen" :open="providerDrawerOpen"
:provider-id="selectedProviderId" :provider-id="selectedProviderId"
:initial-provider="selectedProvider" :initial-provider="selectedProvider"
@@ -310,6 +310,7 @@ const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const priorityDialogOpen = ref(false) const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider') const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false) const providerDrawerOpen = ref(false)
const providerDrawerMounted = ref(false)
const selectedProviderId = ref<string | null>(null) const selectedProviderId = ref<string | null>(null)
const selectedProvider = computed<ProviderWithEndpointsSummary | null>(() => { const selectedProvider = computed<ProviderWithEndpointsSummary | null>(() => {
if (!selectedProviderId.value) return null if (!selectedProviderId.value) return null
@@ -639,6 +640,7 @@ async function handleProviderBatchChanged() {
// 打开提供商详情抽屉 // 打开提供商详情抽屉
function openProviderDrawer(providerId: string) { function openProviderDrawer(providerId: string) {
selectedProviderId.value = providerId selectedProviderId.value = providerId
providerDrawerMounted.value = true
providerDrawerOpen.value = true providerDrawerOpen.value = true
} }
@@ -8,13 +8,16 @@ const source = readFileSync(
) )
describe('PoolManagement provider detail drawer', () => { describe('PoolManagement provider detail drawer', () => {
it('keeps the heavy drawer out of the initial route chunk', () => { it('keeps the heavy drawer out of the initial route chunk and prefetches it when idle', () => {
expect(source).not.toContain( expect(source).not.toContain(
"import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'", "import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'",
) )
expect(source).toContain( expect(source).toContain(
"() => import('@/features/providers/components/ProviderDetailDrawer.vue')", "const loadProviderDetailDrawer = () => import('@/features/providers/components/ProviderDetailDrawer.vue')",
) )
expect(source).toContain('defineAsyncComponent(loadProviderDetailDrawer)')
expect(source).toContain('scheduleProviderDetailDrawerPrefetch()')
expect(source).toContain('@prefetch-provider="prefetchProviderDetailDrawer"')
}) })
it('only mounts the drawer after the provider detail action is opened', () => { it('only mounts the drawer after the provider detail action is opened', () => {
@@ -23,7 +26,8 @@ describe('PoolManagement provider detail drawer', () => {
?.split('/>')[0] ?.split('/>')[0]
expect(drawerTemplate).toBeTruthy() expect(drawerTemplate).toBeTruthy()
expect(drawerTemplate).toContain('v-if="providerDrawerOpen && selectedProviderId"') expect(drawerTemplate).toContain('v-if="providerDrawerMounted && selectedProviderId"')
expect(source).toContain('@view-provider="openProviderDrawer"') expect(source).toContain('@view-provider="openProviderDrawer"')
expect(source).toContain('providerDrawerMounted.value = true')
}) })
}) })
@@ -23,6 +23,7 @@ describe('ProviderManagement detail drawer loading', () => {
?.split('/>')[0] ?.split('/>')[0]
expect(drawerTemplate).toBeTruthy() expect(drawerTemplate).toBeTruthy()
expect(drawerTemplate).toContain('v-if="providerDrawerOpen"') expect(drawerTemplate).toContain('v-if="providerDrawerMounted"')
expect(source).toContain('providerDrawerMounted.value = true')
}) })
}) })