- 新增 ModelWhitelistSelector.vue 支持模型白名单多选 - 新增 ModelIcon.vue 显示品牌图标(基于 @lobehub/icons) - 新增 useModelWhitelist.ts 硬编码各平台模型列表 - 更新账号编辑表单支持模型白名单配置 - 支持 Claude/OpenAI/Gemini/智谱/百度/讯飞等主流平台 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
202 lines
7.0 KiB
Vue
202 lines
7.0 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Multi-select Dropdown -->
|
|
<div class="relative mb-3">
|
|
<div
|
|
@click="toggleDropdown"
|
|
class="cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-dark-500 dark:bg-dark-700"
|
|
>
|
|
<div class="grid grid-cols-2 gap-1.5">
|
|
<span
|
|
v-for="model in modelValue"
|
|
:key="model"
|
|
class="inline-flex items-center justify-between gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-300"
|
|
>
|
|
<span class="flex items-center gap-1 truncate">
|
|
<ModelIcon :model="model" size="14px" />
|
|
<span class="truncate">{{ model }}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
@click.stop="removeModel(model)"
|
|
class="shrink-0 rounded-full hover:bg-gray-200 dark:hover:bg-dark-500"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
|
|
<span class="text-xs text-gray-400">{{ t('admin.accounts.modelCount', { count: modelValue.length }) }}</span>
|
|
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<!-- Dropdown List -->
|
|
<div
|
|
v-if="showDropdown"
|
|
class="absolute left-0 right-0 top-full z-50 mt-1 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-700"
|
|
>
|
|
<div class="sticky top-0 border-b border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-700">
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
class="input w-full text-sm"
|
|
:placeholder="t('admin.accounts.searchModels')"
|
|
@click.stop
|
|
/>
|
|
</div>
|
|
<div class="max-h-52 overflow-auto">
|
|
<button
|
|
v-for="model in filteredModels"
|
|
:key="model.value"
|
|
type="button"
|
|
@click="toggleModel(model.value)"
|
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
|
|
>
|
|
<span
|
|
:class="[
|
|
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
|
modelValue.includes(model.value)
|
|
? 'border-primary-500 bg-primary-500 text-white'
|
|
: 'border-gray-300 dark:border-dark-500'
|
|
]"
|
|
>
|
|
<svg v-if="modelValue.includes(model.value)" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</span>
|
|
<ModelIcon :model="model.value" size="18px" />
|
|
<span class="truncate text-gray-900 dark:text-white">{{ model.value }}</span>
|
|
</button>
|
|
<div v-if="filteredModels.length === 0" class="px-3 py-4 text-center text-sm text-gray-500">
|
|
{{ t('admin.accounts.noMatchingModels') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="mb-4 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
@click="fillRelated"
|
|
class="rounded-lg border border-blue-200 px-3 py-1.5 text-sm text-blue-600 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
|
>
|
|
{{ t('admin.accounts.fillRelatedModels') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="clearAll"
|
|
class="rounded-lg border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/30"
|
|
>
|
|
{{ t('admin.accounts.clearAllModels') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Custom Model Input -->
|
|
<div class="mb-3">
|
|
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.customModelName') }}</label>
|
|
<div class="flex gap-2">
|
|
<input
|
|
v-model="customModel"
|
|
type="text"
|
|
class="input flex-1"
|
|
:placeholder="t('admin.accounts.enterCustomModelName')"
|
|
@keydown.enter.prevent="handleEnter"
|
|
@compositionstart="isComposing = true"
|
|
@compositionend="isComposing = false"
|
|
/>
|
|
<button
|
|
type="button"
|
|
@click="addCustom"
|
|
class="rounded-lg bg-primary-50 px-4 py-2 text-sm font-medium text-primary-600 hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
|
|
>
|
|
{{ t('admin.accounts.addModel') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '@/stores/app'
|
|
import ModelIcon from '@/components/common/ModelIcon.vue'
|
|
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
modelValue: string[]
|
|
platform: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string[]]
|
|
}>()
|
|
|
|
const appStore = useAppStore()
|
|
|
|
const showDropdown = ref(false)
|
|
const searchQuery = ref('')
|
|
const customModel = ref('')
|
|
const isComposing = ref(false)
|
|
|
|
const filteredModels = computed(() => {
|
|
const query = searchQuery.value.toLowerCase().trim()
|
|
if (!query) return allModels
|
|
return allModels.filter(
|
|
m => m.value.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)
|
|
)
|
|
})
|
|
|
|
const toggleDropdown = () => {
|
|
showDropdown.value = !showDropdown.value
|
|
if (!showDropdown.value) searchQuery.value = ''
|
|
}
|
|
|
|
const removeModel = (model: string) => {
|
|
emit('update:modelValue', props.modelValue.filter(m => m !== model))
|
|
}
|
|
|
|
const toggleModel = (model: string) => {
|
|
if (props.modelValue.includes(model)) {
|
|
removeModel(model)
|
|
} else {
|
|
emit('update:modelValue', [...props.modelValue, model])
|
|
}
|
|
}
|
|
|
|
const addCustom = () => {
|
|
const model = customModel.value.trim()
|
|
if (!model) return
|
|
if (props.modelValue.includes(model)) {
|
|
appStore.showInfo(t('admin.accounts.modelExists'))
|
|
return
|
|
}
|
|
emit('update:modelValue', [...props.modelValue, model])
|
|
customModel.value = ''
|
|
}
|
|
|
|
const handleEnter = () => {
|
|
if (!isComposing.value) addCustom()
|
|
}
|
|
|
|
const fillRelated = () => {
|
|
const models = getModelsByPlatform(props.platform)
|
|
const newModels = [...props.modelValue]
|
|
for (const model of models) {
|
|
if (!newModels.includes(model)) newModels.push(model)
|
|
}
|
|
emit('update:modelValue', newModels)
|
|
}
|
|
|
|
const clearAll = () => {
|
|
emit('update:modelValue', [])
|
|
}
|
|
</script>
|