模型配置自定义表单

This commit is contained in:
2026-06-02 11:35:00 +08:00
parent b60583008b
commit c7fba23e3b
2 changed files with 339 additions and 39 deletions

View File

@@ -3,5 +3,5 @@ ENV = 'development'
# 统一后端服务地址前缀网关服务名admin-go
# 开发环境走本地代理,避免 CORS
VITE_API_URL = 'http://116.204.74.41:8000'
# VITE_API_URL = 'http://192.168.3.30:8000'
# VITE_API_URL = 'http://116.204.74.41:8000'
VITE_API_URL = 'http://192.168.3.30:8000'

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="system-edit-module-container">
<el-dialog :title="state.dialog.title" v-model="state.dialog.isShowDialog" width="900px">
<el-form
@@ -213,13 +213,126 @@
</template>
</el-dialog>
<!-- 自定义字段配置弹窗 -->
<el-dialog v-model="showFormDialog" title="配置表单字段" width="600px" :close-on-click-modal="false">
<el-dialog v-model="showFormDialog" title="配置表单字段" width="900px" :close-on-click-modal="false">
<div class="form-config-container">
<div v-for="(field, index) in state.formFields" :key="index" class="form-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeFormField(index)">删除</el-button>
<!-- 删除按钮 - 右上角 -->
<el-button type="danger" link size="small" @click="removeFormField(index)" class="delete-btn">删除</el-button>
<!-- 第一行 -->
<div class="field-row">
<div class="field-col field-col-sm">
<label class="field-label required">字段描述</label>
<el-input v-model="field.label" placeholder="字段显示名称" clearable style="width: 100%"></el-input>
</div>
<div class="field-col field-col-sm">
<label class="field-label required">字段名称</label>
<el-input v-model="field.key" placeholder="字段Key如 user_name" clearable style="width: 100%"></el-input>
</div>
<div class="field-col field-col-sm">
<label class="field-label required">字段类型</label>
<el-select v-model="field.type" placeholder="选择字段类型" clearable style="width: 100%" @change="onFieldTypeChange(index)">
<el-option label="字符串" value="string"></el-option>
<el-option label="数字" value="number"></el-option>
<el-option label="下拉菜单" value="select"></el-option>
<el-option label="单选按钮" value="radio"></el-option>
<el-option label="附件上传" value="file"></el-option>
</el-select>
</div>
<div class="field-col field-col-sm">
<label class="field-label">默认值</label>
<el-input v-model="field.defaultValue" placeholder="默认值" clearable style="width: 100%"></el-input>
</div>
<div class="field-col field-col-sm">
<label class="field-label">是否必填</label>
<div class="switch-wrapper">
<el-switch v-model="field.required"></el-switch>
</div>
</div>
</div>
<!-- 第二行类型特定配置 -->
<div class="field-row">
<!-- 字符串额外配置 -->
<template v-if="field.type === 'string'">
<div class="field-col field-col-sm">
<label class="field-label">最大长度</label>
<el-input-number v-model="field.maxLength" :min="0" :max="10000" placeholder="最大长度" style="width: 100%"></el-input-number>
</div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
<!-- 数字额外配置 -->
<template v-if="field.type === 'number'">
<div class="field-col field-col-sm">
<label class="field-label">数字类型</label>
<el-select v-model="field.numberType" placeholder="选择数字类型" clearable style="width: 100%">
<el-option label="任意数字" value="any"></el-option>
<el-option label="整数" value="integer"></el-option>
<el-option label="浮点数(小数)" value="float"></el-option>
<el-option label="正整数" value="positive-int"></el-option>
<el-option label="正浮点数" value="positive-float"></el-option>
<el-option label="负整数" value="negative-int"></el-option>
<el-option label="负浮点数" value="negative-float"></el-option>
</el-select>
</div>
<div class="field-col field-col-sm">
<label class="field-label">最小值</label>
<el-input-number v-model="field.min" placeholder="最小值" style="width: 100%"></el-input-number>
</div>
<div class="field-col field-col-sm">
<label class="field-label">最大值</label>
<el-input-number v-model="field.max" placeholder="最大值" style="width: 100%"></el-input-number>
</div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
<!-- 附件上传额外配置 -->
<template v-if="field.type === 'file'">
<div class="field-col field-col-sm">
<label class="field-label">最大文件(MB)</label>
<el-input-number v-model="field.maxSize" :min="1" :max="1000" style="width: 100%"></el-input-number>
</div>
<div class="field-col field-col-sm">
<label class="field-label">最大上传数量</label>
<el-input-number v-model="field.maxCount" :min="1" :max="100" :default-value="1" style="width: 100%"></el-input-number>
</div>
<div class="field-col field-col-md">
<label class="field-label">允许格式</label>
<el-input v-model="field.allowedTypes" placeholder=".jpg,.png,.pdf" clearable style="width: 100%"></el-input>
</div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
<!-- 下拉框和单选不需要第二行额外配置 -->
<template v-if="field.type === 'select' || field.type === 'radio'">
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
<div class="field-col"></div>
</template>
</div>
<!-- 下拉菜单/单选框 选项配置 -->
<div v-if="field.type === 'select' || field.type === 'radio'" class="options-row">
<div class="options-label">选项配置</div>
<div v-for="(opt, optIndex) in field.options" :key="optIndex" class="option-item">
<div class="option-col">
<el-input v-model="opt.label" placeholder="显示文本" style="width: 100%" clearable></el-input>
</div>
<span class="separator">:</span>
<div class="option-col">
<el-input v-model="opt.value" placeholder="值" style="width: 100%" clearable></el-input>
</div>
<el-button type="danger" link size="small" @click="removeFieldOption(field, optIndex)">删除</el-button>
</div>
<el-button type="primary" link size="small" @click="addFieldOption(field)">+ 添加选项</el-button>
</div>
</div>
<el-button type="primary" link @click="addFormField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
@@ -426,6 +539,33 @@ import {
export type ModelTypeOption = { id: number | string; label: string };
// 定义自定义字段选项类型
export interface FormFieldOption {
label: string;
value: string;
}
// 定义自定义字段类型
export interface FormField {
label: string; // 字段描述
key: string; // 字段名称
type: 'string' | 'number' | 'select' | 'radio' | 'file'; // 字段类型
defaultValue: string; // 默认值
required: boolean; // 是否必填
// 字符串配置
maxLength?: number; // 最大长度
// 数字配置
min?: number; // 最小值
max?: number; // 最大值
numberType?: 'any' | 'integer' | 'float' | 'positive-int' | 'positive-float' | 'negative-int' | 'negative-float'; // 数字子类型
// 文件上传配置
maxSize?: number; // 最大文件大小(MB)
maxCount?: number; // 最大上传数量
allowedTypes?: string; // 允许的文件格式,逗号分隔
// 下拉框/单选框配置
options?: FormFieldOption[]; // 选项列表
}
const props = withDefaults(
defineProps<{
modelTypes?: ModelTypeOption[];
@@ -492,6 +632,7 @@ const queryResponseTypeOptions = [
{ label: '等候回调', value: 'callback' },
{ label: '主动拉取', value: 'pull' },
];
const state = reactive({
ruleForm: {
id: '',
@@ -680,7 +821,7 @@ const state = reactive({
},
showAdvanced: false,
headers: [] as Array<{ key: string; value: string }>,
formFields: [] as Array<{ key: string; value: string }>,
formFields: [] as Array<FormField>,
requestMappingFields: [] as Array<{ key: string; value: string }>,
responseMappingFields: [] as Array<{ key: string; value: string; isMainBody?: boolean; isTokenField?: boolean }>,
extendMappingFields: [] as Array<{ key: string; value: string }>,
@@ -688,6 +829,25 @@ const state = reactive({
mainBodyIndex: -1, // 记录哪一行被设置为返回主体
});
// 创建空字段
const createEmptyFormField = (): FormField => {
return {
label: '',
key: '',
type: 'string',
defaultValue: '',
required: false,
maxLength: undefined,
min: undefined,
max: undefined,
numberType: 'any',
maxSize: 10,
maxCount: 1,
allowedTypes: '',
options: [{ label: '', value: '' }],
};
};
// 将数组转换为对象
const fieldsToObject = (fields: Array<{ key: string; value: string }>) => {
const obj: Record<string, string> = {};
@@ -703,7 +863,7 @@ const parseHeaders = (headMsg: string) => parseKeyValueString(headMsg);
// 统一的字段解析函数支持数组、对象、JSON字符串
const parseFieldsUnified = (raw: unknown): Array<{ key: string; value: string }> => {
if (!raw) return [];
// 如果是字符串尝试解析为JSON
if (typeof raw === 'string') {
try {
@@ -713,7 +873,7 @@ const parseFieldsUnified = (raw: unknown): Array<{ key: string; value: string }>
return [];
}
}
// 如果是数组格式 [{ key, value }]
if (Array.isArray(raw)) {
return (raw as ModelFormEntry[])
@@ -723,13 +883,13 @@ const parseFieldsUnified = (raw: unknown): Array<{ key: string; value: string }>
value: String(item.value ?? '').trim(),
}));
}
// 如果是对象格式 { key: value } 或者 { key: { value: value } }
if (typeof raw === 'object') {
const fields: Array<{ key: string; value: string }> = [];
Object.keys(raw as Record<string, unknown>).forEach((key) => {
let v = (raw as Record<string, unknown>)[key];
// 处理 { key: { value: "value" } } 格式(后端可能返回这种结构)
// 处理 { key: { value: value } } 格式(后端可能返回这种结构)
if (v && typeof v === 'object' && !Array.isArray(v) && 'value' in v) {
v = (v as { value: unknown }).value;
}
@@ -738,7 +898,7 @@ const parseFieldsUnified = (raw: unknown): Array<{ key: string; value: string }>
});
return fields;
}
return [];
};
// 解析 requestMapping 对象为数组
@@ -882,7 +1042,7 @@ const confirmHeaders = () => {
};
// 添加表单字段
const addFormField = () => {
state.formFields.push({ key: '', value: '' });
state.formFields.push(createEmptyFormField());
};
// 删除表单字段
@@ -890,6 +1050,29 @@ const removeFormField = (index: number) => {
state.formFields.splice(index, 1);
};
// 字段类型变化时初始化默认配置
const onFieldTypeChange = (index: number) => {
const field = state.formFields[index];
if ((field.type === 'select' || field.type === 'radio') && !field.options) {
field.options = [{ label: '', value: '' }];
}
};
// 添加选项
const addFieldOption = (field: FormField) => {
if (!field.options) {
field.options = [];
}
field.options.push({ label: '', value: '' });
};
// 删除选项
const removeFieldOption = (field: FormField, optIndex: number) => {
if (field.options) {
field.options.splice(optIndex, 1);
}
};
// 确认表单字段配置
const confirmFormFields = () => {
showFormDialog.value = false;
@@ -959,12 +1142,36 @@ const setPullMainBody = (index: number) => {
const ensureKeyValueRows = (rows: Array<{ key: string; value: string }>) => (rows.length ? rows : [{ key: '', value: '' }]);
const ensureResponseMappingRows = (rows: Array<{ key: string; value: string; isMainBody?: boolean; isTokenField?: boolean }>) => {
if (!rows.length) return [{ key: '', value: '', isMainBody: false, isTokenField: false }];
return rows.map((row) => ({ ...row, isMainBody: row.isMainBody || false, isTokenField: row.isTokenField || false }));
// 解析旧格式的form数据兼容到新格式
const parseFormFieldsUnified = (raw: unknown): Array<FormField> => {
if (!raw) return [createEmptyFormField()];
// 如果已经是新格式数组(每个元素有 type 字段),直接返回
if (Array.isArray(raw)) {
if (raw.length === 0) return [createEmptyFormField()];
// 确保新增字段有默认值
return (raw as any[]).map(item => {
const field = createEmptyFormField();
return { ...field, ...item };
}) as FormField[];
}
// 旧格式对象 { key: value } -> 转换为新格式数组
if (typeof raw === 'object') {
const fields: FormField[] = [];
Object.entries(raw as Record<string, unknown>).forEach(([key, value]) => {
const field = createEmptyFormField();
field.key = key;
field.label = key;
field.defaultValue = String(value ?? '').trim();
fields.push(field);
});
return fields.length ? fields : [createEmptyFormField()];
}
return [createEmptyFormField()];
};
/** 从 getModel 返回的 data 中取出单条模型对象 */
const unwrapModelDetailPayload = (data: unknown): Record<string, unknown> | null => {
if (data == null) return null;
if (Array.isArray(data)) return null;
@@ -1018,9 +1225,9 @@ const fillFormFromDetailRow = (row: Record<string, unknown>) => {
tokenConfig: '{}',
};
state.headers = ensureKeyValueRows(parseHeaders(String(row.headMsg || '')));
state.formFields = ensureKeyValueRows(parseFieldsUnified(row.form));
state.formFields = parseFormFieldsUnified(row.form);
state.requestMappingFields = ensureKeyValueRows(parseRequestMappingFields(row.requestMapping));
state.responseMappingFields = ensureResponseMappingRows(parseResponseMappingFields(row.responseMapping));
state.responseMappingFields = ensureKeyValueRows(parseResponseMappingFields(row.responseMapping));
state.extendMappingFields = ensureKeyValueRows(parseFieldsUnified(row.extendMapping));
state.tokenConfigFields = ensureKeyValueRows(parseFieldsUnified(row.tokenConfig));
@@ -1135,7 +1342,7 @@ const openDialog = async (type: string, row?: Record<string, unknown>) => {
tokenConfig: '{}',
};
state.headers = [{ key: '', value: '' }];
state.formFields = [{ key: '', value: '' }];
state.formFields = [createEmptyFormField()];
state.requestMappingFields = [{ key: '', value: '' }];
state.responseMappingFields = [{ key: '', value: '', isMainBody: false, isTokenField: false }];
state.mainBodyIndex = -1;
@@ -1174,33 +1381,54 @@ const onSubmit = () => {
if (state.ruleForm.queryResponseType === 'pull') {
await editModuleFormRef.value?.validateField?.('queryPullConfig');
}
// 验证响应映射(如果有配置)
const validResponseFields = state.responseMappingFields.filter((x) => String(x.key || '').trim() !== '');
if (validResponseFields.length > 0) {
await editModuleFormRef.value?.validateField?.('responseMapping');
}
// 验证请求映射(如果有配置)
const validRequestFields = state.requestMappingFields.filter((x) => String(x.key || '').trim() !== '');
if (validRequestFields.length > 0) {
await editModuleFormRef.value?.validateField?.('requestMapping');
}
state.ruleForm.headMsg = stringifyHeaders();
// 过滤掉空键名的字段
const requestMapping = fieldsToObject(state.requestMappingFields.filter((f) => String(f.key || '').trim() !== ''));
const responseMapping = fieldsToObject(state.responseMappingFields.filter((f) => String(f.key || '').trim() !== ''));
// 获取被设置为返回主体的字段 {key: value}
const responseBodyField = state.responseMappingFields.find((f) => f.isMainBody && String(f.key || '').trim() !== '');
const responseBody = responseBodyField ? { [responseBodyField.key.trim()]: responseBodyField.value } : {};
// 获取计费字段(可选)
const responseTokenField =
state.responseMappingFields.find((f) => f.isTokenField)?.key?.trim() || String(state.ruleForm.responseTokenField || '').trim();
// 过滤掉空key的自定义字段
const processedFormFields = state.formFields
.filter((f) => String(f.key || '').trim() !== '')
.map((f) => ({
label: f.label?.trim() || f.key,
key: String(f.key).trim(),
type: f.type,
defaultValue: f.defaultValue || '',
required: Boolean(f.required),
maxLength: f.type === 'string' && f.maxLength ? f.maxLength : undefined,
min: f.type === 'number' && f.min !== undefined ? f.min : undefined,
max: f.type === 'number' && f.max !== undefined ? f.max : undefined,
numberType: f.type === 'number' ? f.numberType || 'any' : undefined,
maxSize: f.type === 'file' && f.maxSize ? f.maxSize : undefined,
maxCount: f.type === 'file' && f.maxCount ? f.maxCount : undefined,
allowedTypes: f.type === 'file' && f.allowedTypes ? f.allowedTypes.trim() : undefined,
options: (f.type === 'select' || f.type === 'radio') && f.options
? f.options.filter(opt => String(opt.label || '').trim() !== '' || String(opt.value || '').trim() !== '')
: undefined,
}));
const submitData: CreateModelParams = {
modelName: state.ruleForm.modelName,
modelType: state.ruleForm.modelType as number | string,
@@ -1213,9 +1441,7 @@ const onSubmit = () => {
isChatModel: state.ruleForm.isChatModel,
// 确保 API 密钥只在 isPrivate=1 时提交
apiKey: state.ruleForm.isPrivate === 1 ? String(state.ruleForm.apiKey ?? '').trim() : '',
form: state.formFields
.filter((f) => String(f.key || '').trim() !== '')
.map((f) => ({ key: String(f.key).trim(), value: String(f.value ?? '') })),
form: processedFormFields,
requestMapping,
responseMapping,
responseBody,
@@ -1276,15 +1502,89 @@ onMounted(() => {
}
.form-config-container {
max-height: 400px;
max-height: 550px;
overflow-y: auto;
}
padding: 5px 0;
.form-field-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.form-field-item {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 16px 16px 12px 16px;
padding-right: 80px;
margin-bottom: 16px;
background-color: #fafafa;
position: relative;
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
}
}
.field-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
.field-col {
flex: 1;
&.field-col-sm {
flex: 0 0 140px;
}
&.field-col-md {
flex: 0 0 200px;
}
&.field-col-xs {
flex: 0 0 80px;
}
}
.field-label {
display: block;
font-size: 13px;
color: #606266;
margin-bottom: 6px;
line-height: 1.2;
&.required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
.switch-wrapper {
padding-top: 4px;
}
}
.options-row {
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed #e4e7ed;
.options-label {
font-size: 14px;
color: #606266;
margin-bottom: 10px;
font-weight: 500;
}
.option-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.option-col {
flex: 1;
max-width: 180px;
}
}
}
}
.ml5 {