feat(models): add external catalog proxy selection

This commit is contained in:
elky
2026-07-31 09:32:25 +08:00
parent 12057db476
commit beb003b7ad
34 changed files with 2286 additions and 176 deletions
@@ -0,0 +1,116 @@
<template>
<div
data-testid="external-models-access-control"
>
<Popover
:open="popoverOpen"
@update:open="popoverOpen = $event"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="proxyNodeId ? 'text-blue-500' : ''"
:disabled="loading || !configLoaded"
:title="t('models.externalCatalog.accessTitle')"
:aria-label="t('models.externalCatalog.accessTitle')"
data-testid="external-models-access-trigger"
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">
{{ t('models.externalCatalog.accessTitle') }}
</span>
<Button
v-if="proxyNodeId"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="loading"
data-testid="external-models-access-clear"
@click="updateAccessConfig(null)"
>
{{ legacyT('清除') }}
</Button>
</div>
<ProxyNodeSelect
:model-value="proxyNodeId || ''"
:disabled="loading || !configLoaded"
:trigger-aria-label="t('models.externalCatalog.accessTitle')"
trigger-class="h-8"
data-testid="external-models-access-select"
@update:model-value="updateAccessConfig"
/>
<p class="text-[10px] text-muted-foreground">
{{ t(proxyNodeId
? 'models.externalCatalog.proxyEnabled'
: 'models.externalCatalog.direct') }}
</p>
</div>
</PopoverContent>
</Popover>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Globe } from 'lucide-vue-next'
import {
getExternalModelsAccessConfig,
updateExternalModelsAccessConfig,
} from '@/api/models-dev'
import { Button, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
const { success, error: showError } = useToast()
const { t, legacyT } = useI18n()
const proxyNodeId = ref<string | null>(null)
const popoverOpen = ref(false)
const configLoaded = ref(false)
const loading = ref(true)
async function loadAccessConfig() {
loading.value = true
try {
const config = await getExternalModelsAccessConfig()
proxyNodeId.value = config.proxy_node_id
configLoaded.value = true
} catch (err: unknown) {
showError(parseApiError(err, t('models.externalCatalog.loadFailed')))
} finally {
loading.value = false
}
}
async function updateAccessConfig(nextProxyNodeId: string | null) {
if (!configLoaded.value || loading.value) return
if (nextProxyNodeId === proxyNodeId.value) return
loading.value = true
try {
const result = await updateExternalModelsAccessConfig(nextProxyNodeId)
proxyNodeId.value = result.proxy_node_id
popoverOpen.value = false
success(t('models.externalCatalog.saved'))
} catch (err: unknown) {
showError(parseApiError(err, t('models.externalCatalog.saveFailed')))
} finally {
loading.value = false
}
}
onMounted(loadAccessConfig)
</script>
@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
const modelsDevMocks = vi.hoisted(() => ({
getExternalModelsAccessConfig: vi.fn(),
updateExternalModelsAccessConfig: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}))
vi.mock('@/api/models-dev', () => modelsDevMocks)
vi.mock('@/composables/useToast', () => ({
useToast: () => toastMocks,
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('div', attrs, slots.default?.())
},
})
return {
Button: defineComponent({
name: 'ButtonStub',
inheritAttrs: false,
props: {
disabled: { type: Boolean, default: false },
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
disabled: props.disabled,
type: 'button',
}, slots.default?.())
},
}),
Popover: defineComponent({
name: 'PopoverStub',
inheritAttrs: false,
props: {
open: { type: Boolean, default: false },
},
emits: ['update:open'],
setup(props, { attrs, slots }) {
return () => h('div', {
...attrs,
'data-popover-open': String(props.open),
}, slots.default?.())
},
}),
PopoverTrigger: passthrough('PopoverTriggerStub'),
PopoverContent: passthrough('PopoverContentStub'),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
return {
Globe: defineComponent({
name: 'GlobeStub',
setup() {
return () => h('span', { 'data-testid': 'external-models-access-globe' })
},
}),
}
})
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ProxyNodeSelectStub',
inheritAttrs: false,
props: {
modelValue: { type: String, required: true },
disabled: { type: Boolean, default: false },
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('div', {
...attrs,
'data-model-value': props.modelValue,
'data-disabled': String(props.disabled),
}, [
h('button', {
type: 'button',
'data-testid': 'select-proxy-node',
onClick: () => emit('update:modelValue', 'proxy-1'),
}),
])
},
}),
}
})
import ExternalModelsAccessControl from '@/features/models/components/ExternalModelsAccessControl.vue'
import { createI18n, setI18nLocale } from '@/i18n'
interface MountedControl {
app: ReturnType<typeof createApp>
root: HTMLDivElement
}
const mountedControls: MountedControl[] = []
function mountControl(): MountedControl {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(ExternalModelsAccessControl)
app.use(createI18n())
app.mount(root)
const mounted = { app, root }
mountedControls.push(mounted)
return mounted
}
async function flushAsyncState() {
await Promise.resolve()
await Promise.resolve()
await nextTick()
}
beforeEach(() => {
setI18nLocale('zh-CN')
modelsDevMocks.getExternalModelsAccessConfig.mockReset()
modelsDevMocks.getExternalModelsAccessConfig.mockResolvedValue({ proxy_node_id: null })
modelsDevMocks.updateExternalModelsAccessConfig.mockReset()
modelsDevMocks.updateExternalModelsAccessConfig.mockImplementation(
async (proxyNodeId: string | null) => ({
proxy_node_id: proxyNodeId,
cache_cleared: true,
}),
)
toastMocks.success.mockReset()
toastMocks.error.mockReset()
})
afterEach(() => {
for (const mounted of mountedControls.splice(0)) {
mounted.app.unmount()
mounted.root.remove()
}
})
describe('ExternalModelsAccessControl', () => {
it('uses the provider-style globe button as the header control', async () => {
const { root } = mountControl()
await flushAsyncState()
const trigger = root.querySelector('[data-testid="external-models-access-trigger"]')
expect(trigger?.tagName).toBe('BUTTON')
expect(trigger?.classList.contains('h-8')).toBe(true)
expect(trigger?.classList.contains('w-8')).toBe(true)
expect(root.querySelector('[data-testid="external-models-access-globe"]')).not.toBeNull()
expect(trigger?.getAttribute('aria-label')).toBe('外部模型目录代理节点')
})
it('loads the saved node into the shared proxy selector', async () => {
modelsDevMocks.getExternalModelsAccessConfig.mockResolvedValue({
proxy_node_id: 'proxy-1',
})
const { root } = mountControl()
await flushAsyncState()
const select = root.querySelector('[data-testid="external-models-access-select"]')
expect(select?.getAttribute('data-model-value')).toBe('proxy-1')
expect(select?.getAttribute('data-disabled')).toBe('false')
expect(root.querySelector('[data-testid="external-models-access-trigger"]')
?.classList.contains('text-blue-500')).toBe(true)
expect(root.querySelector('[data-testid="external-models-access-clear"]')).not.toBeNull()
})
it('immediately saves a selected proxy node', async () => {
const { root } = mountControl()
await flushAsyncState()
;(root.querySelector('[data-testid="select-proxy-node"]') as HTMLButtonElement).click()
await flushAsyncState()
expect(modelsDevMocks.updateExternalModelsAccessConfig).toHaveBeenCalledWith('proxy-1')
expect(toastMocks.success).toHaveBeenCalledWith('外部模型目录代理节点已保存')
expect(root.querySelector('[data-testid="external-models-access-select"]')
?.getAttribute('data-model-value')).toBe('proxy-1')
})
it('clears the selected proxy back to direct access', async () => {
modelsDevMocks.getExternalModelsAccessConfig.mockResolvedValue({
proxy_node_id: 'proxy-1',
})
const { root } = mountControl()
await flushAsyncState()
;(root.querySelector('[data-testid="external-models-access-clear"]') as HTMLButtonElement).click()
await flushAsyncState()
expect(modelsDevMocks.updateExternalModelsAccessConfig).toHaveBeenCalledWith(null)
expect(root.querySelector('[data-testid="external-models-access-select"]')
?.getAttribute('data-model-value')).toBe('')
expect(root.querySelector('[data-testid="external-models-access-clear"]')).toBeNull()
})
it('keeps the globe trigger and selector disabled when config loading fails', async () => {
modelsDevMocks.getExternalModelsAccessConfig.mockRejectedValue(new Error('load failed'))
const { root } = mountControl()
await flushAsyncState()
expect(root.querySelector('[data-testid="external-models-access-select"]')
?.getAttribute('data-disabled')).toBe('true')
expect((root.querySelector('[data-testid="external-models-access-trigger"]') as HTMLButtonElement)
.disabled).toBe(true)
expect(toastMocks.error).toHaveBeenCalledOnce()
})
})
@@ -1,4 +1,5 @@
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
export { default as ExternalModelsAccessControl } from './ExternalModelsAccessControl.vue'
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
export { default as ModelMappingsTab } from './ModelMappingsTab.vue'