Files
admin-ui/src/components/model/ModelSelector.vue
2910410219 a835f54c67 fix(model,settings): 给模型查询接口添加上status=1参数
在两个模型列表查询的请求参数中新增status=1,仅查询启用状态的模型数据
2026-05-15 09:23:36 +08:00

476 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog v-model="visible" title="选择模型" width="1000px" :close-on-click-modal="false" @close="handleClose">
<div class="model-selector-header">
<div class="search-bar">
<el-input v-model="searchParams.modelName" placeholder="搜索模型名称" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<el-button type="success" @click="handleAddModel">+ 新建模型</el-button>
</div>
<div class="model-list" v-loading="loading">
<el-empty v-if="!loading && modelList.length === 0" description="暂无模型数据" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in modelList"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
<div class="model-badges">
<el-tag v-if="model.isOwner === 0" type="warning" size="small">内置模型</el-tag>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
</div>
<div class="model-card-body">
<h3 class="model-name">{{ model.modelName }}</h3>
<p class="model-url">{{ model.baseUrl }}</p>
<div class="model-status">
<el-tag :type="model.enabled === 1 ? 'success' : 'info'" size="small">
{{ model.enabled === 1 ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
</template>
<!-- 新建模型弹窗 -->
<EditModule ref="editModuleRef" :model-types="modelTypes" :is-super-admin="isSuperAdmin" @refresh="handleRefresh" />
<!-- 内置模型 API Key 输入弹窗 -->
<el-dialog v-model="apiKeyDialogVisible" title="配置内置模型" width="500px" :close-on-click-modal="false" append-to-body>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
<template #title>
<div style="line-height: 1.6">
您选择的是内置模型需要配置您自己的 API Key<br />
系统将为您创建一个模型副本
</div>
</template>
</el-alert>
<el-form :model="apiKeyForm" :rules="apiKeyRules" ref="apiKeyFormRef" label-width="100px">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="apiKeyForm.modelName" placeholder="请输入模型名称" />
</el-form-item>
<el-form-item label="API Key" prop="apiKey">
<el-input v-model="apiKeyForm.apiKey" type="password" show-password placeholder="请输入您的 API Key" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="apiKeyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreatePrivateModel" :loading="creatingModel">确定</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getModelModuleList, addModelModule, getModelTypeList, normalizeModelTypeOptions } from '/@/api/settings/modelConfig/modelModule';
import { checkIsSuperAdmin } from '/@/api/system/user/index';
import { getApiErrorMessage } from '/@/utils/request';
import EditModule from '/@/views/settings/modelConfig/modelModule/component/editModule.vue';
interface ModelItem {
id: string;
tenantId?: number;
modelName: string;
modelType: number;
baseUrl: string;
route: string;
httpMethod: string;
enabled: number;
apiKey?: string;
isPrivate?: number;
isChatModel?: number;
headMsg?: string;
form?: any;
requestMapping?: any;
responseMapping?: any;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds?: number;
expectedSeconds?: number;
retryTimes?: number;
retryQueueMaxSeconds?: number;
autoCleanSeconds?: number;
remark?: string;
[key: string]: any;
}
interface Props {
modelValue: boolean;
defaultModel?: ModelItem | null;
modelType?: number;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', model: ModelItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultModel: null,
modelType: 1,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ modelName: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const modelList = ref<ModelItem[]>([]);
const loading = ref(false);
const selectedModel = ref<ModelItem | null>(null);
const editModuleRef = ref();
const isSuperAdmin = ref(false); // 鏄惁涓虹鐞嗗憳
const modelTypes = ref<Array<{ id: number | string; label: string }>>([]); // 妯″瀷绫诲瀷鍒楄〃
// 内置模型 API Key 配置
const apiKeyDialogVisible = ref(false);
const apiKeyFormRef = ref<FormInstance>();
const apiKeyForm = reactive({
modelName: '',
apiKey: '',
});
const apiKeyRules: FormRules = {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
};
const creatingModel = ref(false);
const builtInModelToClone = ref<ModelItem | null>(null);
// 检查是否为管理员
const checkAdminStatus = async () => {
try {
const res: any = await checkIsSuperAdmin();
isSuperAdmin.value = res.data?.isSuperAdmin || false;
} catch {
isSuperAdmin.value = false;
}
};
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = props.defaultModel || null;
fetchModelList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const getModelTypeName = (type: number) => {
const typeMap: Record<number, string> = {
1: '推理模型',
2: '图片模型',
3: '音频模型',
};
return typeMap[type] || '未知类型';
};
const fetchModelList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
modelName: searchParams.modelName || undefined,
modelType: props.modelType, // 使用传入的 modelType
status: 1,
};
const res: any = await getModelModuleList(params);
modelList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch {
// 接口错误由 request 全局提示后端 message
modelList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchModelList();
};
const handlePageChange = () => {
fetchModelList();
};
const handleSelectModel = (model: ModelItem) => {
// 如果是管理员,直接选中任何模型,不需要配置 API Key
if (isSuperAdmin.value) {
selectedModel.value = model;
return;
}
// 非管理员判断是否是内置模型isOwner === 0 表示管理员创建的内置模型)
if (model.isOwner === 0) {
// 内置模型,需要用户配置 API Key 创建副本
builtInModelToClone.value = model;
apiKeyForm.modelName = model.modelName;
apiKeyForm.apiKey = '';
apiKeyDialogVisible.value = true;
} else {
// 用户模型,直接选中
selectedModel.value = model;
}
};
const handleCreatePrivateModel = async () => {
if (!apiKeyFormRef.value || !builtInModelToClone.value) return;
try {
await apiKeyFormRef.value.validate();
creatingModel.value = true;
// 基于内置模型创建新模型(继承原模型的所有配置,只替换 apiKey
const builtInModel = builtInModelToClone.value;
const createParams = {
modelName: apiKeyForm.modelName,
modelType: builtInModel.modelType,
baseUrl: builtInModel.baseUrl,
httpMethod: builtInModel.httpMethod || 'POST',
headMsg: builtInModel.headMsg || '',
isPrivate: builtInModel.isPrivate ?? 1, // 继承原模型的公有/私有属性
enabled: builtInModel.enabled ?? 1,
isChatModel: builtInModel.isChatModel || 0,
apiKey: apiKeyForm.apiKey, // 使用用户输入的新 API Key
form: builtInModel.form || {},
requestMapping: builtInModel.requestMapping || {},
responseMapping: builtInModel.responseMapping || {},
responseBody: builtInModel.responseBody || {}, // 杩斿洖涓讳綋瀛楁
maxConcurrency: builtInModel.maxConcurrency || 10,
queueLimit: builtInModel.queueLimit || 100,
timeoutSeconds: builtInModel.timeoutSeconds || 30,
expectedSeconds: builtInModel.expectedSeconds || 15,
retryTimes: builtInModel.retryTimes || 3,
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
remark: builtInModel.remark || '',
tokenMapping: builtInModel.tokenMapping || '', // Token鏄犲皠瀛楁
};
const res: any = await addModelModule(createParams);
ElMessage.success('模型创建成功');
// 关闭对话框
apiKeyDialogVisible.value = false;
// 刷新列表
await fetchModelList();
// 选中新创建的模型
const newModelId = res.data?.id || res.data;
if (newModelId) {
const newModel = modelList.value.find((m) => m.id === String(newModelId));
if (newModel) {
selectedModel.value = newModel;
}
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(getApiErrorMessage(error, '创建模型失败'));
}
} finally {
creatingModel.value = false;
}
};
const handleAddModel = () => {
editModuleRef.value?.openDialog('add');
};
const handleRefresh = () => {
fetchModelList();
};
const handleConfirm = () => {
if (selectedModel.value) {
emit('confirm', selectedModel.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
apiKeyDialogVisible.value = false;
builtInModelToClone.value = null;
};
// 鍔犺浇妯″瀷绫诲瀷鍒楄〃
const loadModelTypes = async () => {
try {
const res: any = await getModelTypeList();
if (res.code === 0) {
modelTypes.value = normalizeModelTypeOptions(res);
}
} catch {
// 鎺ュ彛閿欒鐢?request 鍏ㄥ眬鎻愮ず鍚庣 message
}
};
onMounted(() => {
checkAdminStatus();
loadModelTypes();
});
</script>
<style scoped lang="scss">
.model-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
flex: 1;
}
.model-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.model-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.model-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.model-card.builtin-model {
border-color: #fbbf24;
background: #fffbeb;
}
.model-card.builtin-model:hover {
border-color: #f59e0b;
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-type {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.model-badges {
display: flex;
align-items: center;
gap: 8px;
}
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-url {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-status {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>