From 016fc821dec0220be1574d4363a240b2762e936f Mon Sep 17 00:00:00 2001 From: 2910410219 <2910410219@qq.com> Date: Thu, 21 May 2026 23:56:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=A1=A8=E5=8D=95=E4=B8=8E=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/settings/creation/index.vue | 1297 ++++++++++++++++++++++--- 1 file changed, 1150 insertions(+), 147 deletions(-) diff --git a/src/views/settings/creation/index.vue b/src/views/settings/creation/index.vue index 67d19d9..e6a44f0 100644 --- a/src/views/settings/creation/index.vue +++ b/src/views/settings/creation/index.vue @@ -68,12 +68,12 @@ - + @@ -91,7 +91,7 @@ v-model="dynamicFormValues[fieldItem.field]" type="textarea" :rows="3" - :placeholder="fieldItem.required ? '必填' : '选填'" + :placeholder="fieldItem.field === 'callbackUrl' ? '' : fieldItem.required ? '必填' : '选填'" /> @@ -150,8 +150,29 @@ 添加键值对 + + + + + 配置请求体 + + + 已配置 {{ getHttpBodyFieldCount(fieldItem.field) }} 个字段 + + - + + + + + + 配置主动拉取参数 + + @@ -206,7 +227,7 @@ - + 自定义字段 @@ -217,40 +238,17 @@ - - + 必填 删除 - - - - - 选择文件 - - - - - - {{ uploadedFile.name }} - - 删除 - - + + + + + + @@ -301,33 +299,36 @@ > - + + + + + 选择文件 + + + + {{ getCreationFileRuleText(field) }} + 已上传 {{ getCreationFileCountText(node, field) }} + + + + {{ uploadedFile.name }} + + 删除 + + + + @@ -620,6 +652,110 @@ + + + + + + + + + + + + + + + + + 添加键值对 + + + 配置请求体 + + 已配置 {{ getHttpBodyFieldCount(field.field) }} 个字段 + + + + + + + + 取消 + 确定 + + + + + + + + + + + handleHttpBodyShowInFormChange(fieldValue, val)" + /> + + + + + + + + + + + + + + + + + + - + + + + + + + + + - + + + + + + + + + + + + 添加字段 + + + 取消 + 确定 + + @@ -737,6 +873,10 @@ const previewUrl = ref(''); const showModelSelector = ref(false); const selectedModelData = ref(null); // 对话模型选择器相关状态 +// HTTP请求体配置相关状态 +const showHttpBodyDialog = ref(false); +const currentHttpBodyField = ref(''); +const httpBodyData = reactive>({}); const showChatModelSelector = ref(false); const selectedChatModel = ref(null); const chatModelList = ref([]); @@ -804,6 +944,10 @@ const logicFlowInstance = ref(null); const nodeSpawnIndex = ref(0); const formState = reactive({ text: '', nodeCode: '', field: '' }); const dynamicFormValues = reactive>({ modelApiKey: '' }); +const showHttpExpandDialog = ref(false); +const currentHttpExpandFields = ref([]); +const httpExpandFormValues = reactive>({}); +const httpExpandKeyValuePairs = reactive>>({}); const nodeSchemaMap = computed(() => { const map: Record = {}; nodeLibraryGroups.value.forEach((group) => { @@ -859,6 +1003,14 @@ const currentModelForm = computed(() => { const allFormFields = computed(() => { return [...currentNodeForm.value, ...currentModelForm.value]; }); +// 过滤后应该显示的字段列表 +const visibleFormFields = computed(() => { + return allFormFields.value.filter((fieldItem) => { + if (fieldItem.field !== 'callbackUrl') return true; + const returnType = dynamicFormValues.responseType; + return returnType === 'callback' || returnType === '等候回调' || returnType === '等待回调'; + }); +}); // 获取可用的上级节点参数 const availableParentParams = computed(() => { if (!selectedElement.value) return []; @@ -905,8 +1057,33 @@ const availableParentParams = computed(() => { // 如果是判断节点,跳过不添加其字段 if (isJudge) return; - // 只添加自定义字段(formConfig) + // 只添加可引用字段(HTTP节点仅允许结果返回结构;其他节点维持原逻辑) if (parentProps.formConfig && Array.isArray(parentProps.formConfig)) { + if (nodeCode === 'http') { + const responseField = parentProps.formConfig.find((f: any) => f.field === 'response' || String(f.label || '').includes('结果返回结构')); + if (!responseField) return; + + let responseValue: any = responseField.value; + if (typeof responseValue === 'string') { + try { + responseValue = JSON.parse(responseValue); + } catch { + responseValue = {}; + } + } + + if (responseValue && typeof responseValue === 'object' && !Array.isArray(responseValue)) { + Object.keys(responseValue).forEach((key) => { + if (!key || key.startsWith('_temp_')) return; + params.push({ + label: `${parentNodeName}.${key}`, + value: `\${${parentId}.${key}}`, + }); + }); + } + return; + } + parentProps.formConfig.forEach((field: any) => { if (field.label) { params.push({ @@ -940,7 +1117,83 @@ const workflowDsl = computed(() => ({ y: n.y || 0, }, inputSource: n.properties?.inputSource || null, - formConfig: n.properties?.formConfig || null, + formConfig: (() => { + const cfg = n.properties?.formConfig; + if (!Array.isArray(cfg)) return null; + + return cfg.map((field: any) => { + const next = { ...field }; + if (next.type !== 'httpBody') return next; + + let bodyVal = next.value; + if (typeof bodyVal === 'string') { + try { + bodyVal = JSON.parse(bodyVal); + } catch { + bodyVal = {}; + } + } + if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) { + next.value = {}; + return next; + } + + const normalized: Record = {}; + Object.entries(bodyVal).forEach(([outerKey, rawItem]: [string, any]) => { + const item = rawItem && typeof rawItem === 'object' ? rawItem : { value: rawItem }; + const realKey = String(item.key || outerKey || '').trim(); + if (!realKey) return; + + const showInForm = item.showInForm === true; + const rawValue = item.value; + let normalizedValue: any = rawValue; + if (typeof rawValue === 'string') { + const matched = rawValue.match(/^\$\{([^\.}]+)\.([^}]+)\}$/); + if (matched) { + normalizedValue = { + field: matched[2], + nodeId: matched[1], + quoteOutput: false, + }; + } + } + + const normalizedItem: any = { + key: realKey, + showInForm, + value: normalizedValue, + }; + if (showInForm) { + const fieldType = item.fieldType || 'string'; + normalizedItem.fieldType = fieldType; + const fc = item.fieldConstraint || {}; + if (fieldType === 'string') { + normalizedItem.fieldConstraint = { + ...(fc.minLength !== undefined && fc.minLength !== null ? { minLength: fc.minLength } : {}), + ...(fc.maxLength !== undefined && fc.maxLength !== null ? { maxLength: fc.maxLength } : {}), + }; + } else if (fieldType === 'number') { + normalizedItem.fieldConstraint = { + numberType: fc.numberType || 'integer', + ...(fc.minValue !== undefined && fc.minValue !== null ? { minValue: fc.minValue } : {}), + ...(fc.maxValue !== undefined && fc.maxValue !== null ? { maxValue: fc.maxValue } : {}), + }; + } else if (fieldType === 'fileUpload') { + normalizedItem.fieldConstraint = { + ...(fc.fileTypes ? { fileTypes: fc.fileTypes } : {}), + ...(fc.maxFileSize !== undefined && fc.maxFileSize !== null ? { maxFileSize: fc.maxFileSize } : {}), + ...(fc.maxFileCount !== undefined && fc.maxFileCount !== null ? { maxFileCount: fc.maxFileCount } : {}), + }; + } + } + + normalized[realKey] = normalizedItem; + }); + + next.value = normalized; + return next; + }); + })(), modelConfig: n.properties?.modelConfig ? { ...n.properties.modelConfig, @@ -1006,7 +1259,22 @@ const getList = async () => { const getNodeLibrary = async () => { try { const res = await getNodeLibraryList(); - nodeLibraryGroups.value = res.data?.groups || []; + const groups = res.data?.groups || []; + + // 对HTTP节点的请求体字段进行类型转换 + groups.forEach((group) => { + group.items?.forEach((item) => { + if (item.nodeCode === 'http' && item.formConfig) { + item.formConfig.forEach((field) => { + if (field.field === 'body' && field.type === 'keyValue') { + field.type = 'httpBody'; + } + }); + } + }); + }); + + nodeLibraryGroups.value = groups; } catch { // 错误已由全局拦截器处理 nodeLibraryGroups.value = []; @@ -1245,7 +1513,24 @@ const useWorkflow = async (workflow: WorkflowItem) => { // 从节点根级别的 formConfig 读取(不是 node.config.formConfig) if (node.formConfig && Array.isArray(node.formConfig)) { node.formConfig.forEach((field: any) => { - const fieldKey = `${node.id}_${field.label}`; + // HTTP 节点:只初始化请求体中 showInForm=true 的子字段 + if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') { + Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => { + if (!bodyItem || bodyItem.showInForm !== true) return; + const bodyFieldKey = `${node.id}_body_${bodyKey}`; + if (bodyItem.fieldType === 'number') { + creationFormValues[bodyFieldKey] = + bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '' ? Number(bodyItem.value) : null; + } else if (bodyItem.fieldType === 'fileUpload') { + creationFormValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value : bodyItem.value ? [bodyItem.value] : []; + } else { + creationFormValues[bodyFieldKey] = bodyItem.value || ''; + } + }); + return; + } + + const fieldKey = `${node.id}_${field.field || field.label}`; // 根据字段类型转换值 if (field.type === 'number') { // 数字类型:转换为数字或 null @@ -1354,6 +1639,148 @@ const deleteWorkflowAction = async (workflow: WorkflowItem) => { } } }; +// 创作模式字段工具 +const getCreationFieldKey = (node: any, field: any) => { + if (field?.__isHttpBodyChild) { + return `${node.id}_body_${field.bodyKey}`; + } + return `${node.id}_${field.field || field.label}`; +}; +const getCreationFieldConstraint = (field: any) => (field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {}); +const toOptionalNumber = (value: any) => { + if (value === undefined || value === null || value === '') return undefined; + const n = Number(value); + return Number.isNaN(n) ? undefined : n; +}; +const getCreationNumberMin = (field: any) => toOptionalNumber(getCreationFieldConstraint(field).minValue); +const getCreationNumberMax = (field: any) => toOptionalNumber(getCreationFieldConstraint(field).maxValue); +const getCreationFileAccept = (field: any) => { + const fc = getCreationFieldConstraint(field); + const types = String(fc.fileTypes || '').trim(); + if (!types) return ''; + return types + .split(',') + .map((t: string) => t.trim()) + .filter(Boolean) + .map((t: string) => (t.startsWith('.') ? t : `.${t}`)) + .join(','); +}; +const isCreationFileField = (field: any) => field?.type === 'upload' || field?.type === 'uploadMultiple' || field?.type === 'fileUpload'; +const getCreationVisibleFields = (node: any) => { + const fields = Array.isArray(node?.formConfig) ? node.formConfig : []; + const result: any[] = []; + fields.forEach((field: any) => { + if (!field) return; + if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return; + + if (String(node?.nodeCode || '').toLowerCase() === 'http') { + if (field.field !== 'body') return; + const bodyVal = field.value; + if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return; + Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => { + if (!bodyItem || bodyItem.showInForm !== true) return; + result.push({ + __isHttpBodyChild: true, + bodyKey, + field: `body.${bodyKey}`, + label: bodyItem.key || bodyKey, + required: false, + type: bodyItem.fieldType || 'input', + fieldType: bodyItem.fieldType || 'string', + fieldConstraint: bodyItem.fieldConstraint || {}, + }); + }); + return; + } + + result.push(field); + }); + return result; +}; +const creationFieldFiles = reactive>>({}); +const getCreationFieldFiles = (node: any, field: any) => { + const key = getCreationFieldKey(node, field); + return creationFieldFiles[key] || []; +}; +const getCreationFileCountText = (node: any, field: any) => { + const current = getCreationFieldFiles(node, field).length; + const max = Number(getCreationFieldConstraint(field).maxFileCount); + if (!Number.isNaN(max) && max > 0) { + return `${current} / ${max}`; + } + return `${current}`; +}; +const getCreationFileRuleText = (field: any) => { + const fc = getCreationFieldConstraint(field); + const parts: string[] = []; + if (fc.fileTypes) { + parts.push(`支持格式: ${String(fc.fileTypes)}`); + } + const maxSize = Number(fc.maxFileSize); + if (!Number.isNaN(maxSize) && maxSize > 0) { + parts.push(`最大大小: ${maxSize}MB`); + } + const maxCount = Number(fc.maxFileCount); + if (!Number.isNaN(maxCount) && maxCount > 0) { + parts.push(`最多: ${maxCount} 个`); + } + return parts.join(' | ') || '请上传文件'; +}; +const handleCreationFieldUpload = async (node: any, field: any, file: any) => { + const key = getCreationFieldKey(node, field); + try { + const fc = getCreationFieldConstraint(field); + const raw = file?.raw; + if (!raw) throw new Error('无效文件'); + + // 文件类型校验 + const typeRules = String(fc.fileTypes || '') + .split(',') + .map((t: string) => t.trim().toLowerCase().replace(/^\./, '')) + .filter(Boolean); + if (typeRules.length > 0) { + const ext = (raw.name?.split('.').pop() || '').toLowerCase(); + if (!typeRules.includes(ext)) { + throw new Error(`文件格式不符合要求,仅支持:${typeRules.join(',')}`); + } + } + + // 文件大小校验(MB) + const maxFileSize = Number(fc.maxFileSize); + if (!Number.isNaN(maxFileSize) && maxFileSize > 0) { + const sizeMB = raw.size / 1024 / 1024; + if (sizeMB > maxFileSize) { + throw new Error(`文件大小超限,最大 ${maxFileSize}MB`); + } + } + + // 文件数量校验 + const maxFileCount = Number(fc.maxFileCount); + if (!Number.isNaN(maxFileCount) && maxFileCount > 0) { + const currentCount = creationFieldFiles[key]?.length || 0; + if (currentCount >= maxFileCount) { + throw new Error(`文件数量超限,最多 ${maxFileCount} 个`); + } + } + + const uploadRes = await uploadFile(raw); + if (!uploadRes || !uploadRes.data || !uploadRes.data.fileURL) throw new Error('上传失败:未返回文件URL'); + const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL; + if (!creationFieldFiles[key]) creationFieldFiles[key] = []; + creationFieldFiles[key].push({ name: file.name, url: fileUrl }); + creationFormValues[key] = creationFieldFiles[key].map((f) => f.url); + ElMessage.success('文件上传成功'); + } catch (error: any) { + ElMessage.error(error?.message || '文件上传失败'); + } +}; +const removeCreationFieldFile = (node: any, field: any, fileIdx: number) => { + const key = getCreationFieldKey(node, field); + if (!creationFieldFiles[key]) return; + creationFieldFiles[key].splice(fileIdx, 1); + creationFormValues[key] = creationFieldFiles[key].map((f) => f.url); +}; + // 处理文件选择 const handleFileSelect = (file: any) => { selectedFiles.value.push(file.raw); @@ -1366,6 +1793,54 @@ const removeFile = (index: number) => { const handleCreationSkillConfirm = (skill: SkillItem) => { selectedCreationSkill.value = skill; }; +const validateCreationFields = (): boolean => { + if (!currentWorkflowForCreation.value?.nodeInputParams) return true; + for (const node of currentWorkflowForCreation.value.nodeInputParams as any[]) { + const fields = getCreationVisibleFields(node); + for (const field of fields) { + const key = getCreationFieldKey(node, field); + const value = creationFormValues[key]; + if (field.required && (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0))) { + ElMessage.warning(`请填写必填项:${field.label}`); + return false; + } + + if (field.type === 'string') { + const fc = getCreationFieldConstraint(field); + const len = String(value || '').length; + if (fc.minLength !== undefined && fc.minLength !== null && len < Number(fc.minLength)) { + ElMessage.warning(`字段「${field.label}」长度不能小于 ${fc.minLength}`); + return false; + } + if (fc.maxLength !== undefined && fc.maxLength !== null && len > Number(fc.maxLength)) { + ElMessage.warning(`字段「${field.label}」长度不能大于 ${fc.maxLength}`); + return false; + } + } + + if (field.type === 'number') { + const fc = getCreationFieldConstraint(field); + if (value !== undefined && value !== null && value !== '') { + const n = Number(value); + if (Number.isNaN(n)) { + ElMessage.warning(`字段「${field.label}」必须是数字`); + return false; + } + if (fc.minValue !== undefined && fc.minValue !== null && n < Number(fc.minValue)) { + ElMessage.warning(`字段「${field.label}」不能小于 ${fc.minValue}`); + return false; + } + if (fc.maxValue !== undefined && fc.maxValue !== null && n > Number(fc.maxValue)) { + ElMessage.warning(`字段「${field.label}」不能大于 ${fc.maxValue}`); + return false; + } + } + } + } + } + return true; +}; + // 发送消息/开始创作 const sendMessage = async () => { if (!currentWorkflowForCreation.value) { @@ -1373,6 +1848,10 @@ const sendMessage = async () => { return; } + if (!validateCreationFields()) { + return; + } + // 检查是否设置了会话模型 try { const chatModelRes: any = await getIsChatModel(); @@ -1423,10 +1902,28 @@ const sendMessage = async () => { // 添加表单配置和值 if (node.formConfig && Array.isArray(node.formConfig)) { nodeParam.formConfig = node.formConfig.map((field: any) => { - const fieldKey = `${node.id}_${field.label}`; + // HTTP body: 将创作模式填写值回写到 body 的 showInForm 子字段 + if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') { + const bodyValue = { ...field.value }; + Object.entries(bodyValue).forEach(([bodyKey, bodyItem]: [string, any]) => { + if (!bodyItem || bodyItem.showInForm !== true) return; + const bodyFieldKey = `${node.id}_body_${bodyKey}`; + const userVal = creationFormValues[bodyFieldKey]; + bodyValue[bodyKey] = { + ...bodyItem, + value: userVal !== undefined ? userVal : bodyItem.value, + }; + }); + return { + ...field, + value: bodyValue, + }; + } + + const fieldKey = `${node.id}_${field.field || field.label}`; return { ...field, - value: creationFormValues[fieldKey] || field.value || '', + value: creationFormValues[fieldKey] !== undefined ? creationFormValues[fieldKey] : field.value, }; }); } @@ -1528,6 +2025,11 @@ const getFieldClass = (type: string) => { if (type === 'number' || type === 'switch') return 'form-item-small'; return 'form-item-medium'; }; +const getCreationFieldLayoutClass = (field: any) => { + if (isCreationFileField(field) || field?.type === 'textarea') return 'form-item-full'; + if (field?.type === 'number' || field?.type === 'inputNumber' || field?.type === 'switch') return 'form-item-small'; + return 'form-item-medium'; +}; // 处理树节点点击 const handleTreeNodeClick = async (data: TreeNode) => { // 处理工作流节点(contentType) @@ -1578,7 +2080,7 @@ const handleTreeNodeClick = async (data: TreeNode) => { // 从节点根级别的 formConfig 读取 if (node.formConfig && Array.isArray(node.formConfig)) { node.formConfig.forEach((field: any) => { - const fieldKey = `${node.id}_${field.label}`; + const fieldKey = `${node.id}_${field.field || field.label}`; // 根据字段类型转换值 if (field.type === 'number') { creationFormValues[fieldKey] = field.value ? Number(field.value) : null; @@ -1655,7 +2157,7 @@ const handleTreeNodeClick = async (data: TreeNode) => { // 从节点根级别的 formConfig 读取 if (node.formConfig && Array.isArray(node.formConfig)) { node.formConfig.forEach((field: any) => { - const fieldKey = `${node.id}_${field.label}`; + const fieldKey = `${node.id}_${field.field || field.label}`; // 根据字段类型转换值 if (field.type === 'number') { creationFormValues[fieldKey] = field.value ? Number(field.value) : null; @@ -1769,7 +2271,17 @@ const getSelectOptions = (fieldItem: NodeLibraryFormItem) => { }; // 添加自定义字段 const addCustomField = () => { - customFields.value.push({ label: '', value: '', type: 'input', required: false, fileList: [], uploadKey: 0 }); + customFields.value.push({ + label: '', + value: '', + type: 'input', + required: false, + fileList: [], + uploadKey: 0, + fileTypes: '', + maxFileSize: undefined, + maxFileCount: undefined, + }); }; // 删除自定义字段 const removeCustomField = (index: number) => { @@ -1781,94 +2293,6 @@ const getCustomFieldFileList = (index: number) => { if (!field || !field.fileList) return []; return field.fileList; }; -// 处理自定义字段文件上传 -const handleCustomFieldUpload = async (index: number, file: any, type: string) => { - const field = customFields.value[index]; - if (!field) return; - - try { - // 上传文件到OSS - const uploadRes = await uploadFile(file.raw); - - // 检查上传是否成功 - if (!uploadRes || !uploadRes.data || !uploadRes.data.fileURL) { - throw new Error('上传失败:未返回文件URL'); - } - - const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL; - - // 初始化 fileList - if (!field.fileList) { - field.fileList = []; - } - - // 根据类型处理 - if (type === 'upload') { - // 单个上传:替换现有文件 - field.fileList = [{ name: file.name, url: fileUrl }]; - field.value = fileUrl; - } else if (type === 'uploadMultiple') { - // 多个上传:添加到数组 - field.fileList.push({ name: file.name, url: fileUrl }); - - // 解析现有的 value - let urls: string[] = []; - if (field.value) { - try { - urls = JSON.parse(field.value); - if (!Array.isArray(urls)) { - urls = [field.value]; - } - } catch (e) { - urls = field.value ? [field.value] : []; - } - } - - // 添加新的 URL - urls.push(fileUrl); - field.value = JSON.stringify(urls); - } - - ElMessage.success('文件上传成功'); - } catch (error: any) { - ElMessage.error(error?.message || '文件上传失败'); - console.error('Upload error:', error); - // 上传失败时,递增 uploadKey 来重置上传组件 - field.uploadKey = (field.uploadKey || 0) + 1; - } -}; -// 删除自定义字段的文件 -const removeCustomFieldFile = (index: number, fileIdx: number, type: string) => { - const field = customFields.value[index]; - if (!field || !field.fileList) return; - - // 删除文件 - field.fileList.splice(fileIdx, 1); - - // 更新 value - if (type === 'upload') { - // 单个上传:清空 value - field.value = ''; - } else if (type === 'uploadMultiple') { - // 多个上传:从数组中删除对应的 URL - try { - let urls: string[] = []; - if (field.value) { - urls = JSON.parse(field.value); - if (!Array.isArray(urls)) { - urls = []; - } - } - urls.splice(fileIdx, 1); - field.value = urls.length > 0 ? JSON.stringify(urls) : ''; - } catch (e) { - field.value = ''; - } - } - - // 递增 uploadKey 来重置上传组件 - field.uploadKey = (field.uploadKey || 0) + 1; -}; // 获取键值对数组(使用响应式存储) const getKeyValuePairs = (field: string) => { // 如果还没有初始化,从 dynamicFormValues 中加载 @@ -1918,7 +2342,336 @@ const removeKeyValuePair = (field: string, index: number) => { const updateKeyValueField = (field: string) => { updateKeyValueFieldFromPairs(field); }; -// 从键值对数组更新字段值 + +const isHttpExpandTriggerField = (fieldItem: any) => { + if (String(formState.nodeCode || '').toLowerCase() !== 'http') return false; + if (fieldItem?.field !== 'responseType') return false; + return Array.isArray(fieldItem?.expand) && fieldItem.expand.length > 0; +}; +const openHttpExpandDialog = (fieldItem: any) => { + if (!Array.isArray(fieldItem?.expand)) return; + currentHttpExpandFields.value = fieldItem.expand; + Object.keys(httpExpandFormValues).forEach((k) => delete httpExpandFormValues[k]); + Object.keys(httpExpandKeyValuePairs).forEach((k) => delete httpExpandKeyValuePairs[k]); + fieldItem.expand.forEach((f: any) => { + httpExpandFormValues[f.field] = dynamicFormValues[f.field] !== undefined ? dynamicFormValues[f.field] : (f.default ?? ''); + }); + showHttpExpandDialog.value = true; +}; +const getHttpExpandKeyValuePairs = (field: string) => { + if (!httpExpandKeyValuePairs[field]) { + const value = httpExpandFormValues[field]; + if (!value) { + httpExpandKeyValuePairs[field] = [{ key: '', value: '' }]; + } else if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + httpExpandKeyValuePairs[field] = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? Object.entries(parsed).map(([k, v]) => ({ key: k, value: String(v) })) + : [{ key: '', value: '' }]; + } catch { + httpExpandKeyValuePairs[field] = [{ key: '', value: '' }]; + } + } else if (typeof value === 'object' && !Array.isArray(value)) { + httpExpandKeyValuePairs[field] = Object.entries(value).map(([k, v]) => ({ key: k, value: String(v) })); + } + } + return httpExpandKeyValuePairs[field] || [{ key: '', value: '' }]; +}; +const updateHttpExpandKeyValueField = (field: string) => { + const pairs = getHttpExpandKeyValuePairs(field); + const obj: Record = {}; + pairs.forEach((p) => { + if (p.key?.trim()) obj[p.key.trim()] = p.value; + }); + httpExpandFormValues[field] = obj; +}; +const addHttpExpandKeyValuePair = (field: string) => { + getHttpExpandKeyValuePairs(field).push({ key: '', value: '' }); + updateHttpExpandKeyValueField(field); +}; +const removeHttpExpandKeyValuePair = (field: string, index: number) => { + const pairs = getHttpExpandKeyValuePairs(field); + pairs.splice(index, 1); + if (pairs.length === 0) pairs.push({ key: '', value: '' }); + updateHttpExpandKeyValueField(field); +}; +const confirmHttpExpandDialog = () => { + currentHttpExpandFields.value.forEach((f: any) => { + dynamicFormValues[f.field] = httpExpandFormValues[f.field]; + }); + showHttpExpandDialog.value = false; + ElMessage.success('主动拉取参数已保存'); +}; + +// HTTP请求体配置相关函数 +// 打开HTTP请求体配置弹窗 +const openHttpBodyDialog = (field: string) => { + currentHttpBodyField.value = field; + showHttpBodyDialog.value = true; +}; +// 获取HTTP请求体已配置字段数量 +const getHttpBodyFieldCount = (field: string) => { + const data = getHttpBodyData(field); + if (!data || typeof data !== 'object') return 0; + return Object.values(data).filter((item: any) => item && String(item.key || '').trim() !== '').length; +}; +// 获取HTTP请求体数据 +const getHttpBodyData = (field: string) => { + const hydrateBodyKeys = (obj: any) => { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return {}; + Object.entries(obj).forEach(([outerKey, rawItem]: [string, any]) => { + if (!rawItem || typeof rawItem !== 'object' || Array.isArray(rawItem)) { + obj[outerKey] = { key: outerKey, value: rawItem, showInForm: false }; + return; + } + if (!rawItem.key) { + rawItem.key = outerKey; + } + if (rawItem.showInForm === true) { + rawItem.fieldType = rawItem.fieldType || 'string'; + rawItem.fieldConstraint = rawItem.fieldConstraint && typeof rawItem.fieldConstraint === 'object' ? rawItem.fieldConstraint : {}; + } else { + rawItem.showInForm = false; + } + }); + return obj; + }; + + if (!httpBodyData[field]) { + const existing = dynamicFormValues[field]; + if (existing && typeof existing === 'string') { + try { + const parsed = JSON.parse(existing); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + httpBodyData[field] = hydrateBodyKeys(parsed); + } else { + httpBodyData[field] = {}; + } + } catch { + httpBodyData[field] = {}; + } + } else if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + httpBodyData[field] = hydrateBodyKeys(existing); + } else { + httpBodyData[field] = {}; + } + } + return httpBodyData[field]; +}; +// 更新HTTP请求体数据到表单 +const updateHttpBodyData = (field: string) => { + dynamicFormValues[field] = JSON.stringify(httpBodyData[field] || {}); +}; +// 添加HTTP请求体字段 +const addHttpBodyField = () => { + const field = currentHttpBodyField.value; + if (!field) return; + const data = getHttpBodyData(field); + const tempKey = `_temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + data[tempKey] = { + key: '', + value: '', + fieldType: 'string', + showInForm: false, + fieldConstraint: { + numberType: 'integer', + minValue: undefined, + maxValue: undefined, + minLength: undefined, + maxLength: undefined, + fileTypes: '', + maxFileSize: undefined, + maxFileCount: undefined, + }, + }; +}; +// 删除HTTP请求体字段 +const deleteHttpBodyField = (fieldKey: string) => { + const field = currentHttpBodyField.value; + if (!field) return; + const data = getHttpBodyData(field); + delete data[fieldKey]; +}; +// 切换是否在表单显示时,自动整理字段数据 +const handleHttpBodyShowInFormChange = (fieldValue: any, enabled: boolean) => { + if (enabled) { + if (!fieldValue.fieldType) { + fieldValue.fieldType = 'string'; + } + if (!fieldValue.fieldConstraint || typeof fieldValue.fieldConstraint !== 'object') { + fieldValue.fieldConstraint = { + numberType: 'integer', + minValue: undefined, + maxValue: undefined, + minLength: undefined, + maxLength: undefined, + fileTypes: '', + maxFileSize: undefined, + }; + } + } else { + fieldValue.fieldType = undefined; + fieldValue.fieldConstraint = undefined; + } +}; +// 确认HTTP请求体配置 +const confirmHttpBodyConfig = () => { + const field = currentHttpBodyField.value; + if (!field) return; + + const data = getHttpBodyData(field); + const entries = Object.entries(data) as Array<[string, any]>; + + if (entries.length === 0) { + ElMessage.warning('请至少添加一个请求体字段'); + return; + } + + for (const [, item] of entries) { + const keyName = String(item?.key || '').trim(); + if (!keyName) { + ElMessage.warning('请为所有字段设置键名'); + return; + } + + if (item.showInForm !== true) { + const value = item?.value; + if (value === undefined || value === null || String(value).trim() === '') { + ElMessage.warning(`字段「${keyName}」不在表单显示时,值不能为空`); + return; + } + continue; + } + + const fieldType = item?.fieldType || 'string'; + const fc = item?.fieldConstraint || {}; + + if (fieldType === 'string') { + const min = fc.minLength; + const max = fc.maxLength; + if (min !== undefined && min !== null && Number(min) < 0) { + ElMessage.warning(`字段「${keyName}」最小长度不能小于0`); + return; + } + if (max !== undefined && max !== null && Number(max) < 0) { + ElMessage.warning(`字段「${keyName}」最大长度不能小于0`); + return; + } + if (min !== undefined && max !== undefined && min !== null && max !== null && Number(min) > Number(max)) { + ElMessage.warning(`字段「${keyName}」最小长度不能大于最大长度`); + return; + } + } else if (fieldType === 'number') { + const numberType = fc.numberType; + if (numberType !== 'integer' && numberType !== 'decimal') { + ElMessage.warning(`字段「${keyName}」请选择数字类型(整数/小数)`); + return; + } + const min = fc.minValue; + const max = fc.maxValue; + if (min !== undefined && min !== null && Number.isNaN(Number(min))) { + ElMessage.warning(`字段「${keyName}」最小值必须是数字`); + return; + } + if (max !== undefined && max !== null && Number.isNaN(Number(max))) { + ElMessage.warning(`字段「${keyName}」最大值必须是数字`); + return; + } + if (min !== undefined && max !== undefined && min !== null && max !== null && Number(min) > Number(max)) { + ElMessage.warning(`字段「${keyName}」最小值不能大于最大值`); + return; + } + } else if (fieldType === 'fileUpload') { + const fileTypes = String(fc.fileTypes || '').trim(); + const maxFileSize = fc.maxFileSize; + const maxFileCount = fc.maxFileCount; + if (!fileTypes) { + ElMessage.warning(`字段「${keyName}」请填写文件格式`); + return; + } + if (maxFileSize === undefined || maxFileSize === null || String(maxFileSize).trim() === '') { + ElMessage.warning(`字段「${keyName}」请填写最大文件大小`); + return; + } + if (Number.isNaN(Number(maxFileSize)) || Number(maxFileSize) <= 0) { + ElMessage.warning(`字段「${keyName}」最大文件大小必须大于0`); + return; + } + if (maxFileCount === undefined || maxFileCount === null || String(maxFileCount).trim() === '') { + ElMessage.warning(`字段「${keyName}」请填写最大数量`); + return; + } + if (Number.isNaN(Number(maxFileCount)) || Number(maxFileCount) <= 0) { + ElMessage.warning(`字段「${keyName}」最大数量必须大于0`); + return; + } + } + } + + // 归一化为最终结构:外层使用真实 key,移除 _temp,按 showInForm 裁剪字段 + const normalizedBodyData: Record = {}; + entries.forEach(([, item]) => { + const realKey = String(item?.key || '').trim(); + if (!realKey) return; + + const showInForm = item?.showInForm === true; + const rawValue = item?.value; + let normalizedValue: any = rawValue; + + // 如果 value 选择了上级参数(形如 ${nodeId.field}),转成对象结构传给后端 + if (typeof rawValue === 'string') { + const matched = rawValue.match(/^\$\{([^\.}]+)\.([^}]+)\}$/); + if (matched) { + normalizedValue = { + field: matched[2], + nodeId: matched[1], + quoteOutput: false, + }; + } + } + + const normalizedItem: any = { + key: realKey, + showInForm, + value: normalizedValue, + }; + + if (showInForm) { + const fieldType = item?.fieldType || 'string'; + const fc = item?.fieldConstraint || {}; + normalizedItem.fieldType = fieldType; + + if (fieldType === 'string') { + normalizedItem.fieldConstraint = { + ...(fc.minLength !== undefined && fc.minLength !== null ? { minLength: fc.minLength } : {}), + ...(fc.maxLength !== undefined && fc.maxLength !== null ? { maxLength: fc.maxLength } : {}), + }; + } else if (fieldType === 'number') { + normalizedItem.fieldConstraint = { + numberType: fc.numberType || 'integer', + ...(fc.minValue !== undefined && fc.minValue !== null ? { minValue: fc.minValue } : {}), + ...(fc.maxValue !== undefined && fc.maxValue !== null ? { maxValue: fc.maxValue } : {}), + }; + } else if (fieldType === 'fileUpload') { + normalizedItem.fieldConstraint = { + ...(fc.fileTypes ? { fileTypes: fc.fileTypes } : {}), + ...(fc.maxFileSize !== undefined && fc.maxFileSize !== null ? { maxFileSize: fc.maxFileSize } : {}), + ...(fc.maxFileCount !== undefined && fc.maxFileCount !== null ? { maxFileCount: fc.maxFileCount } : {}), + }; + } + } + + normalizedBodyData[realKey] = normalizedItem; + }); + + httpBodyData[field] = normalizedBodyData; + updateHttpBodyData(field); + showHttpBodyDialog.value = false; + ElMessage.success('请求体配置已保存'); +}; +// 添加HTTP请求体字段 const updateKeyValueFieldFromPairs = (field: string) => { const pairs = fieldKeyValuePairs[field]; if (!pairs) return; @@ -2050,9 +2803,6 @@ const canAddCustomFields = (element: SelectedState | null) => { // 排除HTTP节点(已有完整的表单配置,不需要自定义字段) if (nodeCode === 'http') return false; - // 排除表单节点 - if (nodeCode === 'form') return false; - return true; }; // 判断是否可以选择上级参数(排除表单参数节点和开始节点) @@ -2368,6 +3118,14 @@ watch( formState.nodeCode = String(e?.properties?.nodeCode || ''); formState.field = String(e?.properties?.field || ''); + // 清理字段级缓存,避免不同工作流/不同节点串数据 + Object.keys(fieldKeyValuePairs).forEach((k) => delete fieldKeyValuePairs[k]); + Object.keys(httpBodyData).forEach((k) => delete httpBodyData[k]); + Object.keys(fieldFileLists).forEach((k) => delete fieldFileLists[k]); + Object.keys(fieldUploadKeys).forEach((k) => delete fieldUploadKeys[k]); + currentHttpBodyField.value = ''; + showHttpBodyDialog.value = false; + // 重置 dynamicFormValues(不删除键,保持响应式) for (const key in dynamicFormValues) { dynamicFormValues[key] = ''; @@ -2396,11 +3154,34 @@ watch( } } else { // 自定义字段:加载到 customFields + const customType = fieldConfig.type === 'upload' ? 'uploadMultiple' : fieldConfig.type || 'input'; + let parsedFileList: Array<{ name: string; url: string }> = []; + if (customType === 'uploadMultiple' && fieldConfig.value) { + try { + const urls = typeof fieldConfig.value === 'string' ? JSON.parse(fieldConfig.value) : fieldConfig.value; + if (Array.isArray(urls)) { + parsedFileList = urls.map((u: string) => ({ name: String(u).split('/').pop() || 'file', url: u })); + } + } catch { + parsedFileList = []; + } + } customFields.value.push({ label: fieldConfig.label || '', value: fieldConfig.value || '', - type: fieldConfig.type || 'input', + type: customType, required: fieldConfig.required || false, + fileList: parsedFileList, + uploadKey: 0, + fileTypes: fieldConfig.fieldConstraint?.fileTypes || '', + maxFileSize: + fieldConfig.fieldConstraint?.maxFileSize !== undefined && fieldConfig.fieldConstraint?.maxFileSize !== null + ? Number(fieldConfig.fieldConstraint.maxFileSize) + : undefined, + maxFileCount: + fieldConfig.fieldConstraint?.maxFileCount !== undefined && fieldConfig.fieldConstraint?.maxFileCount !== null + ? Number(fieldConfig.fieldConstraint.maxFileCount) + : undefined, }); } }); @@ -2524,6 +3305,18 @@ const applySelected = () => { ElMessage.warning(`请填写必填项:${missingField.label}`); return; } + + const invalidCustomUploadField = customFields.value.find((field: any) => { + if (field.type !== 'uploadMultiple') return false; + const hasTypes = String(field.fileTypes || '').trim().length > 0; + const maxSize = Number(field.maxFileSize); + const maxCount = Number(field.maxFileCount); + return !hasTypes || Number.isNaN(maxSize) || maxSize <= 0 || Number.isNaN(maxCount) || maxCount <= 0; + }); + if (invalidCustomUploadField) { + ElMessage.warning(`自定义字段「${invalidCustomUploadField.label || '未命名字段'}」请完整配置文件格式、大小和数量`); + return; + } } const p: Item = { ...cur.properties, nodeCode: formState.nodeCode }; @@ -2593,12 +3386,23 @@ const applySelected = () => { // 1. 添加基础表单字段(非模型字段) // 重用上面的 modelFieldNames currentNodeForm.value.forEach((fieldItem) => { - const value = dynamicFormValues[fieldItem.field]; + const rawValue = dynamicFormValues[fieldItem.field]; + let normalizedValue = rawValue !== undefined && rawValue !== null ? rawValue : fieldItem.default || ''; + + // keyValue/httpBody 保存为对象,而不是 JSON 字符串 + if ((fieldItem.type === 'keyValue' || fieldItem.type === 'httpBody') && typeof normalizedValue === 'string') { + try { + normalizedValue = normalizedValue ? JSON.parse(normalizedValue) : {}; + } catch { + normalizedValue = {}; + } + } + formConfig.push({ type: fieldItem.type, field: fieldItem.field, label: fieldItem.label, - value: value !== undefined && value !== null ? value : fieldItem.default || '', + value: normalizedValue, required: fieldItem.required || false, }); }); @@ -2611,6 +3415,15 @@ const applySelected = () => { label: field.label, value: field.value, required: field.required, + ...(field.type === 'uploadMultiple' + ? { + fieldConstraint: { + ...(field.fileTypes ? { fileTypes: field.fileTypes } : {}), + ...(field.maxFileSize !== undefined && field.maxFileSize !== null ? { maxFileSize: field.maxFileSize } : {}), + ...(field.maxFileCount !== undefined && field.maxFileCount !== null ? { maxFileCount: field.maxFileCount } : {}), + }, + } + : {}), }); }); @@ -2648,6 +3461,11 @@ const applySelected = () => { }; // 保存工作流 const saveWorkflowAction = async () => { + // 保存前先应用当前选中节点的编辑值,避免 UI 修改未写回节点属性 + if (selectedElement.value && selectedElement.value.kind === 'node') { + applySelected(); + } + syncDsl(); const validateResult = validateFlowConstraints(); if (!validateResult.ok) { @@ -3998,6 +4816,65 @@ onBeforeUnmount(() => { .creation-actions :deep(.el-button:active) { transform: translateY(0); } +/* 创作模式表单布局优化(仅创作表单生效) */ +.creation-form-panel { + padding: 14px 16px 10px; +} +.simple-form-scroll { + height: 100%; + overflow: auto; + padding-right: 4px; +} +.creation-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 16px; + align-items: start; + grid-auto-flow: row dense; +} +.creation-form-grid :deep(.el-form-item:last-child:nth-child(odd)) { + grid-column: 1 / -1; +} +.creation-form-grid :deep(.el-form-item) { + margin-bottom: 0; +} +.creation-form-grid :deep(.el-form-item__label) { + margin-bottom: 6px; + font-size: 13px; + color: #334155; + line-height: 1.35; +} +.creation-form-grid :deep(.el-input-number), +.creation-form-grid :deep(.el-select), +.creation-form-grid :deep(.el-select .el-input), +.creation-form-grid :deep(.el-textarea), +.creation-form-grid :deep(.el-input) { + width: 100%; +} +.creation-form-grid :deep(.el-input__wrapper), +.creation-form-grid :deep(.el-textarea__inner) { + border-radius: 8px; +} +.creation-form-grid :deep(.el-input-number .el-input__inner) { + text-align: left; +} +.creation-form-grid .form-item-full { + grid-column: 1 / -1; +} +.creation-form-grid .form-item-small, +.creation-form-grid .form-item-medium { + grid-column: span 1; +} +@media (max-width: 900px) { + .creation-form-grid { + grid-template-columns: 1fr; + } + .creation-form-grid .form-item-small, + .creation-form-grid .form-item-medium { + grid-column: 1 / -1; + } +} + @media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; @@ -4271,6 +5148,132 @@ onBeforeUnmount(() => { gap: 8px; } +/* 创作模式上传卡片样式 */ +.creation-upload-card { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 8px; +} +.creation-upload-top { + display: flex; + align-items: center; + justify-content: flex-start; +} +.creation-upload-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.creation-upload-tag { + display: inline-flex; + align-items: center; + font-size: 12px; + line-height: 1; + padding: 7px 10px; + border-radius: 999px; + border: 1px solid #dbeafe; + background: #eff6ff; + color: #1e40af; + max-width: 100%; +} +.creation-upload-tag.rule { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.creation-upload-tag.count { + border-color: #cbd5e1; + background: #f8fafc; + color: #334155; +} +.creation-upload-list { + margin-top: 2px; + display: flex; + flex-direction: column; + gap: 6px; +} +.creation-upload-item { + padding: 6px 8px; + border-radius: 8px; + background: #fff; + border: 1px solid #e2e8f0; +} + +/* HTTP 请求体配置样式 */ +.http-body-config-wrapper { + display: flex; + flex-direction: column; + gap: 10px; +} +.http-body-summary { + font-size: 12px; + color: #64748b; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 6px 10px; +} +.http-body-dialog-content { + max-height: 62vh; + overflow: auto; + padding-right: 4px; +} +.http-body-fields-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.http-body-field-item { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px; + background: #fff; +} +.http-body-main-row { + display: grid; + grid-template-columns: 1fr 180px 40px; + gap: 10px; + align-items: center; +} +.http-body-value-row, +.http-body-rules-row { + margin-top: 10px; +} +.http-body-rules-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + padding-top: 10px; + border-top: 1px dashed #e5e7eb; +} +.rule-num, +.rule-type { + width: 140px; +} +.rule-file-types { + width: 320px; +} +.rule-file-size { + width: 180px; +} +.rule-sep { + color: #94a3b8; +} +.http-body-add-btn { + margin-top: 12px; +} +@media (max-width: 1200px) { + .http-body-main-row { + grid-template-columns: 1fr 120px 1fr 40px; + } +} /* 预览弹窗样式 */ .preview-container { width: 100%;