mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
feat(models): add external catalog proxy selection
This commit is contained in:
@@ -2,23 +2,62 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: { get: apiMocks.get, delete: apiMocks.delete },
|
||||
default: { get: apiMocks.get, put: apiMocks.put, delete: apiMocks.delete },
|
||||
}))
|
||||
|
||||
import { clearModelsDevCache, getModelsDevList, refreshModelsDevList } from '@/api/models-dev'
|
||||
import {
|
||||
clearModelsDevCache,
|
||||
getExternalModelsAccessConfig,
|
||||
getModelsDevList,
|
||||
refreshModelsDevList,
|
||||
updateExternalModelsAccessConfig,
|
||||
} from '@/api/models-dev'
|
||||
|
||||
beforeEach(() => {
|
||||
clearModelsDevCache()
|
||||
localStorage.clear()
|
||||
apiMocks.get.mockReset()
|
||||
apiMocks.put.mockReset()
|
||||
apiMocks.delete.mockReset()
|
||||
apiMocks.delete.mockResolvedValue({ data: { cleared: true } })
|
||||
})
|
||||
|
||||
describe('external models access config', () => {
|
||||
it('reads the configured proxy node', async () => {
|
||||
apiMocks.get.mockResolvedValue({ data: { proxy_node_id: 'proxy-1' } })
|
||||
|
||||
await expect(getExternalModelsAccessConfig()).resolves.toEqual({
|
||||
proxy_node_id: 'proxy-1',
|
||||
})
|
||||
expect(apiMocks.get).toHaveBeenCalledWith('/api/admin/models/external/config')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a proxy node', 'proxy-1'],
|
||||
['direct access', null],
|
||||
] as const)('updates %s and clears the browser cache', async (_label, proxyNodeId) => {
|
||||
localStorage.setItem('models_dev_cache', JSON.stringify({ timestamp: Date.now(), data: {} }))
|
||||
apiMocks.put.mockResolvedValue({
|
||||
data: { proxy_node_id: proxyNodeId, cache_cleared: true },
|
||||
})
|
||||
|
||||
await expect(updateExternalModelsAccessConfig(proxyNodeId)).resolves.toEqual({
|
||||
proxy_node_id: proxyNodeId,
|
||||
cache_cleared: true,
|
||||
})
|
||||
expect(apiMocks.put).toHaveBeenCalledWith(
|
||||
'/api/admin/models/external/config',
|
||||
{ proxy_node_id: proxyNodeId },
|
||||
)
|
||||
expect(localStorage.getItem('models_dev_cache')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getModelsDevList', () => {
|
||||
it('uses current modalities and experimental mode pricing while keeping legacy fallbacks', async () => {
|
||||
apiMocks.get.mockResolvedValue({
|
||||
|
||||
@@ -73,6 +73,14 @@ export interface ModelsDevProvider {
|
||||
|
||||
export type ModelsDevData = Record<string, ModelsDevProvider>
|
||||
|
||||
export interface ExternalModelsAccessConfig {
|
||||
proxy_node_id: string | null
|
||||
}
|
||||
|
||||
export interface ExternalModelsAccessConfigUpdate extends ExternalModelsAccessConfig {
|
||||
cache_cleared: boolean
|
||||
}
|
||||
|
||||
// 扁平化的模型列表项(用于搜索和选择)
|
||||
export interface ModelsDevModelItem {
|
||||
providerId: string
|
||||
@@ -165,6 +173,29 @@ export async function getModelsDevData(): Promise<ModelsDevData> {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 models.dev 目录的出站访问配置。
|
||||
*/
|
||||
export async function getExternalModelsAccessConfig(): Promise<ExternalModelsAccessConfig> {
|
||||
const response = await api.get<ExternalModelsAccessConfig>('/api/admin/models/external/config')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 models.dev 目录的代理节点。null 表示直连。
|
||||
* 后端会清理共享目录缓存;前端同时清理内存和 localStorage 缓存。
|
||||
*/
|
||||
export async function updateExternalModelsAccessConfig(
|
||||
proxyNodeId: string | null,
|
||||
): Promise<ExternalModelsAccessConfigUpdate> {
|
||||
const response = await api.put<ExternalModelsAccessConfigUpdate>(
|
||||
'/api/admin/models/external/config',
|
||||
{ proxy_node_id: proxyNodeId },
|
||||
)
|
||||
clearModelsDevCache()
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 模型列表缓存(避免重复转换)
|
||||
let modelsListCache: ModelsDevModelItem[] | null = null
|
||||
let modelsListCacheTimestamp: number | null = null
|
||||
|
||||
@@ -269,8 +269,18 @@ export const proxyNodesApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteProxyNode(nodeId: string): Promise<{ message: string; node_id: string; cleared_system_proxy: boolean }> {
|
||||
const response = await apiClient.delete<{ message: string; node_id: string; cleared_system_proxy: boolean }>(`/api/admin/proxy-nodes/${nodeId}`)
|
||||
async deleteProxyNode(nodeId: string): Promise<{
|
||||
message: string
|
||||
node_id: string
|
||||
cleared_system_proxy: boolean
|
||||
cleared_external_models_proxy: boolean
|
||||
}> {
|
||||
const response = await apiClient.delete<{
|
||||
message: string
|
||||
node_id: string
|
||||
cleared_system_proxy: boolean
|
||||
cleared_external_models_proxy: boolean
|
||||
}>(`/api/admin/proxy-nodes/${nodeId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
<div class="space-y-1.5">
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="proxyNodesStore.loading || nodeOptions.length === 0"
|
||||
:disabled="disabled || proxyNodesStore.loading || nodeOptions.length === 0"
|
||||
@update:model-value="(v: string) => $emit('update:modelValue', v)"
|
||||
>
|
||||
<SelectTrigger :class="triggerClass">
|
||||
<SelectTrigger
|
||||
:class="triggerClass"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
<SelectValue
|
||||
:placeholder="proxyNodesStore.loading
|
||||
? legacyT('加载节点列表中...')
|
||||
@@ -40,10 +43,16 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
triggerClass?: string
|
||||
}>()
|
||||
triggerAriaLabel?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
triggerClass: '',
|
||||
triggerAriaLabel: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h } from 'vue'
|
||||
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
@@ -19,34 +19,81 @@ vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = (name: string) => defineComponent({
|
||||
name,
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.())
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h('div', attrs, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
Select: passthrough('SelectStub'),
|
||||
Select: defineComponent({
|
||||
name: 'SelectStub',
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'proxy-select',
|
||||
'data-disabled': String(props.disabled),
|
||||
}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
SelectTrigger: passthrough('SelectTriggerStub'),
|
||||
SelectValue: passthrough('SelectValueStub'),
|
||||
SelectContent: passthrough('SelectContentStub'),
|
||||
SelectItem: passthrough('SelectItemStub'),
|
||||
SelectItem: defineComponent({
|
||||
name: 'SelectItemStub',
|
||||
props: {
|
||||
value: { type: String, required: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { 'data-select-value': props.value }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function mountSelect(props: Record<string, unknown> = {}) {
|
||||
const root = document.createElement('div')
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ProxyNodeSelect, { modelValue: '', ...props })
|
||||
},
|
||||
}))
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
return { app, root }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
proxyNodesStore.ensureLoaded.mockClear()
|
||||
})
|
||||
|
||||
describe('ProxyNodeSelect', () => {
|
||||
it('loads proxy nodes when mounted', () => {
|
||||
const root = document.createElement('div')
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ProxyNodeSelect, { modelValue: '' })
|
||||
},
|
||||
}))
|
||||
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
const { app } = mountSelect()
|
||||
|
||||
expect(proxyNodesStore.ensureLoaded).toHaveBeenCalledTimes(1)
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('stays disabled when there are no proxy nodes', () => {
|
||||
const { app, root } = mountSelect()
|
||||
|
||||
expect(root.querySelector('[data-testid="proxy-select"]')
|
||||
?.getAttribute('data-disabled')).toBe('true')
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('applies an accessible label to the select trigger', () => {
|
||||
const { app, root } = mountSelect({
|
||||
triggerAriaLabel: 'External model catalog access',
|
||||
})
|
||||
|
||||
expect(root.querySelector('[aria-label="External model catalog access"]')).not.toBeNull()
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -286,6 +286,12 @@ export const messages = {
|
||||
'nav.userManagement': '用户管理',
|
||||
'nav.providers': '提供商',
|
||||
'nav.modelManagement': '模型管理',
|
||||
'models.externalCatalog.accessTitle': '外部模型目录代理节点',
|
||||
'models.externalCatalog.proxyEnabled': '当前通过所选代理节点访问 models.dev',
|
||||
'models.externalCatalog.direct': '未设置,直接访问 models.dev',
|
||||
'models.externalCatalog.saved': '外部模型目录代理节点已保存',
|
||||
'models.externalCatalog.saveFailed': '保存外部模型目录代理节点失败',
|
||||
'models.externalCatalog.loadFailed': '加载外部模型目录代理节点失败',
|
||||
'nav.routing': '调度策略',
|
||||
'nav.pool': '号池管理',
|
||||
'nav.standaloneKeys': '独立密钥',
|
||||
@@ -599,6 +605,12 @@ export const messages = {
|
||||
'nav.userManagement': 'Users',
|
||||
'nav.providers': 'Providers',
|
||||
'nav.modelManagement': 'Models',
|
||||
'models.externalCatalog.accessTitle': 'External model catalog proxy',
|
||||
'models.externalCatalog.proxyEnabled': 'Accessing models.dev through the selected proxy node',
|
||||
'models.externalCatalog.direct': 'No proxy configured; accessing models.dev directly',
|
||||
'models.externalCatalog.saved': 'External model catalog proxy saved',
|
||||
'models.externalCatalog.saveFailed': 'Failed to save external model catalog proxy',
|
||||
'models.externalCatalog.loadFailed': 'Failed to load external model catalog proxy',
|
||||
'nav.routing': 'Routing',
|
||||
'nav.pool': 'Pool',
|
||||
'nav.standaloneKeys': 'Standalone keys',
|
||||
@@ -752,6 +764,8 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'代理节点已更新': 'Proxy node updated',
|
||||
'代理节点已删除': 'Proxy node deleted',
|
||||
'代理节点已删除,系统默认代理已自动清除': 'Proxy node deleted. The system default proxy was cleared automatically.',
|
||||
'代理节点已删除,外部模型目录代理已自动清除': 'Proxy node deleted. The external model catalog proxy was cleared automatically.',
|
||||
'代理节点已删除,系统默认代理和外部模型目录代理已自动清除': 'Proxy node deleted. The system default proxy and external model catalog proxy were cleared automatically.',
|
||||
'代理节点安装命令已生成': 'Proxy node install command generated',
|
||||
'安装命令已复制到剪贴板': 'Install command copied to clipboard',
|
||||
'生成代理节点安装命令失败': 'Failed to generate proxy node install command',
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
>
|
||||
<ListChecks class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<ExternalModelsAccessControl />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -691,6 +692,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
|
||||
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
|
||||
import ExternalModelsAccessControl from '@/features/models/components/ExternalModelsAccessControl.vue'
|
||||
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
||||
import type { Model } from '@/api/endpoints'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -103,6 +103,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { clearModelsDevCache } from '@/api/models-dev'
|
||||
import {
|
||||
proxyNodesApi,
|
||||
type ProxyNode,
|
||||
@@ -117,6 +118,7 @@ import {
|
||||
import { Card } from '@/components/ui'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseBatchProxyNodeInput } from './proxy-node-batch'
|
||||
import { proxyNodeDeleteSuccessMessage } from './proxy-node-delete-feedback'
|
||||
import ProxyNodeBatchUpgradeDialog from './components/ProxyNodeBatchUpgradeDialog.vue'
|
||||
import ProxyNodeEventsDialog from './components/ProxyNodeEventsDialog.vue'
|
||||
import ProxyNodeFormDialog from './components/ProxyNodeFormDialog.vue'
|
||||
@@ -597,12 +599,14 @@ async function handleDelete(node: ProxyNode) {
|
||||
|
||||
try {
|
||||
const result = await proxyNodesApi.deleteProxyNode(node.id)
|
||||
await store.fetchNodes()
|
||||
if (result.cleared_system_proxy) {
|
||||
success(legacyT('代理节点已删除,系统默认代理已自动清除'))
|
||||
} else {
|
||||
success(legacyT('代理节点已删除'))
|
||||
if (result.cleared_external_models_proxy) {
|
||||
clearModelsDevCache()
|
||||
}
|
||||
await store.fetchNodes()
|
||||
success(legacyT(proxyNodeDeleteSuccessMessage(
|
||||
result.cleared_system_proxy,
|
||||
result.cleared_external_models_proxy,
|
||||
)))
|
||||
} catch (err: unknown) {
|
||||
toastError(parseApiError(err, legacyT('删除失败')))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { proxyNodeDeleteSuccessMessage } from '../proxy-node-delete-feedback'
|
||||
|
||||
describe('proxyNodeDeleteSuccessMessage', () => {
|
||||
it.each([
|
||||
{
|
||||
clearedSystemProxy: true,
|
||||
clearedExternalModelsProxy: true,
|
||||
expected: '代理节点已删除,系统默认代理和外部模型目录代理已自动清除',
|
||||
},
|
||||
{
|
||||
clearedSystemProxy: true,
|
||||
clearedExternalModelsProxy: false,
|
||||
expected: '代理节点已删除,系统默认代理已自动清除',
|
||||
},
|
||||
{
|
||||
clearedSystemProxy: false,
|
||||
clearedExternalModelsProxy: true,
|
||||
expected: '代理节点已删除,外部模型目录代理已自动清除',
|
||||
},
|
||||
{
|
||||
clearedSystemProxy: false,
|
||||
clearedExternalModelsProxy: false,
|
||||
expected: '代理节点已删除',
|
||||
},
|
||||
])(
|
||||
'returns the coordinated message for system=$clearedSystemProxy and external=$clearedExternalModelsProxy',
|
||||
({ clearedSystemProxy, clearedExternalModelsProxy, expected }) => {
|
||||
expect(proxyNodeDeleteSuccessMessage(
|
||||
clearedSystemProxy,
|
||||
clearedExternalModelsProxy,
|
||||
)).toBe(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
export function proxyNodeDeleteSuccessMessage(
|
||||
clearedSystemProxy: boolean,
|
||||
clearedExternalModelsProxy: boolean,
|
||||
): string {
|
||||
if (clearedSystemProxy && clearedExternalModelsProxy) {
|
||||
return '代理节点已删除,系统默认代理和外部模型目录代理已自动清除'
|
||||
}
|
||||
if (clearedSystemProxy) {
|
||||
return '代理节点已删除,系统默认代理已自动清除'
|
||||
}
|
||||
if (clearedExternalModelsProxy) {
|
||||
return '代理节点已删除,外部模型目录代理已自动清除'
|
||||
}
|
||||
return '代理节点已删除'
|
||||
}
|
||||
Reference in New Issue
Block a user