mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
feat(models): add external catalog proxy selection
This commit is contained in:
@@ -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>
|
||||
+222
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user