Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 (#2328)

* refactor: 重构配置文件管理,以支持更灵活的、基于 umo part 的配置文件隔离

* Refactor: 重构配置前端页面,新增数个配置项 (#2331)

* refactor: 重构配置前端页面,新增数个配置项

* feat: 完善多配置文件结构

* perf: 系统配置入口

* fix: normal config item list not display

* fix: 修复 axios 请求中的上下文引用问题
This commit is contained in:
Soulter
2025-08-13 09:18:49 +08:00
committed by GitHub
parent 6c1f540170
commit 369eab18ab
31 changed files with 2611 additions and 786 deletions
@@ -176,7 +176,7 @@ function saveEditedContent() {
<!-- List item -->
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
v-model="iterable[key]"
class="config-field"
/>
</div>
@@ -287,9 +287,9 @@ function saveEditedContent() {
></v-switch>
<!-- List item -->
<ListConfigItem
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
v-model="iterable[metadataKey]"
class="config-field"
/>
</div>
@@ -0,0 +1,396 @@
<script setup>
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue'
import ListConfigItem from './ListConfigItem.vue'
import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue'
import { useI18n } from '@/i18n/composables'
const props = defineProps({
metadata: {
type: Object,
required: true
},
iterable: {
type: Object,
required: true
},
metadataKey: {
type: String,
required: true
}
})
const { t } = useI18n()
const dialog = ref(false)
const currentEditingKey = ref('')
const currentEditingLanguage = ref('json')
const currentEditingTheme = ref('vs-light')
let currentEditingKeyIterable = null
function getValueBySelector(obj, selector) {
const keys = selector.split('.')
let current = obj
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key]
} else {
return undefined
}
}
return current
}
function setValueBySelector(obj, selector, value) {
const keys = selector.split('.')
let current = obj
// 创建嵌套对象路径
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key]
}
// 设置最终值
current[keys[keys.length - 1]] = value
}
// 创建一个计算属性来处理 JSON selector 的获取和设置
function createSelectorModel(selector) {
return computed({
get() {
return getValueBySelector(props.iterable, selector)
},
set(value) {
setValueBySelector(props.iterable, selector, value)
}
})
}
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
currentEditingLanguage.value = language || 'json'
currentEditingTheme.value = theme || 'vs-light'
currentEditingKeyIterable = value
dialog.value = true
}
function saveEditedContent() {
dialog.value = false
}
function shouldShowItem(itemMeta, itemKey) {
if (!itemMeta?.condition) {
return true
}
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
return true
}
function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items)
// 检查当前索引之后是否还有可见的配置项
for (let i = currentIndex + 1; i < itemEntries.length; i++) {
const [itemKey, itemMeta] = itemEntries[i]
if (shouldShowItem(itemMeta, itemKey)) {
return true
}
}
return false
}
</script>
<template>
<v-card style="margin-bottom: 16px; padding-bottom: 8px; background-color: rgb(var(--v-theme-background));" rounded="md" variant="outlined">
<v-card-text class="config-section" v-if="metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }}
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-card-text>
<!-- Object Type Configuration with JSON Selector Support -->
<div v-if="metadata[metadataKey]?.type === 'object'" class="object-config">
<div v-for="(itemMeta, itemKey, index) in metadata[metadataKey].items" :key="itemKey" class="config-item">
<!-- Check if itemKey is a JSON selector -->
<template v-if="shouldShowItem(itemMeta, itemKey)">
<!-- JSON Selector Property -->
<v-row v-if="!itemMeta?.invisible" class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ itemMeta?.description || itemKey }}
<span class="property-key">({{ itemKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
{{ itemMeta?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="12" sm="6" class="config-input">
<div class="w-100" v-if="!itemMeta?._special">
<!-- Select input for JSON selector -->
<v-select v-if="itemMeta?.options" v-model="createSelectorModel(itemKey).value"
:items="itemMeta?.options" :disabled="itemMeta?.readonly" density="compact" variant="outlined"
class="config-field" hide-details></v-select>
<!-- Code Editor for JSON selector -->
<div v-else-if="itemMeta?.editor_mode" class="editor-container">
<VueMonacoEditor :theme="itemMeta?.editor_theme || 'vs-light'"
:language="itemMeta?.editor_language || 'json'"
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
v-model:value="createSelectorModel(itemKey).value">
</VueMonacoEditor>
<v-btn icon size="small" variant="text" color="primary" class="editor-fullscreen-btn"
@click="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
:title="t('core.common.editor.fullscreen')">
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</div>
<!-- String input for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'string'" v-model="createSelectorModel(itemKey).value"
density="compact" variant="outlined" class="config-field" hide-details></v-text-field>
<!-- Numeric input for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'"
v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined" class="config-field"
type="number" hide-details></v-text-field>
<!-- Text area for JSON selector -->
<v-textarea v-else-if="itemMeta?.type === 'text'" v-model="createSelectorModel(itemKey).value"
variant="outlined" rows="3" class="config-field" hide-details></v-textarea>
<!-- Boolean switch for JSON selector -->
<v-switch v-else-if="itemMeta?.type === 'bool'" v-model="createSelectorModel(itemKey).value"
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
<!-- List item for JSON selector -->
<ListConfigItem
v-else-if="itemMeta?.type === 'list'"
v-model="createSelectorModel(itemKey).value"
button-text="修改"
class="config-field"
/>
<!-- Fallback for JSON selector -->
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
class="config-field" hide-details></v-text-field>
</div>
<div v-else-if="itemMeta?._special === 'select_provider'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'speech_to_text'"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'text_to_speech'"
/>
</div>
<div v-else-if="itemMeta?._special === 'provider_pool'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
button-text="选择提供商池..."
/>
</div>
<div v-else-if="itemMeta?._special === 'select_persona'">
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
/>
</div>
<div v-else-if="itemMeta?._special === 'persona_pool'">
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
button-text="选择人格池..."
/>
</div>
</v-col>
</v-row>
</template>
<v-divider class="config-divider" v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
</div>
</div>
</v-card>
<!-- Full Screen Editor Dialog -->
<v-dialog v-model="dialog" fullscreen transition="dialog-bottom-transition" scrollable>
<v-card>
<v-toolbar color="primary" dark>
<v-btn icon @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>{{ t('core.common.editor.editingTitle') }} - {{ currentEditingKey }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn variant="text" @click="saveEditedContent">{{ t('core.common.save') }}</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-text class="pa-0">
<VueMonacoEditor :theme="currentEditingTheme" :language="currentEditingLanguage"
style="height: calc(100vh - 64px);" v-model:value="currentEditingKeyIterable[currentEditingKey]">
</VueMonacoEditor>
</v-card-text>
</v-card>
</v-dialog>
</template>
<style scoped>
.config-section {
margin-bottom: 4px;
}
.config-title {
/* font-weight: 600; */
font-size: 1.3rem;
color: var(--v-theme-primaryText);
}
.config-hint {
font-size: 0.75rem;
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
.metadata-key,
.property-key {
font-size: 0.85em;
opacity: 0.7;
font-weight: normal;
display: none;
}
.important-hint {
opacity: 1;
margin-right: 4px;
}
.object-config,
.simple-config {
width: 100%;
}
.nested-object {
padding-left: 16px;
}
.nested-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 12px;
margin: 12px 0;
background-color: rgba(0, 0, 0, 0.02);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.config-row {
margin: 0;
align-items: center;
padding: 10px 8px;
border-radius: 4px;
}
.config-row:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.property-info {
padding: 0;
}
.property-name {
font-size: 0.875rem;
/* font-weight: 600; */
color: var(--v-theme-primaryText);
}
.property-hint {
font-size: 0.75rem;
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
.type-indicator {
display: flex;
justify-content: center;
}
.config-input {
padding: 4px 8px;
}
.config-field {
margin-bottom: 0;
}
.config-divider {
border-color: rgba(0, 0, 0, 0.1);
margin-left: 24px;
}
.editor-container {
position: relative;
display: flex;
width: 100%;
}
.editor-fullscreen-btn {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
.editor-fullscreen-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
@media (max-width: 600px) {
.nested-object {
padding-left: 8px;
}
.config-row {
padding: 8px 0;
}
.property-info,
.type-indicator,
.config-input {
padding: 4px;
}
}
</style>
+194 -113
View File
@@ -1,135 +1,216 @@
<template>
<div class="list-config-item">
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;">
<v-list-item v-for="(item, index) in items" :key="index">
<v-list-item-content style="display: flex; justify-content: space-between;">
<v-list-item-title v-if="editIndex !== index">
<v-chip size="small" label color="primary">{{ item }}</v-chip>
</v-list-item-title>
<div class="d-flex align-center justify-space-between">
<div>
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
暂无项目
</span>
<div v-else class="d-flex flex-wrap ga-2">
<v-chip v-for="item in displayItems" :key="item" size="x-small" label color="primary">
{{ item.length > 20 ? item.slice(0, 20) + '...' : item }}
</v-chip>
<v-chip v-if="modelValue.length > maxDisplayItems" size="x-small" label color="grey-lighten-1">
+{{ modelValue.length - maxDisplayItems }}
</v-chip>
</div>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- List Management Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ dialogTitle }}
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-list v-if="localItems.length > 0" density="compact">
<v-list-item
v-for="(item, index) in localItems"
:key="index"
rounded="md"
class="ma-1">
<v-list-item-title v-if="editIndex !== index">
{{ item }}
</v-list-item-title>
<v-text-field
v-else
v-model="editItem"
hide-details
variant="outlined"
density="compact"
@keyup.enter="saveEdit"
@keyup.esc="cancelEdit"
autofocus
></v-text-field>
<template v-slot:append>
<div v-if="editIndex !== index" class="d-flex">
<v-btn @click="startEdit(index, item)" variant="plain" icon size="small">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn @click="removeItem(index)" variant="plain" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div v-else class="d-flex">
<v-btn @click="saveEdit" variant="plain" color="success" icon size="small">
<v-icon>mdi-check</v-icon>
</v-btn>
<v-btn @click="cancelEdit" variant="plain" color="error" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-format-list-bulleted</v-icon>
<p class="text-grey mt-4">暂无项目</p>
</div>
</v-card-text>
<!-- Add new item section -->
<v-card-text class="pa-4">
<div class="d-flex align-center ga-2">
<v-text-field
v-else
v-model="editItem"
dense
hide-details
v-model="newItem"
:label="t('core.common.list.addItemPlaceholder')"
@keyup.enter="addItem"
clearable
hide-details
variant="outlined"
density="compact"
@keyup.enter="saveEdit"
@keyup.esc="cancelEdit"
autofocus
></v-text-field>
<div v-if="editIndex !== index">
<v-btn @click="startEdit(index, item)" variant="plain" class="edit-btn" icon size="small">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn @click="removeItem(index)" variant="plain" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div v-else>
<v-btn @click="saveEdit" variant="plain" color="success" icon size="small">
<v-icon>mdi-check</v-icon>
</v-btn>
<v-btn @click="cancelEdit" variant="plain" color="error" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</v-list-item-content>
</v-list-item>
</v-list>
<div style="display: flex; align-items: center;">
<v-text-field v-model="newItem" :label="t('core.common.list.addItemPlaceholder')" @keyup.enter="addItem" clearable dense hide-details
variant="outlined" density="compact"></v-text-field>
<v-btn @click="addItem" text variant="tonal">
<v-icon>mdi-plus</v-icon>
{{ t('core.common.list.addButton') }}
</v-btn>
</div>
class="flex-grow-1">
</v-text-field>
<v-btn @click="addItem" variant="tonal" color="primary">
<v-icon>mdi-plus</v-icon>
{{ t('core.common.list.addButton') }}
</v-btn>
</div>
</v-card-text>
</div>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelDialog">取消</v-btn>
<v-btn color="primary" @click="confirmDialog">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { useI18n } from '@/i18n/composables';
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n } from '@/i18n/composables'
export default {
name: 'ListConfigItem',
setup() {
const { t } = useI18n();
return { t };
const { t } = useI18n()
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
props: {
value: {
type: Array,
default: () => [],
},
label: {
type: String,
default: '',
},
label: {
type: String,
default: ''
},
data() {
return {
newItem: '',
items: this.value,
editIndex: -1,
editItem: '',
};
buttonText: {
type: String,
default: '修改'
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
dialogTitle: {
type: String,
default: '修改列表项'
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
removeItem(index) {
this.items.splice(index, 1);
},
startEdit(index, item) {
this.editIndex = index;
this.editItem = item;
},
saveEdit() {
if (this.editItem.trim() !== '') {
this.items[this.editIndex] = this.editItem.trim();
this.cancelEdit();
}
},
cancelEdit() {
this.editIndex = -1;
this.editItem = '';
},
},
};
maxDisplayItems: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const localItems = ref([])
const originalItems = ref([])
const newItem = ref('')
const editIndex = ref(-1)
const editItem = ref('')
// 计算要显示的项目
const displayItems = computed(() => {
return props.modelValue.slice(0, props.maxDisplayItems)
})
// 监听 modelValue 变化,同步到 localItems
watch(() => props.modelValue, (newValue) => {
localItems.value = [...(newValue || [])]
}, { immediate: true })
function openDialog() {
localItems.value = [...(props.modelValue || [])]
originalItems.value = [...(props.modelValue || [])]
dialog.value = true
editIndex.value = -1
editItem.value = ''
newItem.value = ''
}
function addItem() {
if (newItem.value.trim() !== '') {
localItems.value.push(newItem.value.trim())
newItem.value = ''
}
}
function removeItem(index) {
localItems.value.splice(index, 1)
}
function startEdit(index, item) {
editIndex.value = index
editItem.value = item
}
function saveEdit() {
if (editItem.value.trim() !== '') {
localItems.value[editIndex.value] = editItem.value.trim()
cancelEdit()
}
}
function cancelEdit() {
editIndex.value = -1
editItem.value = ''
}
function confirmDialog() {
emit('update:modelValue', [...localItems.value])
dialog.value = false
}
function cancelDialog() {
localItems.value = [...originalItems.value]
editIndex.value = -1
editItem.value = ''
newItem.value = ''
dialog.value = false
}
</script>
<style scoped>
.list-config-item {
border: 1px solid var(--v-theme-border);
padding: 16px;
margin-bottom: 8px;
border-radius: 10px;
background-color: var(--v-theme-background);
}
.v-list-item {
padding: 0;
transition: all 0.2s ease;
}
.v-list-item-title {
font-size: 14px;
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-btn {
margin-left: 8px;
}
.edit-btn {
margin-right: -8px;
.v-chip {
margin: 2px;
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- Persona Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择人格
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<v-list v-if="!loading && personaList.length > 0" density="compact">
<v-list-item
v-for="persona in personaList"
:key="persona.persona_id"
:value="persona.persona_id"
@click="selectPersona(persona)"
:active="selectedPersona === persona.persona_id"
rounded="md"
class="ma-1">
<v-list-item-title>{{ persona.persona_id }}</v-list-item-title>
<v-list-item-subtitle>
{{ persona.system_prompt ? persona.system_prompt.substring(0, 50) + '...' : '无描述' }}
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedPersona === persona.persona_id" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!loading && personaList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-account-off</v-icon>
<p class="text-grey mt-4">暂无可用的人格</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!selectedPersona">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
buttonText: {
type: String,
default: '选择人格...'
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const personaList = ref([])
const loading = ref(false)
const selectedPersona = ref('')
// 监听 modelValue 变化,同步到 selectedPersona
watch(() => props.modelValue, (newValue) => {
selectedPersona.value = newValue || ''
}, { immediate: true })
async function openDialog() {
selectedPersona.value = props.modelValue || ''
dialog.value = true
await loadPersonas()
}
async function loadPersonas() {
loading.value = true
try {
const response = await axios.get('/api/persona/list')
if (response.data.status === 'ok') {
personaList.value = response.data.data || []
}
} catch (error) {
console.error('加载人格列表失败:', error)
personaList.value = []
} finally {
loading.value = false
}
}
function selectPersona(persona) {
selectedPersona.value = persona.persona_id
}
function confirmSelection() {
emit('update:modelValue', selectedPersona.value)
dialog.value = false
}
function cancelSelection() {
selectedPersona.value = props.modelValue || ''
dialog.value = false
}
</script>
<style scoped>
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>
@@ -0,0 +1,150 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择提供商
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<v-list v-if="!loading && providerList.length > 0" density="compact">
<v-list-item
v-for="provider in providerList"
:key="provider.id"
:value="provider.id"
@click="selectProvider(provider)"
:active="selectedProvider === provider.id"
rounded="md"
class="ma-1">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || '未知类型' }}
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === provider.id" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!loading && providerList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">暂无可用的提供商</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!selectedProvider">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
providerType: {
type: String,
default: 'chat_completion'
},
buttonText: {
type: String,
default: '选择提供商...'
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const providerList = ref([])
const loading = ref(false)
const selectedProvider = ref('')
// 监听 modelValue 变化,同步到 selectedProvider
watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || ''
}, { immediate: true })
async function openDialog() {
selectedProvider.value = props.modelValue || ''
dialog.value = true
await loadProviders()
}
async function loadProviders() {
loading.value = true
try {
const response = await axios.get('/api/config/provider/list', {
params: {
provider_type: props.providerType
}
})
if (response.data.status === 'ok') {
providerList.value = response.data.data || []
}
} catch (error) {
console.error('加载提供商列表失败:', error)
providerList.value = []
} finally {
loading.value = false
}
}
function selectProvider(provider) {
selectedProvider.value = provider.id
}
function confirmSelection() {
emit('update:modelValue', selectedProvider.value)
dialog.value = false
}
function cancelSelection() {
selectedProvider.value = props.modelValue || ''
dialog.value = false
}
</script>
<style scoped>
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>