6078 lines
178 KiB
Vue
6078 lines
178 KiB
Vue
<template>
|
||
<div class="creation-page" :class="{ 'creation-mode': isCreationMode }">
|
||
<!-- 左侧面板:工作空间/当前选中元素 Tab切换 -->
|
||
<div class="panel left">
|
||
<el-tabs v-model="leftPanelTab" class="left-tabs">
|
||
<!-- Tab 1: 工作空间 -->
|
||
<el-tab-pane label="工作空间" name="workspace">
|
||
<div class="tree-wrap" v-loading="treeLoading">
|
||
<el-empty v-if="!treeLoading && treeNodes.length === 0" description="暂无作品数据" />
|
||
<el-tree
|
||
v-else
|
||
:data="treeNodes"
|
||
node-key="id"
|
||
:props="treeProps"
|
||
default-expand-all
|
||
:highlight-current="true"
|
||
:expand-on-click-node="false"
|
||
@node-click="handleTreeNodeClick"
|
||
>
|
||
<template #default="{ data }">
|
||
<div class="tree-node">
|
||
<span class="ellipsis">{{ data.label }}</span>
|
||
<div v-if="data.nodeType === 'title' && data.fileUrl" class="tree-node-actions">
|
||
<el-button type="primary" link size="small" @click.stop="previewNode(data)"> 预览 </el-button>
|
||
<el-button type="primary" link size="small" @click.stop="downloadNode(data)"> 下载 </el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
</div>
|
||
</el-tab-pane>
|
||
<!-- Tab 2: 当前选中元素 -->
|
||
<el-tab-pane label="当前选中" name="selected">
|
||
<div class="selected-panel">
|
||
<el-empty v-if="!selectedElement" description="请先点击一个节点或连线" :image-size="84" />
|
||
<div v-else class="form-container">
|
||
<div class="form-scroll-area">
|
||
<el-form label-position="top" class="prop-form">
|
||
<el-form-item v-if="selectedElement.kind === 'node'" label="节点名称"><el-input v-model="formState.text" /></el-form-item>
|
||
<el-form-item v-if="selectedElement.kind === 'edge'" label="字段"><el-input v-model="formState.field" /></el-form-item>
|
||
<template v-if="selectedElement.kind === 'node'">
|
||
<!-- 提示词选择(如果节点支持) -->
|
||
<el-form-item v-if="currentNodePromptOption" label="选择提示词">
|
||
<div class="prompt-selector-wrapper">
|
||
<el-button type="primary" @click="showPromptSelector = true" style="width: 100%">
|
||
<el-icon>
|
||
<Plus />
|
||
</el-icon>
|
||
编辑提示词
|
||
</el-button>
|
||
<div v-if="promptContent" class="selected-prompt-tag">
|
||
<el-tag type="success" size="large" closable @close="handleRemovePrompt">
|
||
{{ promptContent.length > 50 ? `${promptContent.substring(0, 50)}...` : promptContent }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
<!-- 对话模式开关(如果节点支持) -->
|
||
<el-form-item v-if="currentNodeisSaveFile" label="是否保存文件">
|
||
<el-switch v-model="isSaveFileEnabled" active-text="开启" inactive-text="关闭" />
|
||
</el-form-item>
|
||
<!-- 模型选择(如果有模型配置) -->
|
||
<el-form-item v-if="currentNodeModelConfig.length > 0" label="选择模型">
|
||
<div class="model-selector-wrapper">
|
||
<el-button type="primary" @click="showModelSelector = true" style="width: 100%">
|
||
<el-icon>
|
||
<Plus />
|
||
</el-icon>
|
||
选择模型
|
||
</el-button>
|
||
<div v-if="selectedModel" class="selected-model-tag">
|
||
<el-tag type="success" size="large" closable @close="handleRemoveModel">
|
||
{{ selectedModel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
<!-- 技能选择(如果节点支持) -->
|
||
<el-form-item v-if="currentNodeSkillOption" label="选择技能">
|
||
<div class="skill-selector-wrapper">
|
||
<el-button type="primary" @click="showSkillSelector = true">
|
||
<el-icon>
|
||
<Plus />
|
||
</el-icon>
|
||
选择技能
|
||
</el-button>
|
||
<div v-if="selectedSkill" class="selected-skill-tag">
|
||
<el-tag type="success" size="large" closable @close="handleRemoveSkill">
|
||
{{ selectedSkill.name }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
<template v-if="isParameterConversionNode">
|
||
<el-divider content-position="left">参数结构</el-divider>
|
||
<div class="output-config-list">
|
||
<div v-for="(item, index) in outputConfigFields" :key="index" class="output-config-card">
|
||
<div class="output-config-inline-row">
|
||
<el-input v-model="item.field" placeholder="参数名" class="output-config-inline-input" />
|
||
<el-input v-model="item.value" placeholder="参数说明" class="output-config-inline-input" />
|
||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeOutputConfigField(index)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-button type="primary" link :icon="Plus" class="add-pair-btn" @click="addOutputConfigField">添加参数结构</el-button>
|
||
</template>
|
||
<!-- 基础表单 + 模型表单 -->
|
||
<el-form-item v-for="fieldItem in visibleFormFields" :key="fieldItem.field" :label="fieldItem.label">
|
||
<!-- 下拉选择框 -->
|
||
<el-select
|
||
v-if="fieldItem.type === 'select' || isSelectField(fieldItem)"
|
||
v-model="dynamicFormValues[fieldItem.field]"
|
||
:placeholder="fieldItem.field === 'callbackUrl' ? '' : fieldItem.required ? '必填' : '选填'"
|
||
class="w100"
|
||
>
|
||
<el-option v-for="opt in getSelectOptions(fieldItem)" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
<!-- 数字输入框 -->
|
||
<el-input-number
|
||
v-else-if="fieldItem.type === 'number' || fieldItem.type === 'inputNumber'"
|
||
v-model="dynamicFormValues[fieldItem.field]"
|
||
:min="fieldItem.field === 'count' ? 1 : undefined"
|
||
class="w100"
|
||
/>
|
||
<!-- 多行文本框 -->
|
||
<el-input
|
||
v-else-if="fieldItem.type === 'textarea'"
|
||
v-model="dynamicFormValues[fieldItem.field]"
|
||
type="textarea"
|
||
:rows="3"
|
||
:placeholder="fieldItem.field === 'callbackUrl' ? '' : fieldItem.required ? '必填' : '选填'"
|
||
/>
|
||
<!-- 开关 -->
|
||
<el-switch v-else-if="fieldItem.type === 'switch'" v-model="dynamicFormValues[fieldItem.field]" />
|
||
<!-- 单个文件上传 -->
|
||
<div v-else-if="fieldItem.type === 'upload'" class="field-upload-wrapper">
|
||
<el-upload
|
||
:key="`field-upload-${fieldItem.field}-${getFieldUploadKey(fieldItem.field)}`"
|
||
:auto-upload="false"
|
||
:on-change="(file: any) => handleFieldUpload(fieldItem.field, file, 'upload')"
|
||
:file-list="[]"
|
||
:limit="1"
|
||
:show-file-list="false"
|
||
class="field-upload"
|
||
>
|
||
<el-button size="small" type="primary" :disabled="getFieldFileList(fieldItem.field).length >= 1"> 选择文件 </el-button>
|
||
</el-upload>
|
||
<!-- 手动显示已上传成功的文件列表 -->
|
||
<div v-if="getFieldFileList(fieldItem.field).length > 0" class="uploaded-files-list">
|
||
<div v-for="(uploadedFile, fileIdx) in getFieldFileList(fieldItem.field)" :key="fileIdx" class="uploaded-file-item">
|
||
<span class="file-name">{{ uploadedFile.name }}</span>
|
||
<el-button type="danger" link size="small" @click="removeFieldFile(fieldItem.field, fileIdx, 'upload')"> 删除 </el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 多个文件上传 -->
|
||
<div v-else-if="fieldItem.type === 'uploadMultiple'" class="field-upload-wrapper">
|
||
<el-upload
|
||
:key="`field-upload-${fieldItem.field}-${getFieldUploadKey(fieldItem.field)}`"
|
||
:auto-upload="false"
|
||
:multiple="true"
|
||
:on-change="(file: any) => handleFieldUpload(fieldItem.field, file, 'uploadMultiple')"
|
||
:file-list="[]"
|
||
:show-file-list="false"
|
||
class="field-upload"
|
||
>
|
||
<el-button size="small" type="primary">选择文件</el-button>
|
||
</el-upload>
|
||
<!-- 手动显示已上传成功的文件列表 -->
|
||
<div v-if="getFieldFileList(fieldItem.field).length > 0" class="uploaded-files-list">
|
||
<div v-for="(uploadedFile, fileIdx) in getFieldFileList(fieldItem.field)" :key="fileIdx" class="uploaded-file-item">
|
||
<span class="file-name">{{ uploadedFile.name }}</span>
|
||
<el-button type="danger" link size="small" @click="removeFieldFile(fieldItem.field, fileIdx, 'uploadMultiple')">
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 键值对输入(HTTP节点的headers和body) -->
|
||
<div v-else-if="fieldItem.type === 'keyValue'" class="key-value-input-wrapper">
|
||
<div v-for="(pair, pairIndex) in getKeyValuePairs(fieldItem.field)" :key="pairIndex" class="key-value-pair">
|
||
<el-input v-model="pair.key" placeholder="键" class="key-input" @input="updateKeyValueField(fieldItem.field)" />
|
||
<el-input v-model="pair.value" placeholder="值" class="value-input" @input="updateKeyValueField(fieldItem.field)" />
|
||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeKeyValuePair(fieldItem.field, pairIndex)" />
|
||
</div>
|
||
<el-button type="primary" link :icon="Plus" @click="addKeyValuePair(fieldItem.field)" class="add-pair-btn">
|
||
添加键值对
|
||
</el-button>
|
||
</div>
|
||
<!-- HTTP请求体配置(弹窗方式) -->
|
||
<div v-else-if="fieldItem.type === 'httpBody'" class="http-body-config-wrapper">
|
||
<el-button type="primary" @click="openHttpBodyDialog(fieldItem.field)" style="width: 100%">
|
||
<el-icon>
|
||
<Setting />
|
||
</el-icon>
|
||
配置请求体
|
||
</el-button>
|
||
<div v-if="getHttpBodyFieldCount(fieldItem.field) > 0" class="http-body-summary">
|
||
已配置 {{ getHttpBodyFieldCount(fieldItem.field) }} 个字段
|
||
</div>
|
||
</div>
|
||
<!-- 默认文本输入框 -->
|
||
<el-input
|
||
v-else
|
||
v-model="dynamicFormValues[fieldItem.field]"
|
||
:placeholder="fieldItem.field === 'callbackUrl' ? '' : fieldItem.required ? '必填' : '选填'"
|
||
/>
|
||
|
||
<div v-if="isHttpExpandTriggerField(fieldItem) && dynamicFormValues.responseType === 'pull'" class="http-body-config-wrapper">
|
||
<el-button type="primary" @click="openHttpExpandDialog(fieldItem)" style="width: 100%; margin-top: 10px">
|
||
<el-icon>
|
||
<Setting />
|
||
</el-icon>
|
||
配置主动拉取参数
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<!-- 上级节点参数选择(表单参数节点和开始节点除外) -->
|
||
<template v-if="canSelectParentParams(selectedElement)">
|
||
<el-divider content-position="left">引用上级参数</el-divider>
|
||
<!-- 显示已引用的参数(只显示有字段的节点) -->
|
||
<div v-if="currentInputSource && Array.isArray(currentInputSource) && currentInputSource.length > 0" class="input-source-list">
|
||
<div
|
||
v-for="(sourceNode, index) in currentInputSource.filter((n: any) => n.field && n.field.length > 0)"
|
||
:key="index"
|
||
class="input-source-item"
|
||
>
|
||
<div class="input-source-content">
|
||
<div class="input-source-label">
|
||
<span class="input-source-key"
|
||
>来自节点:{{ formatParamReference(`\${${sourceNode.nodeId}.field}`).split('.')[0] }}</span
|
||
>
|
||
</div>
|
||
<div v-for="fieldName in sourceNode.field" :key="fieldName" class="input-source-field">
|
||
<span class="input-source-field-name">{{ fieldName }}</span>
|
||
<el-button type="danger" link size="small" @click="removeInputSource(sourceNode.nodeId, fieldName)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-form-item label="选择参数">
|
||
<el-select v-model="selectedParentParam" placeholder="选择上级节点的参数" class="w100" @change="addParentParam">
|
||
<el-option v-for="param in availableParentFieldParams" :key="param.value" :label="param.label" :value="param.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</template>
|
||
<!-- 自定义表单项(判断节点、开始节点除外) -->
|
||
<template v-if="canAddCustomFields(selectedElement)">
|
||
<el-divider content-position="left">自定义字段</el-divider>
|
||
<div v-for="(customField, index) in customFields" :key="index" class="custom-field-config">
|
||
<el-input v-model="customField.label" placeholder="字段名" class="custom-field-input" />
|
||
<div class="custom-field-row">
|
||
<el-select v-model="customField.type" placeholder="类型" class="custom-field-type">
|
||
<el-option label="文本" value="input" />
|
||
<el-option label="数字" value="number" />
|
||
<el-option label="多行文本" value="textarea" />
|
||
<el-option label="开关" value="switch" />
|
||
<el-option label="文件上传" value="uploadMultiple" />
|
||
</el-select>
|
||
<el-checkbox v-model="customField.required" class="custom-field-required">必填</el-checkbox>
|
||
<el-button type="danger" link @click="removeCustomField(index)">删除</el-button>
|
||
</div>
|
||
<!-- 文件上传类型:只配置规则,不在此处上传 -->
|
||
<div v-if="customField.type === 'uploadMultiple'" class="custom-field-upload-wrapper">
|
||
<div class="http-body-rules-row" style="margin-top: 8px">
|
||
<el-input v-model="customField.fileTypes" placeholder="文件格式(如 .mp4,jpg,png)" class="rule-file-types" />
|
||
<el-input-number v-model="customField.maxFileSize" :min="1" placeholder="最大大小(MB)" class="rule-file-size" />
|
||
<el-input-number v-model="customField.maxFileCount" :min="1" placeholder="最大数量" class="rule-file-size" />
|
||
</div>
|
||
</div>
|
||
<!-- 非文件上传类型显示默认值输入框 -->
|
||
<el-input v-else v-model="customField.value" placeholder="默认值" class="custom-field-value-full" />
|
||
</div>
|
||
<el-button type="primary" link class="w100" @click="addCustomField">+ 添加自定义字段</el-button>
|
||
</template>
|
||
</template>
|
||
</el-form>
|
||
</div>
|
||
<div class="form-actions">
|
||
<el-button type="primary" size="large" class="apply-button" @click="applySelected">
|
||
<el-icon>
|
||
<Check />
|
||
</el-icon>
|
||
应用到当前元素
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
<div class="editor-shell">
|
||
<div class="main">
|
||
<!-- 创作模式:动态表单 -->
|
||
<div v-show="isCreationMode" class="creation-mode-container">
|
||
<div class="panel creation-main-panel">
|
||
<div class="creation-header">
|
||
<div>
|
||
<div class="title">{{ currentWorkflowForCreation?.flowName || '内容创作' }}</div>
|
||
<div class="sub">{{ currentWorkflowForCreation?.description || '填写表单参数进行内容创作' }}</div>
|
||
</div>
|
||
<div class="creation-header-actions">
|
||
<el-button @click="creationFormCollapsed = !creationFormCollapsed">
|
||
{{ creationFormCollapsed ? '展开表单' : '收起表单' }}
|
||
</el-button>
|
||
<el-button @click="backToCanvas">返回画布</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="creation-middle"
|
||
:class="{ 'form-collapsed': creationFormCollapsed }"
|
||
:style="
|
||
creationFormCollapsed
|
||
? undefined
|
||
: { gridTemplateRows: `${formPanelHeightPercent}% 8px minmax(0, calc(100% - ${formPanelHeightPercent}% - 8px))` }
|
||
"
|
||
>
|
||
<div v-show="!creationFormCollapsed" class="creation-form-panel">
|
||
<div class="simple-form-scroll">
|
||
<el-form label-position="top" class="simple-creation-form creation-form-grid">
|
||
<template v-if="currentWorkflowForCreation?.nodeInputParams">
|
||
<template v-for="node in currentWorkflowForCreation.nodeInputParams">
|
||
<template v-if="node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0">
|
||
<el-form-item
|
||
v-for="field in getCreationVisibleFields(node)"
|
||
:key="getCreationFieldKey(node, field)"
|
||
:label="field.label"
|
||
:required="field.required"
|
||
:class="getCreationFieldLayoutClass(field)"
|
||
>
|
||
<el-input
|
||
v-if="field.type === 'input' || field.type === 'string'"
|
||
v-model="creationFormValues[getCreationFieldKey(node, field)]"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
:disabled="isFromWorkspace"
|
||
clearable
|
||
/>
|
||
<el-input-number
|
||
v-else-if="field.type === 'number'"
|
||
v-model="creationFormValues[getCreationFieldKey(node, field)]"
|
||
class="w100"
|
||
:controls="true"
|
||
:min="getCreationNumberMin(field)"
|
||
:max="getCreationNumberMax(field)"
|
||
:disabled="isFromWorkspace"
|
||
/>
|
||
<el-input
|
||
v-else-if="field.type === 'textarea'"
|
||
v-model="creationFormValues[getCreationFieldKey(node, field)]"
|
||
type="textarea"
|
||
:rows="3"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
:disabled="isFromWorkspace"
|
||
show-word-limit
|
||
:maxlength="500"
|
||
/>
|
||
<el-switch
|
||
v-else-if="field.type === 'switch'"
|
||
v-model="creationFormValues[getCreationFieldKey(node, field)]"
|
||
active-text="开启"
|
||
inactive-text="关闭"
|
||
:disabled="isFromWorkspace"
|
||
/>
|
||
<div v-else-if="isCreationFileField(field)" class="field-upload-wrapper creation-upload-card">
|
||
<div class="creation-upload-top">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:multiple="true"
|
||
:show-file-list="false"
|
||
:accept="getCreationFileAccept(field)"
|
||
:on-change="(file: any) => handleCreationFieldUpload(node, field, file)"
|
||
>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="isCreationFieldUploading(node, field)"
|
||
:disabled="isFromWorkspace || isCreationFieldUploading(node, field)"
|
||
>{{ isCreationFieldUploading(node, field) ? '上传中...' : '选择文件' }}</el-button
|
||
>
|
||
</el-upload>
|
||
</div>
|
||
<div class="creation-upload-tags">
|
||
<span class="creation-upload-tag rule">{{ getCreationFileRuleText(field) }}</span>
|
||
<span class="creation-upload-tag count">已上传 {{ getCreationFileCountText(node, field) }}</span>
|
||
</div>
|
||
<div v-if="getCreationFieldFiles(node, field).length > 0" class="uploaded-files-list creation-upload-list">
|
||
<div
|
||
v-for="(uploadedFile, fileIdx) in getCreationFieldFiles(node, field)"
|
||
:key="fileIdx"
|
||
class="uploaded-file-item creation-upload-item"
|
||
>
|
||
<span class="file-name">{{ uploadedFile.name }}</span>
|
||
<el-button
|
||
type="danger"
|
||
link
|
||
size="small"
|
||
:disabled="isFromWorkspace"
|
||
@click="removeCreationFieldFile(node, field, fileIdx)"
|
||
>
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
<el-empty v-else description="暂无表单配置" :image-size="80" />
|
||
</el-form>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="!creationFormCollapsed" class="middle-splitter" @mousedown="handleMiddleSplitterMouseDown">
|
||
<div class="middle-splitter-line"></div>
|
||
</div>
|
||
|
||
<div class="panel creation-history-panel">
|
||
<div class="history-header">历史对话</div>
|
||
<div class="history-list-placeholder">
|
||
<div class="history-item assistant">
|
||
<div class="role">助手</div>
|
||
<div class="bubble">这里展示历史对话内容(样式占位,功能待定)。</div>
|
||
</div>
|
||
<div class="history-item user">
|
||
<div class="role">我</div>
|
||
<div class="bubble">收起上方表单后,此区域可完整展示历史对话。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="creation-input-area">
|
||
<div v-if="selectedFiles.length > 0" class="selected-files-top">
|
||
<el-tag v-for="(file, index) in selectedFiles" :key="index" closable @close="removeFile(index)" type="info" size="small">
|
||
{{ file.name }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<div class="chat-input-container">
|
||
<div class="input-tools-left">
|
||
<el-upload :auto-upload="false" :show-file-list="false" :on-change="handleFileSelect" multiple>
|
||
<el-button text :icon="Paperclip" class="tool-btn" />
|
||
</el-upload>
|
||
<el-button text :icon="MagicStick" @click="showCreationSkillSelector = true" class="tool-btn" />
|
||
</div>
|
||
|
||
<el-input v-model="userInput" placeholder="说点什么..." class="chat-input" @keydown.enter="sendMessage" />
|
||
|
||
<el-button v-if="!isCreating" type="primary" :icon="Promotion" @click="sendMessage" class="send-btn" circle />
|
||
<el-button v-else type="danger" :icon="VideoPause" @click="stopExecution" class="send-btn" circle />
|
||
</div>
|
||
|
||
<div v-if="selectedCreationSkill" class="selected-skill-bottom">
|
||
<el-tag type="success" closable @close="selectedCreationSkill = null" size="small"> 技能: {{ selectedCreationSkill.name }} </el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 画布编辑模式 -->
|
||
<div v-show="!isCreationMode" class="panel canvas-panel">
|
||
<div class="meta">
|
||
<div class="meta-left">
|
||
<span class="meta-title">工作流画布</span>
|
||
<span class="meta-info">节点 {{ flowDsl.nodes.length }} / 连线 {{ flowDsl.edges.length }}</span>
|
||
</div>
|
||
<div class="meta-actions">
|
||
<el-button size="small" @click="resetFlow">清空画布</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="danger"
|
||
:disabled="
|
||
!selectedElement ||
|
||
(selectedElement.kind === 'node' &&
|
||
(selectedElement.properties?.nodeCode === START_NODE_CODE || selectedElement.text === START_NODE_TEXT))
|
||
"
|
||
@click="deleteSelectedElement"
|
||
>
|
||
删除选中
|
||
</el-button>
|
||
<el-button type="primary" size="small" @click="saveWorkflowAction" :loading="saving">保存工作流</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="canvas-layout">
|
||
<div class="node-library">
|
||
<div class="title-sm">节点库</div>
|
||
<el-empty v-if="nodeLibraryGroups.length === 0" description="暂无节点" :image-size="60" />
|
||
<div v-else class="node-library-groups">
|
||
<div v-for="group in nodeLibraryGroups" :key="group.group" class="node-group">
|
||
<div class="node-group-title">{{ group.label }}</div>
|
||
<div class="node-group-items">
|
||
<el-button
|
||
v-for="item in group.items"
|
||
:key="item.nodeCode"
|
||
text
|
||
class="node-item"
|
||
@click="addNodeFromLibrary(item.nodeCode, item.nodeName)"
|
||
>
|
||
{{ item.nodeName }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="canvas-wrap">
|
||
<div ref="logicFlowRef" class="logicflow-canvas"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:工作流列表(竖状) -->
|
||
<div class="panel right-panel">
|
||
<el-tabs v-model="workflowTab" class="workflow-tabs">
|
||
<!-- Tab 1: 我的工作流 -->
|
||
<el-tab-pane label="我的工作流" name="user">
|
||
<div class="right-panel-header">
|
||
<el-button type="success" size="small" @click="createNewWorkflow">新建</el-button>
|
||
<!-- <el-button type="primary" link size="small" @click="refreshWorkflowList">刷新</el-button> -->
|
||
</div>
|
||
<div class="workflow-list-vertical" v-loading="workflowListLoading">
|
||
<el-empty v-if="!workflowListLoading && userWorkflowList.length === 0" description="暂无工作流" :image-size="60" />
|
||
<div v-else class="workflow-list-scroll">
|
||
<div
|
||
v-for="workflow in userWorkflowList"
|
||
:key="workflow.id"
|
||
class="workflow-item"
|
||
:class="{ active: currentEditingWorkflowId === workflow.id }"
|
||
@click="isSuperAdmin ? editWorkflow(workflow) : useWorkflow(workflow)"
|
||
>
|
||
<div class="workflow-item-content">
|
||
<div class="workflow-item-name">{{ workflow.flowName }}</div>
|
||
<div class="workflow-item-desc">{{ workflow.description || '暂无描述' }}</div>
|
||
</div>
|
||
<div class="workflow-item-actions">
|
||
<el-button type="primary" link size="small" @click.stop="editWorkflow(workflow)">编辑</el-button>
|
||
<el-button v-if="!isSuperAdmin" type="success" link size="small" @click.stop="useWorkflow(workflow)">使用</el-button>
|
||
<el-button type="danger" link size="small" @click.stop="deleteWorkflowAction(workflow)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 分页 -->
|
||
<div v-if="userWorkflowPagination.total > 0" class="workflow-pagination">
|
||
<el-pagination
|
||
v-model:current-page="userWorkflowPagination.pageNum"
|
||
:page-size="userWorkflowPagination.pageSize"
|
||
:total="userWorkflowPagination.total"
|
||
layout="prev, pager, next"
|
||
@current-change="handleUserPageChange"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- Tab 2: 模板工作流 -->
|
||
<el-tab-pane label="模板工作流" name="template">
|
||
<div class="right-panel-header">
|
||
<el-button v-if="isAdmin" type="success" size="small" @click="createNewWorkflow">新建</el-button>
|
||
<!-- <el-button type="primary" link size="small" @click="refreshWorkflowList">刷新</el-button> -->
|
||
</div>
|
||
<div class="workflow-list-vertical" v-loading="workflowListLoading">
|
||
<el-empty v-if="!workflowListLoading && templateWorkflowList.length === 0" description="暂无模板" :image-size="60" />
|
||
<div v-else class="workflow-list-scroll">
|
||
<div
|
||
v-for="workflow in templateWorkflowList"
|
||
:key="workflow.id"
|
||
class="workflow-item"
|
||
@click="isSuperAdmin ? editWorkflow(workflow) : useWorkflow(workflow)"
|
||
>
|
||
<div class="workflow-item-content">
|
||
<div class="workflow-item-name">{{ workflow.flowName || workflow.flowTemplateName }}</div>
|
||
<div class="workflow-item-desc">{{ workflow.description || '暂无描述' }}</div>
|
||
</div>
|
||
<div class="workflow-item-actions">
|
||
<el-button type="primary" link size="small" @click.stop="editWorkflow(workflow)">编辑</el-button>
|
||
<el-button v-if="!isSuperAdmin" type="success" link size="small" @click.stop="useWorkflow(workflow)">使用</el-button>
|
||
<el-button v-if="isAdmin" type="danger" link size="small" @click.stop="deleteWorkflowAction(workflow)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 分页 -->
|
||
<div v-if="templateWorkflowPagination.total > 0" class="workflow-pagination">
|
||
<el-pagination
|
||
v-model:current-page="templateWorkflowPagination.pageNum"
|
||
:page-size="templateWorkflowPagination.pageSize"
|
||
:total="templateWorkflowPagination.total"
|
||
layout="prev, pager, next"
|
||
@current-change="handleTemplatePageChange"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- 保存工作流对话框 -->
|
||
<SaveWorkflowDialog
|
||
v-model="saveDialogVisible"
|
||
:save-form="saveForm"
|
||
:current-editing-workflow-id="currentEditingWorkflowId"
|
||
:saving="saving"
|
||
@confirm="confirmSaveWorkflow"
|
||
/>
|
||
|
||
<!-- 提示词选择器 -->
|
||
<PromptSelector v-model="showPromptSelector" :default-prompt="promptContent" @confirm="handlePromptConfirm" />
|
||
|
||
<!-- 技能选择器 -->
|
||
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
|
||
|
||
<!-- 创作技能选择器 -->
|
||
<SkillSelector v-model="showCreationSkillSelector" :default-skill="selectedCreationSkill" @confirm="handleCreationSkillConfirm" />
|
||
|
||
<!-- 模型选择器 -->
|
||
<ModelSelector v-model="showModelSelector" :default-model="selectedModelData" :model-type="currentNodeModelType" @confirm="handleModelConfirm" />
|
||
|
||
<!-- 对话模型选择器 -->
|
||
<el-dialog v-model="showChatModelSelector" title="设置对话模型" width="900px" :close-on-click-modal="false">
|
||
<div class="chat-model-selector">
|
||
<div class="chat-model-search">
|
||
<el-input v-model="chatModelSearchKeyword" placeholder="搜索模型名称" clearable @clear="handleChatModelSearch">
|
||
<template #prefix
|
||
><el-icon> <Search /> </el-icon
|
||
></template>
|
||
</el-input>
|
||
<el-button type="primary" @click="handleChatModelSearch">搜索</el-button>
|
||
</div>
|
||
<div class="chat-model-list" v-loading="chatModelLoading">
|
||
<el-empty v-if="!chatModelLoading && filteredChatModels.length === 0" description="暂无推理模型" :image-size="100" />
|
||
<div v-else class="chat-model-grid">
|
||
<div
|
||
v-for="model in filteredChatModels"
|
||
:key="model.id"
|
||
class="chat-model-card"
|
||
:class="{ selected: selectedChatModel?.id === model.id, 'is-chat-model': model.isChatModel === 1 }"
|
||
@click="selectedChatModel = model"
|
||
>
|
||
<div class="chat-model-name">{{ model.modelName }}</div>
|
||
<div class="chat-model-url">{{ model.baseUrl }}</div>
|
||
<el-icon v-if="selectedChatModel?.id === model.id || model.isChatModel === 1" class="check-icon" color="#67c23a">
|
||
<CircleCheck />
|
||
</el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="chatModelPagination.total > 0" class="chat-model-pagination">
|
||
<el-pagination
|
||
v-model:current-page="chatModelPagination.pageNum"
|
||
:page-size="chatModelPagination.pageSize"
|
||
:total="chatModelPagination.total"
|
||
layout="total, prev, pager, next"
|
||
@current-change="handleChatModelPageChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="showChatModelSelector = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSetChatModel" :disabled="!selectedChatModel" :loading="settingChatModel">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 内置对话模型 API Key 输入弹窗 -->
|
||
<el-dialog v-model="chatModelApiKeyDialogVisible" 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="chatModelApiKeyForm" :rules="chatModelApiKeyRules" ref="chatModelApiKeyFormRef" label-width="100px">
|
||
<el-form-item label="模型名称" prop="modelName">
|
||
<el-input v-model="chatModelApiKeyForm.modelName" placeholder="请输入模型名称" />
|
||
</el-form-item>
|
||
<el-form-item label="API Key" prop="apiKey">
|
||
<el-input v-model="chatModelApiKeyForm.apiKey" type="password" show-password placeholder="请输入您的 API Key" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="chatModelApiKeyDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleCreateChatModelFromBuiltIn" :loading="creatingChatModel">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- HTTP主动拉取参数配置弹窗 -->
|
||
<el-dialog v-model="showHttpExpandDialog" title="配置主动拉取参数" width="900px" :close-on-click-modal="false" destroy-on-close>
|
||
<div class="http-body-dialog-content">
|
||
<el-form label-position="top">
|
||
<el-form-item v-for="field in currentHttpExpandFields" :key="field.field" :label="field.label" :required="field.required">
|
||
<el-select v-if="field.type === 'select' || isSelectField(field)" v-model="httpExpandFormValues[field.field]" class="w100">
|
||
<el-option v-for="opt in getSelectOptions(field)" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
<el-input-number
|
||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||
v-model="httpExpandFormValues[field.field]"
|
||
class="w100"
|
||
/>
|
||
<el-switch v-else-if="field.type === 'switch'" v-model="httpExpandFormValues[field.field]" />
|
||
<div v-else-if="field.type === 'keyValue'" class="key-value-input-wrapper">
|
||
<div v-for="(pair, pairIndex) in getHttpExpandKeyValuePairs(field.field)" :key="pairIndex" class="key-value-pair">
|
||
<el-input v-model="pair.key" placeholder="键" class="key-input" @input="updateHttpExpandKeyValueField(field.field)" />
|
||
<el-input v-model="pair.value" placeholder="值" class="value-input" @input="updateHttpExpandKeyValueField(field.field)" />
|
||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeHttpExpandKeyValuePair(field.field, pairIndex)" />
|
||
</div>
|
||
<el-button type="primary" link :icon="Plus" @click="addHttpExpandKeyValuePair(field.field)">添加键值对</el-button>
|
||
</div>
|
||
<div v-else-if="field.type === 'httpBody'" class="http-body-config-wrapper">
|
||
<el-button type="primary" @click="openHttpBodyDialog(field.field)">配置请求体</el-button>
|
||
<div v-if="getHttpBodyFieldCount(field.field) > 0" class="http-body-summary">
|
||
已配置 {{ getHttpBodyFieldCount(field.field) }} 个字段
|
||
</div>
|
||
</div>
|
||
<el-input v-else v-model="httpExpandFormValues[field.field]" />
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="showHttpExpandDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmHttpExpandDialog">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- HTTP请求体配置弹窗 -->
|
||
<el-dialog v-model="showHttpBodyDialog" title="配置请求体" width="980px" :close-on-click-modal="false" destroy-on-close>
|
||
<div class="http-body-dialog-content">
|
||
<div class="http-body-fields-list">
|
||
<div v-for="(fieldValue, fieldKey) in getHttpBodyData(currentHttpBodyField)" :key="fieldKey" class="http-body-field-item">
|
||
<div class="http-body-main-row">
|
||
<el-input v-model="fieldValue.key" placeholder="键名" class="http-body-key" />
|
||
<el-switch
|
||
v-model="fieldValue.showInForm"
|
||
active-text="在表单显示"
|
||
class="http-body-switch"
|
||
@change="(val: boolean) => handleHttpBodyShowInFormChange(fieldValue, val)"
|
||
/>
|
||
<el-button type="danger" :icon="Delete" circle @click="deleteHttpBodyField(String(fieldKey))" />
|
||
</div>
|
||
|
||
<div v-if="!fieldValue.showInForm" class="http-body-value-row">
|
||
<el-select
|
||
v-model="fieldValue.value"
|
||
filterable
|
||
allow-create
|
||
default-first-option
|
||
clearable
|
||
placeholder="值(可手动输入或选择上级字段)"
|
||
class="w100"
|
||
>
|
||
<el-option
|
||
v-for="param in getHttpBodyReferenceOptions(fieldValue.value)"
|
||
:key="param.value"
|
||
:label="param.label"
|
||
:value="param.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div v-else class="http-body-rules-row">
|
||
<el-select v-model="fieldValue.fieldType" placeholder="类型" class="http-body-type">
|
||
<el-option label="字符串" value="string" />
|
||
<el-option label="数字" value="number" />
|
||
<el-option label="文件上传" value="fileUpload" />
|
||
</el-select>
|
||
<template v-if="fieldValue.fieldType === 'string'">
|
||
<el-input-number v-model="fieldValue.fieldConstraint.minLength" :min="0" placeholder="最小长度" class="rule-num" />
|
||
<span class="rule-sep">-</span>
|
||
<el-input-number v-model="fieldValue.fieldConstraint.maxLength" :min="0" placeholder="最大长度" class="rule-num" />
|
||
</template>
|
||
<template v-else-if="fieldValue.fieldType === 'number'">
|
||
<el-select v-model="fieldValue.fieldConstraint.numberType" placeholder="数字类型" class="rule-type">
|
||
<el-option label="整数" value="integer" />
|
||
<el-option label="小数" value="decimal" />
|
||
</el-select>
|
||
<el-input-number v-model="fieldValue.fieldConstraint.minValue" placeholder="最小值" class="rule-num" />
|
||
<span class="rule-sep">-</span>
|
||
<el-input-number v-model="fieldValue.fieldConstraint.maxValue" placeholder="最大值" class="rule-num" />
|
||
</template>
|
||
<template v-else-if="fieldValue.fieldType === 'fileUpload'">
|
||
<el-input v-model="fieldValue.fieldConstraint.fileTypes" placeholder="文件格式(如 jpg,png,pdf)" class="rule-file-types" />
|
||
<el-input-number v-model="fieldValue.fieldConstraint.maxFileSize" :min="0" placeholder="最大大小(MB)" class="rule-file-size" />
|
||
<el-input-number v-model="fieldValue.fieldConstraint.maxFileCount" :min="1" placeholder="最大数量" class="rule-file-size" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-button type="primary" :icon="Plus" @click="addHttpBodyField" class="http-body-add-btn">添加字段</el-button>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="showHttpBodyDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmHttpBodyConfig">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<!-- 预览弹窗 -->
|
||
<el-dialog v-model="previewDialogVisible" title="预览" width="95%" top="2vh" :close-on-click-modal="false" destroy-on-close>
|
||
<div class="preview-container">
|
||
<el-image v-if="previewUrl && previewMode === 'image'" :src="previewUrl" fit="contain" style="width: 100%; height: 100%" />
|
||
<video
|
||
v-else-if="previewUrl && previewMode === 'video'"
|
||
:src="previewUrl"
|
||
controls
|
||
style="width: 100%; height: 100%; background: #000"
|
||
></video>
|
||
<audio v-else-if="previewUrl && previewMode === 'audio'" :src="previewUrl" controls style="width: 100%"></audio>
|
||
<iframe v-else-if="previewUrl" :src="previewUrl" class="preview-iframe" frameborder="0"></iframe>
|
||
<el-empty v-else description="无法加载预览内容" />
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { Plus, Paperclip, MagicStick, Promotion, Check, Setting, Search, CircleCheck, VideoPause, Delete } from '@element-plus/icons-vue';
|
||
import LogicFlow from '@logicflow/core';
|
||
import { Control, SelectionSelect } from '@logicflow/extension';
|
||
import '@logicflow/core/dist/index.css';
|
||
import '@logicflow/extension/lib/style/index.css';
|
||
import SkillSelector from '/@/components/skill/NodeSkillSelector.vue';
|
||
import ModelSelector from '/@/components/model/ModelSelector.vue';
|
||
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
|
||
import PromptSelector from './component/PromptSelector.vue';
|
||
import type { SkillItem } from '/@/api/settings/skill';
|
||
import {
|
||
downloadToFile,
|
||
getExecutionList,
|
||
getNodeLibraryList,
|
||
getWorkflowList,
|
||
getWorkflowDetail,
|
||
getExecutionDetail,
|
||
updateWorkflow,
|
||
deleteWorkflow,
|
||
saveWorkflow,
|
||
executeFlow,
|
||
type ExecutionTreeItem,
|
||
type NodeLibraryFormItem,
|
||
type NodeLibraryGroup,
|
||
type WorkflowItem,
|
||
type ExecuteFlowParams,
|
||
} from '/@/api/settings/creation';
|
||
import { uploadFile } from '/@/api/common/upload';
|
||
import { getModelModuleList, updateChatModel, getIsChatModel, addModelModule } from '/@/api/settings/modelConfig/modelModule';
|
||
import { checkIsSuperAdmin } from '/@/api/system/user';
|
||
|
||
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
|
||
type Item = Record<string, any>;
|
||
const START_NODE_CODE = '__start__';
|
||
const START_NODE_TEXT = '开始';
|
||
const JUDGE_KEYWORDS = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'];
|
||
interface TreeNode {
|
||
id: string;
|
||
label: string;
|
||
nodeType: NodeType;
|
||
children?: TreeNode[];
|
||
fileUrl?: string;
|
||
workflowId?: number | string;
|
||
fileType?: string;
|
||
sessionId?: string;
|
||
}
|
||
interface SelectedState {
|
||
id: string;
|
||
type: string;
|
||
kind: 'node' | 'edge';
|
||
properties: Item;
|
||
text?: string;
|
||
}
|
||
const treeLoading = ref(false);
|
||
const treeNodes = ref<TreeNode[]>([]);
|
||
const imgAddressPrefix = ref('');
|
||
const selectedElement = ref<SelectedState | null>(null);
|
||
const customFields = ref<
|
||
Array<{
|
||
label: string;
|
||
value: string;
|
||
type: string;
|
||
required: boolean;
|
||
fileList?: Array<{ name: string; url: string }>;
|
||
uploadKey?: number;
|
||
fileTypes?: string;
|
||
maxFileSize?: number;
|
||
maxFileCount?: number;
|
||
}>
|
||
>([]);
|
||
const outputConfigFields = ref<Array<{ field: string; value: string }>>([]);
|
||
const selectedParentParam = ref('');
|
||
const selectedModel = ref('');
|
||
const showSkillSelector = ref(false);
|
||
const selectedSkill = ref<SkillItem | null>(null);
|
||
const showPromptSelector = ref(false);
|
||
const promptContent = ref('');
|
||
const isSaveFileEnabled = ref(false);
|
||
const saving = ref(false);
|
||
const leftPanelTab = ref('selected'); // 默认显示"当前选中"Tab
|
||
const saveDialogVisible = ref(false);
|
||
const saveForm = reactive({
|
||
flowName: '',
|
||
description: '',
|
||
});
|
||
const workflowListLoading = ref(false);
|
||
const currentEditingWorkflowId = ref<string | null>(null);
|
||
const isCreationMode = ref(false); // 是否处于创作模式
|
||
const currentWorkflowForCreation = ref<any>(null); // 当前用于创作的工作流数据
|
||
const creationFormValues = reactive<Record<string, any>>({}); // 创作表单的值
|
||
const workflowTab = ref('user'); // 工作流 Tab:user 或 template
|
||
const userWorkflowList = ref<WorkflowItem[]>([]); // 用户工作流列表
|
||
const templateWorkflowList = ref<WorkflowItem[]>([]); // 模板工作流列表
|
||
const isAdmin = ref(false); // 是否为管理员
|
||
const isSuperAdmin = ref(false); // 是否为超级管理员(管理员只能编辑,不能创作)
|
||
const userWorkflowPagination = reactive({
|
||
pageNum: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
});
|
||
const templateWorkflowPagination = reactive({
|
||
pageNum: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
});
|
||
// AI 创作输入相关状态
|
||
const userInput = ref('');
|
||
const selectedFiles = ref<File[]>([]);
|
||
const selectedCreationSkill = ref<SkillItem | null>(null);
|
||
const showCreationSkillSelector = ref(false);
|
||
const currentSessionId = ref<string | null>(null); // 当前会话的 sessionId(从工作空间进入时使用)
|
||
const isFromWorkspace = ref(false); // 是否从工作空间进入创作模式
|
||
const isCreating = ref(false);
|
||
const creationFormCollapsed = ref(false);
|
||
const formPanelHeightPercent = ref(50);
|
||
const isDraggingMiddleSplitter = ref(false);
|
||
// 预览相关状态
|
||
const previewDialogVisible = ref(false);
|
||
const previewUrl = ref('');
|
||
const previewMode = ref<'iframe' | 'image' | 'video' | 'audio'>('iframe');
|
||
// 模型选择器相关状态
|
||
const showModelSelector = ref(false);
|
||
const selectedModelData = ref<any>(null);
|
||
// 对话模型选择器相关状态
|
||
// HTTP请求体配置相关状态
|
||
const showHttpBodyDialog = ref(false);
|
||
const currentHttpBodyField = ref('');
|
||
const httpBodyData = reactive<Record<string, any>>({});
|
||
const showChatModelSelector = ref(false);
|
||
const selectedChatModel = ref<any>(null);
|
||
const chatModelList = ref<any[]>([]);
|
||
const chatModelLoading = ref(false);
|
||
const chatModelSearchKeyword = ref('');
|
||
const settingChatModel = ref(false);
|
||
const chatModelPagination = reactive({
|
||
pageNum: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
});
|
||
// 内置对话模型 API Key 配置
|
||
const chatModelApiKeyDialogVisible = ref(false);
|
||
const chatModelApiKeyFormRef = ref<any>(null);
|
||
const chatModelApiKeyForm = reactive({
|
||
modelName: '',
|
||
apiKey: '',
|
||
});
|
||
const chatModelApiKeyRules = {
|
||
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
|
||
apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
|
||
};
|
||
const creatingChatModel = ref(false);
|
||
const builtInChatModelToClone = ref<any>(null);
|
||
const filteredChatModels = computed(() => {
|
||
return chatModelList.value;
|
||
});
|
||
// 会话ID管理(每次使用工作流时生成新的 sessionId)
|
||
const getSessionId = () => {
|
||
// 如果从工作空间进入,使用当前会话的 sessionId
|
||
if (currentSessionId.value) {
|
||
return currentSessionId.value;
|
||
}
|
||
// 否则生成新的 sessionId
|
||
const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
return sessionId;
|
||
};
|
||
// 格式化参数引用显示
|
||
const formatParamReference = (value: string) => {
|
||
// 从 ${nodeId.field} 提取节点名和字段名
|
||
const match = value.match(/\$\{(.+?)\.(.+?)\}/);
|
||
if (!match) return value;
|
||
|
||
const nodeId = match[1];
|
||
const field = match[2];
|
||
|
||
// 查找节点名称
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return `${field}`;
|
||
|
||
const graphData = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
|
||
const node = (graphData.nodes || []).find((n: any) => n.id === nodeId);
|
||
const nodeName = node ? (typeof node.text === 'string' ? node.text : node.text?.value || nodeId) : nodeId;
|
||
|
||
return `${nodeName}.${field}`;
|
||
};
|
||
// 当前节点的 inputSource
|
||
const currentInputSource = computed(() => {
|
||
const inputSource = selectedElement.value?.properties?.inputSource;
|
||
return Array.isArray(inputSource) ? inputSource : [];
|
||
});
|
||
const flowDsl = ref<{ nodes: Item[]; edges: Item[] }>({ nodes: [], edges: [] });
|
||
const logicFlowRef = ref<HTMLDivElement | null>(null);
|
||
const logicFlowInstance = ref<LogicFlow | null>(null);
|
||
const nodeSpawnIndex = ref(0);
|
||
const formState = reactive({ text: '', nodeCode: '', field: '' });
|
||
const dynamicFormValues = reactive<Record<string, any>>({ modelApiKey: '' });
|
||
const showHttpExpandDialog = ref(false);
|
||
const currentHttpExpandFields = ref<any[]>([]);
|
||
const httpExpandFormValues = reactive<Record<string, any>>({});
|
||
const httpExpandKeyValuePairs = reactive<Record<string, Array<{ key: string; value: string }>>>({});
|
||
const nodeSchemaMap = computed(() => {
|
||
const map: Record<string, NodeLibraryFormItem[]> = {};
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
(group.items || []).forEach((item) => {
|
||
map[item.nodeCode] = item.formConfig || [];
|
||
});
|
||
});
|
||
return map;
|
||
});
|
||
const currentNodeForm = computed<NodeLibraryFormItem[]>(() => nodeSchemaMap.value[formState.nodeCode] || []);
|
||
// 获取当前节点的模型配置
|
||
const currentNodeModelConfig = computed(() => {
|
||
let modelConfigs: any[] = [];
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
(group.items || []).forEach((item) => {
|
||
if (item.nodeCode === formState.nodeCode) {
|
||
modelConfigs = item.modelConfig || [];
|
||
}
|
||
});
|
||
});
|
||
return modelConfigs;
|
||
});
|
||
// 获取当前节点是否支持技能选择
|
||
const currentNodeSkillOption = computed(() => {
|
||
let skillOption = false;
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
(group.items || []).forEach((item) => {
|
||
if (item.nodeCode === formState.nodeCode) {
|
||
skillOption = item.skillOption || false;
|
||
}
|
||
});
|
||
});
|
||
return skillOption;
|
||
});
|
||
// 获取当前节点是否支持提示词选择
|
||
const currentNodePromptOption = computed(() => {
|
||
let promptOption = false;
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
(group.items || []).forEach((item) => {
|
||
if (item.nodeCode === formState.nodeCode) {
|
||
promptOption = item.promptOption || false;
|
||
}
|
||
});
|
||
});
|
||
return promptOption;
|
||
});
|
||
// 获取当前节点是否支持对话模式
|
||
const currentNodeisSaveFile = computed(() => {
|
||
let isSaveFile = false;
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
(group.items || []).forEach((item) => {
|
||
if (item.nodeCode === formState.nodeCode) {
|
||
isSaveFile = item.isSaveFile || false;
|
||
}
|
||
});
|
||
});
|
||
return isSaveFile;
|
||
});
|
||
// 获取当前节点的模型类型
|
||
const currentNodeModelType = computed(() => {
|
||
const currentNodeCode = String(formState.nodeCode || '').trim();
|
||
if (!currentNodeCode) return 0;
|
||
|
||
for (const group of nodeLibraryGroups.value) {
|
||
for (const item of group.items || []) {
|
||
if (item.nodeCode === currentNodeCode) {
|
||
const mt = Number(item.modelType ?? 0);
|
||
return Number.isNaN(mt) ? 0 : mt;
|
||
}
|
||
}
|
||
}
|
||
return 0;
|
||
});
|
||
// 获取当前选中模型的表单字段
|
||
const currentModelForm = computed<NodeLibraryFormItem[]>(() => {
|
||
// 不显示模型的表单字段,返回空数组
|
||
return [];
|
||
});
|
||
// 合并基础表单和模型表单
|
||
const allFormFields = computed<NodeLibraryFormItem[]>(() => {
|
||
return [...currentNodeForm.value, ...currentModelForm.value];
|
||
});
|
||
// 过滤后应该显示的字段列表
|
||
const visibleFormFields = computed<NodeLibraryFormItem[]>(() => {
|
||
return allFormFields.value.filter((fieldItem) => {
|
||
if (fieldItem.field !== 'callbackUrl') return true;
|
||
const returnType = dynamicFormValues.responseType;
|
||
return returnType === 'callback' || returnType === '等候回调' || returnType === '等待回调';
|
||
});
|
||
});
|
||
const isParameterConversionNode = computed(() => String(formState.nodeCode || '').toLowerCase() === 'data_conversion_model');
|
||
// 获取可用的上级节点参数
|
||
const availableParentParams = computed(() => {
|
||
if (!selectedElement.value) return [];
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return [];
|
||
|
||
const graphData = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
|
||
const edges = graphData.edges || [];
|
||
const nodes = graphData.nodes || [];
|
||
|
||
// 递归查找所有上级节点
|
||
const findAllParentNodes = (nodeId: string, visited = new Set<string>()): string[] => {
|
||
if (visited.has(nodeId)) return [];
|
||
visited.add(nodeId);
|
||
|
||
const incomingEdges = edges.filter((e) => e.targetNodeId === nodeId);
|
||
const parentIds: string[] = [];
|
||
|
||
incomingEdges.forEach((edge) => {
|
||
parentIds.push(edge.sourceNodeId);
|
||
// 递归查找上级的上级
|
||
parentIds.push(...findAllParentNodes(edge.sourceNodeId, visited));
|
||
});
|
||
|
||
return parentIds;
|
||
};
|
||
|
||
const allParentIds = findAllParentNodes(selectedElement.value.id);
|
||
const params: Array<{ label: string; value: string }> = [];
|
||
const pushParentParam = (label: string, value: string) => {
|
||
if (!label || !value) return;
|
||
if (params.some((item) => item.value === value)) return;
|
||
params.push({ label, value });
|
||
};
|
||
|
||
// 遍历所有上级节点
|
||
allParentIds.forEach((parentId) => {
|
||
const parentNode = nodes.find((n) => n.id === parentId);
|
||
if (!parentNode) return;
|
||
|
||
const parentNodeName = typeof parentNode.text === 'string' ? parentNode.text : parentNode.text?.value || '';
|
||
const parentProps = parentNode.properties || {};
|
||
|
||
// 判断是否为判断节点
|
||
const nodeCode = String(parentProps.nodeCode || '').toLowerCase();
|
||
const nodeText = parentNodeName.toLowerCase();
|
||
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeText.includes(k));
|
||
|
||
// 如果是判断节点,跳过不添加其字段
|
||
if (isJudge) return;
|
||
|
||
pushParentParam(`${parentNodeName}.输出结果`, `\${${parentId}.nodeOutputResult}`);
|
||
|
||
const modelOutputFields = Array.isArray(parentProps.modelOutputFields) ? parentProps.modelOutputFields : [];
|
||
if (modelOutputFields.length > 0) {
|
||
modelOutputFields.forEach((field: any) => {
|
||
const fieldName = String(field || '').trim();
|
||
if (!fieldName) return;
|
||
pushParentParam(`${parentNodeName}.${fieldName}`, `\${${parentId}.${fieldName}}`);
|
||
});
|
||
}
|
||
|
||
// 只添加可引用字段(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;
|
||
pushParentParam(`${parentNodeName}.${key}`, `\${${parentId}.${key}}`);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
parentProps.formConfig.forEach((field: any) => {
|
||
if (field.label) {
|
||
pushParentParam(`${parentNodeName}.${field.label}`, `\${${parentId}.${field.label}}`);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
return params;
|
||
});
|
||
const availableParentFieldParams = computed(() =>
|
||
availableParentParams.value.filter((item) => {
|
||
const value = String(item.value || '').trim();
|
||
const label = String(item.label || '').trim();
|
||
return value !== '${}' && !value.endsWith('.nodeOutputResult}') && !label.endsWith('.输出结果');
|
||
})
|
||
);
|
||
const getReferenceDisplayLabel = (value: string) => {
|
||
const formatted = formatParamReference(value);
|
||
return formatted === value ? value : formatted;
|
||
};
|
||
const getHttpBodyReferenceOptions = (currentValue?: string) => {
|
||
const options = [...availableParentFieldParams.value];
|
||
const normalizedValue = String(currentValue || '').trim();
|
||
if (!normalizedValue) return options;
|
||
if (options.some((item) => item.value === normalizedValue)) return options;
|
||
const isReferenceValue = /^\$\{[^.}]+\.[^}]+\}$/.test(normalizedValue);
|
||
if (!isReferenceValue) return options;
|
||
return [
|
||
...options,
|
||
{
|
||
label: getReferenceDisplayLabel(normalizedValue),
|
||
value: normalizedValue,
|
||
},
|
||
];
|
||
};
|
||
const treeProps = { children: 'children', label: 'label' };
|
||
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||
const nodeLibraryGroups = ref<NodeLibraryGroup[]>([]);
|
||
const workflowDsl = computed(() => ({
|
||
version: '1.0.0',
|
||
startNodeId: flowDsl.value.nodes[0]?.id || '',
|
||
nodes: flowDsl.value.nodes.map((n) => ({
|
||
id: n.id,
|
||
nodeCode: n.properties?.nodeCode || 'unknown',
|
||
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
|
||
type: n.type || 'rect',
|
||
skillName: n.properties?.skillName || null,
|
||
config: {
|
||
nodeCode: n.properties?.nodeCode || 'unknown',
|
||
width: n.properties?.width || 100,
|
||
height: n.properties?.height || 80,
|
||
x: n.x || 0,
|
||
y: n.y || 0,
|
||
},
|
||
inputSource: n.properties?.inputSource || null,
|
||
modelOutputFields: Array.isArray(n.properties?.modelOutputFields) ? n.properties.modelOutputFields : 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<string, any> = {};
|
||
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,
|
||
};
|
||
} else {
|
||
const nodeOutputMatched = rawValue.match(/^\$\{([^}]+)\}$/);
|
||
if (nodeOutputMatched) {
|
||
normalizedValue = {
|
||
field: 'nodeOutputResult',
|
||
nodeId: nodeOutputMatched[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,
|
||
modelResponse: n.properties.modelConfig?.modelResponse || n.properties?.modelResponse || null,
|
||
}
|
||
: null,
|
||
outputConfig: Array.isArray(n.properties?.outputConfig) ? n.properties.outputConfig : null,
|
||
isSaveFile: n.properties?.isSaveFileEnabled ?? null,
|
||
promptContent: n.properties?.promptContent || '',
|
||
outputResult: null,
|
||
})),
|
||
edges: flowDsl.value.edges.map((e) => ({
|
||
id: e.id,
|
||
from: e.sourceNodeId,
|
||
to: e.targetNodeId,
|
||
type: e.type || 'polyline',
|
||
mapping: { ...e.properties },
|
||
})),
|
||
}));
|
||
const joinUrl = (b: string, p: string) => `${b.replace(/\/$/, '')}${p.startsWith('/') ? p : `/${p}`}`;
|
||
const buildAssetUrl = (p?: string) =>
|
||
!p
|
||
? ''
|
||
: /^https?:\/\//i.test(p)
|
||
? p
|
||
: /^https?:\/\//i.test(imgAddressPrefix.value || '')
|
||
? joinUrl(imgAddressPrefix.value, p)
|
||
: imgAddressPrefix.value
|
||
? joinUrl(joinUrl(apiBaseUrl, imgAddressPrefix.value), p)
|
||
: joinUrl(apiBaseUrl, p);
|
||
const buildTreeNodes = (tree: ExecutionTreeItem[]): TreeNode[] =>
|
||
tree.map((d, di) => ({
|
||
id: `date-${di}`,
|
||
label: d.createDate,
|
||
nodeType: 'date',
|
||
children: (d.flows || []).map((f, fi) => ({
|
||
id: `flow-${di}-${fi}`,
|
||
label: f.flowName || '未命名工作流',
|
||
nodeType: 'contentType',
|
||
workflowId: f.Id,
|
||
sessionId: f.sessionId,
|
||
children: (f.items || []).map((item, ii) => ({
|
||
id: `item-${di}-${fi}-${ii}`,
|
||
label: item.label || `作品${ii + 1}`,
|
||
nodeType: 'title',
|
||
fileUrl: item.content,
|
||
fileType: item.type,
|
||
workflowId: f.Id,
|
||
sessionId: f.sessionId,
|
||
})),
|
||
})),
|
||
}));
|
||
const getList = async () => {
|
||
treeLoading.value = true;
|
||
try {
|
||
const res = await getExecutionList();
|
||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||
treeNodes.value = buildTreeNodes(res.data?.tree || []);
|
||
} catch {
|
||
// 错误已由全局拦截器处理
|
||
treeNodes.value = [];
|
||
imgAddressPrefix.value = '';
|
||
} finally {
|
||
treeLoading.value = false;
|
||
}
|
||
};
|
||
const getNodeLibrary = async () => {
|
||
try {
|
||
const res = await getNodeLibraryList();
|
||
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 = [];
|
||
}
|
||
};
|
||
// 获取工作流列表
|
||
const fetchWorkflowList = async () => {
|
||
workflowListLoading.value = true;
|
||
try {
|
||
const res = await getWorkflowList();
|
||
|
||
// 分别处理用户工作流和模板工作流
|
||
const userWorkflows = res.data?.listFlowUserRes?.list || [];
|
||
const templateWorkflows = res.data?.listFlowTemplateRes?.list || [];
|
||
|
||
// 获取管理员权限
|
||
isAdmin.value = res.data?.isAdmin || false;
|
||
|
||
// 用户工作流分页
|
||
userWorkflowPagination.total = userWorkflows.length;
|
||
const userStart = (userWorkflowPagination.pageNum - 1) * userWorkflowPagination.pageSize;
|
||
const userEnd = userStart + userWorkflowPagination.pageSize;
|
||
userWorkflowList.value = userWorkflows.slice(userStart, userEnd);
|
||
|
||
// 模板工作流分页
|
||
templateWorkflowPagination.total = templateWorkflows.length;
|
||
const templateStart = (templateWorkflowPagination.pageNum - 1) * templateWorkflowPagination.pageSize;
|
||
const templateEnd = templateStart + templateWorkflowPagination.pageSize;
|
||
templateWorkflowList.value = templateWorkflows.slice(templateStart, templateEnd);
|
||
} catch {
|
||
// 错误已由全局拦截器处理
|
||
userWorkflowList.value = [];
|
||
templateWorkflowList.value = [];
|
||
userWorkflowPagination.total = 0;
|
||
templateWorkflowPagination.total = 0;
|
||
isAdmin.value = false;
|
||
} finally {
|
||
workflowListLoading.value = false;
|
||
}
|
||
};
|
||
// 刷新工作流列表
|
||
// const refreshWorkflowList = () => {
|
||
// userWorkflowPagination.pageNum = 1;
|
||
// templateWorkflowPagination.pageNum = 1;
|
||
// fetchWorkflowList();
|
||
// };
|
||
// 新建工作流
|
||
const createNewWorkflow = () => {
|
||
resetCreationTempState();
|
||
// 切换回画布编辑模式
|
||
isCreationMode.value = false;
|
||
currentWorkflowForCreation.value = null;
|
||
currentSessionId.value = null; // 清空会话 ID
|
||
isFromWorkspace.value = false; // 清空工作空间标识
|
||
|
||
// 清空当前编辑状态
|
||
currentEditingWorkflowId.value = null;
|
||
|
||
// 重置画布
|
||
resetFlow();
|
||
};
|
||
// 处理用户工作流分页变化
|
||
const handleUserPageChange = (page: number) => {
|
||
userWorkflowPagination.pageNum = page;
|
||
fetchWorkflowList();
|
||
};
|
||
// 处理模板工作流分页变化
|
||
const handleTemplatePageChange = (page: number) => {
|
||
templateWorkflowPagination.pageNum = page;
|
||
fetchWorkflowList();
|
||
};
|
||
// 处理技能选择确认(只更新临时状态,不保存到节点)
|
||
const handleSkillConfirm = (skill: SkillItem) => {
|
||
selectedSkill.value = skill;
|
||
if (selectedElement.value?.kind === 'node') {
|
||
selectedElement.value.properties = {
|
||
...(selectedElement.value.properties || {}),
|
||
skillName: skill.name,
|
||
};
|
||
}
|
||
};
|
||
// 移除已选择的技能(只更新临时状态)
|
||
const handleRemoveSkill = () => {
|
||
selectedSkill.value = null;
|
||
if (selectedElement.value?.kind === 'node') {
|
||
const nextProperties = { ...(selectedElement.value.properties || {}) };
|
||
delete nextProperties.skillName;
|
||
selectedElement.value.properties = nextProperties;
|
||
}
|
||
};
|
||
// 处理模型选择确认(只更新临时状态,不保存到节点)
|
||
const handleModelConfirm = (model: any) => {
|
||
selectedModel.value = model.modelName;
|
||
selectedModelData.value = {
|
||
...model,
|
||
// 保存模型的 form 和 responseBody,用于后续保存到节点
|
||
modelFormData: model.form || [],
|
||
responseBody: model.responseBody || {}, // 只使用 responseBody
|
||
};
|
||
};
|
||
// 移除已选择的模型(只更新临时状态)
|
||
const handleRemoveModel = () => {
|
||
selectedModel.value = '';
|
||
selectedModelData.value = null;
|
||
};
|
||
// 处理提示词输入确认
|
||
const handlePromptConfirm = (value: string) => {
|
||
promptContent.value = value;
|
||
};
|
||
// 移除已填写的提示词
|
||
const handleRemovePrompt = () => {
|
||
promptContent.value = '';
|
||
};
|
||
// 获取对话模型列表
|
||
const fetchChatModelList = async () => {
|
||
chatModelLoading.value = true;
|
||
try {
|
||
const res: any = await getModelModuleList({
|
||
pageNum: chatModelPagination.pageNum,
|
||
pageSize: chatModelPagination.pageSize,
|
||
modelType: 100, // 传递 modelType=100 固定获取对话模型
|
||
modelName: chatModelSearchKeyword.value || undefined,
|
||
enabled: 1,
|
||
});
|
||
chatModelList.value = res.data?.list || [];
|
||
chatModelPagination.total = res.data?.total || 0;
|
||
} catch {
|
||
chatModelList.value = [];
|
||
chatModelPagination.total = 0;
|
||
} finally {
|
||
chatModelLoading.value = false;
|
||
}
|
||
};
|
||
// 处理对话模型分页变化
|
||
const handleChatModelPageChange = (page: number) => {
|
||
chatModelPagination.pageNum = page;
|
||
fetchChatModelList();
|
||
};
|
||
// 处理对话模型搜索
|
||
const handleChatModelSearch = () => {
|
||
chatModelPagination.pageNum = 1;
|
||
fetchChatModelList();
|
||
};
|
||
// 设置对话模型
|
||
const handleSetChatModel = async () => {
|
||
if (!selectedChatModel.value) return;
|
||
|
||
// 判断是否是内置模型(isOwner === 0 表示管理员创建的内置模型)
|
||
if (selectedChatModel.value.isOwner === 0) {
|
||
// 内置模型,需要用户配置 API Key 创建副本
|
||
builtInChatModelToClone.value = selectedChatModel.value;
|
||
chatModelApiKeyForm.modelName = selectedChatModel.value.modelName;
|
||
chatModelApiKeyForm.apiKey = '';
|
||
chatModelApiKeyDialogVisible.value = true;
|
||
return;
|
||
}
|
||
|
||
// 用户模型,直接设置为对话模型
|
||
settingChatModel.value = true;
|
||
try {
|
||
await updateChatModel({
|
||
id: selectedChatModel.value.id,
|
||
isChatModel: 1,
|
||
});
|
||
ElMessage.success('对话模型设置成功');
|
||
showChatModelSelector.value = false;
|
||
selectedChatModel.value = null;
|
||
} catch {
|
||
// 接口错误由 request 全局提示后端 message
|
||
} finally {
|
||
settingChatModel.value = false;
|
||
}
|
||
};
|
||
// 创建内置对话模型副本并设置为会话模型
|
||
const handleCreateChatModelFromBuiltIn = async () => {
|
||
if (!chatModelApiKeyFormRef.value || !builtInChatModelToClone.value) return;
|
||
|
||
try {
|
||
await chatModelApiKeyFormRef.value.validate();
|
||
|
||
creatingChatModel.value = true;
|
||
|
||
// 基于内置模型创建新模型(继承原模型的所有配置,只替换 apiKey)
|
||
const builtInModel = builtInChatModelToClone.value;
|
||
const createParams = {
|
||
modelName: chatModelApiKeyForm.modelName,
|
||
modelType: builtInModel.modelType,
|
||
baseUrl: builtInModel.baseUrl,
|
||
httpMethod: builtInModel.httpMethod || 'POST',
|
||
headMsg: builtInModel.headMsg || '',
|
||
isPrivate: builtInModel.isPrivate ?? 1,
|
||
enabled: builtInModel.enabled ?? 1,
|
||
isChatModel: 1, // 设置为会话模型
|
||
callMode: builtInModel.callMode ?? builtInModel.isAsync ?? 0,
|
||
apiKey: chatModelApiKeyForm.apiKey,
|
||
form: builtInModel.form || {},
|
||
requestMapping: builtInModel.requestMapping || {},
|
||
requiredFields: Array.isArray(builtInModel.requiredFields) ? builtInModel.requiredFields : [],
|
||
firstFrame: String(builtInModel.firstFrame || ''),
|
||
lastFrame: String(builtInModel.lastFrame || ''),
|
||
responseMapping: builtInModel.responseMapping || {},
|
||
responseBody: builtInModel.responseBody || {},
|
||
tokenMapping: builtInModel.tokenMapping || '',
|
||
prompt: builtInModel.prompt || '',
|
||
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,
|
||
};
|
||
|
||
await addModelModule(createParams);
|
||
|
||
ElMessage.success('模型创建成功并已设置为会话模型');
|
||
|
||
// 关闭对话框
|
||
chatModelApiKeyDialogVisible.value = false;
|
||
showChatModelSelector.value = false;
|
||
|
||
// 清空表单
|
||
chatModelApiKeyForm.modelName = '';
|
||
chatModelApiKeyForm.apiKey = '';
|
||
builtInChatModelToClone.value = null;
|
||
} catch (error: any) {
|
||
if (error !== 'cancel') {
|
||
ElMessage.error(error?.message || '创建模型失败');
|
||
}
|
||
} finally {
|
||
creatingChatModel.value = false;
|
||
}
|
||
};
|
||
// 使用工作流
|
||
const useWorkflow = async (workflow: WorkflowItem) => {
|
||
// 管理员权限检查:管理员只能编辑,不能进入创作模式
|
||
if (isSuperAdmin.value) {
|
||
ElMessage.warning('管理员只能查看和编辑工作流,不能进入创作模式');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 调用详情接口获取最新的工作流数据
|
||
const res = await getWorkflowDetail(workflow.id);
|
||
if (res.data) {
|
||
// 切换到创作模式
|
||
isCreationMode.value = true;
|
||
currentWorkflowForCreation.value = res.data;
|
||
// 从工作流进入,不禁用表单
|
||
isFromWorkspace.value = false;
|
||
currentSessionId.value = null; // 清空会话 ID
|
||
resetCreationTempState();
|
||
|
||
// 根据 nodeInputParams 初始化表单默认值
|
||
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
|
||
res.data.nodeInputParams.forEach((node: any) => {
|
||
// 从节点根级别的 formConfig 读取(不是 node.config.formConfig)
|
||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||
node.formConfig.forEach((field: any) => {
|
||
// 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
|
||
creationFormValues[fieldKey] = field.value ? Number(field.value) : null;
|
||
} else if (field.type === 'switch') {
|
||
// 开关类型:转换为布尔值
|
||
creationFormValues[fieldKey] = Boolean(field.value);
|
||
} else {
|
||
// 其他类型:保持原值或空字符串
|
||
creationFormValues[fieldKey] =
|
||
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
|
||
? Array.isArray(field.value)
|
||
? field.value
|
||
: field.value
|
||
? [field.value]
|
||
: []
|
||
: field.value || '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// 初始化其他配置字段(从 config 中读取)
|
||
if (node.config) {
|
||
Object.keys(node.config).forEach((key) => {
|
||
if (
|
||
![
|
||
'nodeCode',
|
||
'width',
|
||
'height',
|
||
'x',
|
||
'y',
|
||
'formConfig',
|
||
'inputSource',
|
||
'fieldMetadata',
|
||
'selectedModel',
|
||
'modelOutputFields',
|
||
].includes(key)
|
||
) {
|
||
const fieldKey = `${node.id}_${key}`;
|
||
creationFormValues[fieldKey] = node.config[key];
|
||
}
|
||
});
|
||
}
|
||
});
|
||
hydrateCreationFileFields(res.data.nodeInputParams);
|
||
}
|
||
|
||
ElMessage.success(`已进入创作模式`);
|
||
} else {
|
||
ElMessage.warning('该工作流没有内容');
|
||
}
|
||
} catch (error) {
|
||
// 后端错误会自动显示
|
||
}
|
||
};
|
||
// 编辑工作流
|
||
const editWorkflow = async (workflow: WorkflowItem) => {
|
||
try {
|
||
// 调用详情接口获取最新的工作流数据
|
||
const res = await getWorkflowDetail(workflow.id);
|
||
if (res.data?.flowContent) {
|
||
resetCreationTempState();
|
||
// 切换回画布编辑模式
|
||
isCreationMode.value = false;
|
||
currentWorkflowForCreation.value = null;
|
||
currentSessionId.value = null; // 清空会话 ID
|
||
isFromWorkspace.value = false; // 清空工作空间标识
|
||
|
||
// 等待 DOM 更新后再加载工作流
|
||
await nextTick();
|
||
|
||
loadWorkflowFromDsl(res.data.flowContent);
|
||
// 预填充保存表单,并记录当前编辑的工作流ID
|
||
currentEditingWorkflowId.value = res.data.id;
|
||
saveForm.flowName = res.data.flowName || res.data.flowTemplateName || '';
|
||
saveForm.description = res.data.description || '';
|
||
} else {
|
||
ElMessage.warning('该工作流没有内容');
|
||
}
|
||
} catch (error) {
|
||
// 后端错误会自动显示
|
||
}
|
||
};
|
||
// 返回画布编辑模式
|
||
const backToCanvas = async () => {
|
||
resetCreationTempState();
|
||
isCreationMode.value = false;
|
||
currentWorkflowForCreation.value = null;
|
||
currentSessionId.value = null; // 清空会话 ID
|
||
isFromWorkspace.value = false; // 清空工作空间标识
|
||
|
||
// 等待 DOM 更新后重新渲染画布
|
||
await nextTick();
|
||
|
||
const lf = logicFlowInstance.value;
|
||
if (lf) {
|
||
// 重新渲染当前的工作流数据
|
||
const currentData = lf.getGraphData() as any;
|
||
lf.render(currentData);
|
||
lf.zoom(1);
|
||
lf.translateCenter();
|
||
}
|
||
};
|
||
// 删除工作流
|
||
const deleteWorkflowAction = async (workflow: WorkflowItem) => {
|
||
try {
|
||
const workflowName = workflow.flowName || workflow.flowTemplateName || '该工作流';
|
||
await ElMessageBox.confirm(`确定要删除工作流"${workflowName}"吗?此操作不可恢复。`, '删除确认', {
|
||
confirmButtonText: '确定删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
});
|
||
|
||
await deleteWorkflow(workflow.id);
|
||
ElMessage.success('工作流删除成功');
|
||
|
||
// 如果删除的是当前正在编辑的工作流,清空编辑状态
|
||
if (currentEditingWorkflowId.value === workflow.id) {
|
||
currentEditingWorkflowId.value = null;
|
||
saveForm.flowName = '';
|
||
saveForm.description = '';
|
||
}
|
||
|
||
// 刷新工作流列表
|
||
await fetchWorkflowList();
|
||
} catch (error) {
|
||
if (error === 'cancel') {
|
||
// 用户取消操作,不显示错误
|
||
}
|
||
}
|
||
};
|
||
// 创作模式字段工具
|
||
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 resetCreationTempState = () => {
|
||
selectedFiles.value = [];
|
||
selectedCreationSkill.value = null;
|
||
userInput.value = '';
|
||
Object.keys(creationFormValues).forEach((key) => delete creationFormValues[key]);
|
||
Object.keys(creationFieldFiles).forEach((key) => delete creationFieldFiles[key]);
|
||
Object.keys(creationFieldUploading).forEach((key) => delete creationFieldUploading[key]);
|
||
};
|
||
const hydrateCreationFileFields = (nodes: any[] = []) => {
|
||
nodes.forEach((node: any) => {
|
||
getCreationVisibleFields(node).forEach((field: any) => {
|
||
if (!isCreationFileField(field)) return;
|
||
const key = getCreationFieldKey(node, field);
|
||
const rawValue = creationFormValues[key];
|
||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||
if (urls.length === 0) return;
|
||
creationFieldFiles[key] = urls.map((url: string, index: number) => ({
|
||
name:
|
||
String(url || '')
|
||
.split('/')
|
||
.pop() || `file-${index + 1}`,
|
||
url,
|
||
}));
|
||
});
|
||
});
|
||
};
|
||
const creationFieldFiles = reactive<Record<string, Array<{ name: string; url: string }>>>({});
|
||
const creationFieldUploading = reactive<Record<string, boolean>>({});
|
||
const isCreationFieldUploading = (node: any, field: any) => {
|
||
const key = getCreationFieldKey(node, field);
|
||
return creationFieldUploading[key] === true;
|
||
};
|
||
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);
|
||
if (creationFieldUploading[key]) return;
|
||
creationFieldUploading[key] = true;
|
||
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 || '文件上传失败');
|
||
} finally {
|
||
creationFieldUploading[key] = false;
|
||
}
|
||
};
|
||
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);
|
||
};
|
||
// 移除文件
|
||
const removeFile = (index: number) => {
|
||
selectedFiles.value.splice(index, 1);
|
||
};
|
||
// 处理创作技能选择
|
||
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) {
|
||
ElMessage.warning('请先选择一个工作流');
|
||
return;
|
||
}
|
||
|
||
if (!validateCreationFields()) {
|
||
return;
|
||
}
|
||
|
||
// 检查是否设置了会话模型
|
||
try {
|
||
const chatModelRes: any = await getIsChatModel();
|
||
if (!chatModelRes.data || Object.keys(chatModelRes.data).length === 0) {
|
||
ElMessageBox.alert('请先设置对话模型后再进行创作', '提示', {
|
||
confirmButtonText: '去设置',
|
||
type: 'warning',
|
||
})
|
||
.then(() => {
|
||
showChatModelSelector.value = true;
|
||
})
|
||
.catch(() => {});
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('获取会话模型失败,请稍后重试');
|
||
return;
|
||
}
|
||
|
||
isCreating.value = true;
|
||
|
||
try {
|
||
// 1. 先上传文件到 OSS,获取文件 URL
|
||
const fileUrls: string[] = [];
|
||
|
||
if (isFromWorkspace.value && currentWorkflowForCreation.value?.fileUrls) {
|
||
fileUrls.push(...currentWorkflowForCreation.value.fileUrls);
|
||
}
|
||
|
||
if (selectedFiles.value.length > 0) {
|
||
for (const file of selectedFiles.value) {
|
||
const uploadRes = await uploadFile(file);
|
||
// 拼接完整的文件地址
|
||
const fullUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
|
||
fileUrls.push(fullUrl);
|
||
}
|
||
}
|
||
|
||
// 2. 构建节点输入参数
|
||
const nodeInputParams =
|
||
currentWorkflowForCreation.value.nodeInputParams?.map((node: any) => {
|
||
// 先展开原始节点的所有字段(保留 promptContent、isSaveFile、outputConfig、modelOutputFields 等)
|
||
const nodeParam: any = { ...node };
|
||
|
||
// 用用户填写的值覆盖 formConfig
|
||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||
nodeParam.formConfig = node.formConfig.map((field: any) => {
|
||
// 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:
|
||
bodyItem.fieldType === 'fileUpload'
|
||
? Array.isArray(userVal !== undefined ? userVal : bodyItem.value)
|
||
? userVal !== undefined
|
||
? userVal
|
||
: bodyItem.value
|
||
: userVal !== undefined
|
||
? [userVal]
|
||
: bodyItem.value
|
||
? [bodyItem.value]
|
||
: []
|
||
: userVal !== undefined
|
||
? userVal
|
||
: bodyItem.value,
|
||
};
|
||
});
|
||
return {
|
||
...field,
|
||
value: bodyValue,
|
||
};
|
||
}
|
||
|
||
const fieldKey = `${node.id}_${field.field || field.label}`;
|
||
return {
|
||
...field,
|
||
value: creationFormValues[fieldKey] !== undefined ? creationFormValues[fieldKey] : field.value,
|
||
};
|
||
});
|
||
}
|
||
|
||
// 用用户填写的值覆盖 config
|
||
if (node.config) {
|
||
nodeParam.config = { ...node.config };
|
||
Object.keys(node.config).forEach((key) => {
|
||
const fieldKey = `${node.id}_${key}`;
|
||
if (creationFormValues[fieldKey] !== undefined) {
|
||
nodeParam.config[key] = creationFormValues[fieldKey];
|
||
}
|
||
});
|
||
}
|
||
|
||
return nodeParam;
|
||
}) || [];
|
||
|
||
// 3. 同步更新 flowContent.nodes,使其与 nodeInputParams 一致
|
||
const updatedFlowContent = {
|
||
...currentWorkflowForCreation.value.flowContent,
|
||
nodes: nodeInputParams, // 使用更新后的节点参数
|
||
};
|
||
|
||
// 4. 构建请求参数
|
||
const params: ExecuteFlowParams = {
|
||
flowId: currentWorkflowForCreation.value.id, // ID 是字符串
|
||
flowContent: updatedFlowContent,
|
||
nodeInputParams: nodeInputParams,
|
||
sessionId: getSessionId(),
|
||
desc: userInput.value,
|
||
skillName: selectedCreationSkill.value?.name,
|
||
flowName: currentWorkflowForCreation.value.flowName || currentWorkflowForCreation.value.flowTemplateName, // 工作流名称
|
||
fileUrl: fileUrls, // 添加文件 URL 数组
|
||
resultUrl: currentWorkflowForCreation.value.resultUrl || '', // 添加结果节点 URL
|
||
};
|
||
|
||
// 5. 调用执行接口(不再使用 FormData,直接传 JSON)
|
||
await executeFlow(params);
|
||
|
||
ElMessage.success('创作完成!');
|
||
|
||
// 6. 清空输入
|
||
userInput.value = '';
|
||
selectedFiles.value = [];
|
||
selectedCreationSkill.value = null;
|
||
|
||
// 7. 重新获取工作空间数据
|
||
await getList();
|
||
} catch {
|
||
// 接口错误由 request 全局提示后端 message
|
||
} finally {
|
||
isCreating.value = false;
|
||
}
|
||
};
|
||
// 终止执行(接口预留)
|
||
const stopExecution = async () => {
|
||
try {
|
||
// TODO: 调用终止执行的接口
|
||
// await stopExecutionApi({ sessionId: getSessionId() });
|
||
|
||
ElMessage.warning('终止执行功能开发中...');
|
||
|
||
// 暂时直接停止加载状态
|
||
isCreating.value = false;
|
||
} catch (error) {
|
||
ElMessage.error('终止执行失败');
|
||
}
|
||
};
|
||
|
||
const handleMiddleSplitterMouseDown = () => {
|
||
if (creationFormCollapsed.value) return;
|
||
isDraggingMiddleSplitter.value = true;
|
||
};
|
||
|
||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||
if (!isDraggingMiddleSplitter.value) return;
|
||
const middleEl = document.querySelector('.creation-middle') as HTMLElement | null;
|
||
if (!middleEl) return;
|
||
|
||
const rect = middleEl.getBoundingClientRect();
|
||
const y = e.clientY - rect.top;
|
||
const ratio = (y / rect.height) * 100;
|
||
formPanelHeightPercent.value = Math.min(75, Math.max(25, ratio));
|
||
};
|
||
|
||
const handleGlobalMouseUp = () => {
|
||
isDraggingMiddleSplitter.value = false;
|
||
};
|
||
// 根据字段类型返回CSS类名
|
||
const _getFieldClass = (type: string) => {
|
||
if (type === 'textarea') return 'form-item-full';
|
||
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)
|
||
if (data.nodeType === 'contentType' && data.workflowId) {
|
||
// 管理员权限检查:管理员只能编辑,不能进入创作模式
|
||
if (isSuperAdmin.value) {
|
||
ElMessage.warning('管理员只能查看和编辑工作流,不能进入创作模式');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 从工作空间进入,使用 execution/get 接口获取执行详情
|
||
const res = await getExecutionDetail(String(data.workflowId));
|
||
if (res.data) {
|
||
// 设置当前会话的 sessionId(从工作空间进入)
|
||
currentSessionId.value = data.sessionId || null;
|
||
// 标记为从工作空间进入
|
||
isFromWorkspace.value = true;
|
||
resetCreationTempState();
|
||
|
||
// 处理 fileUrl
|
||
const fileUrls: string[] = [];
|
||
if (res.data.outputParams && Array.isArray(res.data.outputParams)) {
|
||
const prefix = res.data.imgAddressPrefix || '';
|
||
res.data.outputParams.forEach((param: any) => {
|
||
Object.values(param).forEach((value: any) => {
|
||
if (typeof value === 'string' && value) {
|
||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||
fileUrls.push(value);
|
||
} else {
|
||
fileUrls.push(prefix ? `${prefix}${value}` : value);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
res.data.fileUrls = fileUrls;
|
||
|
||
// 切换到创作模式
|
||
isCreationMode.value = true;
|
||
currentWorkflowForCreation.value = res.data;
|
||
|
||
// 根据 nodeInputParams 初始化表单默认值
|
||
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
|
||
res.data.nodeInputParams.forEach((node: any) => {
|
||
// 从节点根级别的 formConfig 读取
|
||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||
node.formConfig.forEach((field: any) => {
|
||
const fieldKey = `${node.id}_${field.field || field.label}`;
|
||
// 根据字段类型转换值
|
||
if (field.type === 'number') {
|
||
creationFormValues[fieldKey] = field.value ? Number(field.value) : null;
|
||
} else if (field.type === 'switch') {
|
||
creationFormValues[fieldKey] = Boolean(field.value);
|
||
} else {
|
||
creationFormValues[fieldKey] =
|
||
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
|
||
? Array.isArray(field.value)
|
||
? field.value
|
||
: field.value
|
||
? [field.value]
|
||
: []
|
||
: field.value || '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// 初始化其他配置字段(从 config 中读取)
|
||
if (node.config) {
|
||
Object.keys(node.config).forEach((key) => {
|
||
if (
|
||
![
|
||
'nodeCode',
|
||
'width',
|
||
'height',
|
||
'x',
|
||
'y',
|
||
'formConfig',
|
||
'inputSource',
|
||
'fieldMetadata',
|
||
'selectedModel',
|
||
'modelOutputFields',
|
||
].includes(key)
|
||
) {
|
||
const fieldKey = `${node.id}_${key}`;
|
||
creationFormValues[fieldKey] = node.config[key];
|
||
}
|
||
});
|
||
}
|
||
});
|
||
hydrateCreationFileFields(res.data.nodeInputParams);
|
||
}
|
||
|
||
ElMessage.success(`已进入创作模式`);
|
||
} else {
|
||
ElMessage.warning('该工作流没有内容');
|
||
}
|
||
} catch (error) {
|
||
// 后端错误会自动显示
|
||
}
|
||
}
|
||
|
||
// 处理结果节点(title)
|
||
if (data.nodeType === 'title' && data.workflowId && data.fileUrl) {
|
||
// 管理员权限检查:管理员只能编辑,不能进入创作模式
|
||
if (isSuperAdmin.value) {
|
||
ElMessage.warning('管理员只能查看和编辑工作流,不能进入创作模式');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 从工作空间进入,使用 execution/get 接口获取执行详情
|
||
const res = await getExecutionDetail(String(data.workflowId));
|
||
if (res.data) {
|
||
// 设置当前会话的 sessionId(从工作空间进入)
|
||
currentSessionId.value = data.sessionId || null;
|
||
// 标记为从工作空间进入
|
||
isFromWorkspace.value = true;
|
||
|
||
// 拼接当前点击结果节点的完整 URL
|
||
const prefix = res.data.imgAddressPrefix || '';
|
||
let resultUrl = '';
|
||
if (data.fileUrl.startsWith('http://') || data.fileUrl.startsWith('https://')) {
|
||
resultUrl = data.fileUrl;
|
||
} else {
|
||
resultUrl = prefix ? `${prefix}${data.fileUrl}` : data.fileUrl;
|
||
}
|
||
|
||
// 只传递当前点击结果的 URL
|
||
const fileUrls: string[] = [resultUrl];
|
||
res.data.fileUrls = fileUrls;
|
||
res.data.resultUrl = resultUrl;
|
||
|
||
// 切换到创作模式
|
||
isCreationMode.value = true;
|
||
currentWorkflowForCreation.value = res.data;
|
||
|
||
// 根据 nodeInputParams 初始化表单默认值
|
||
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
|
||
res.data.nodeInputParams.forEach((node: any) => {
|
||
// 从节点根级别的 formConfig 读取
|
||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||
node.formConfig.forEach((field: any) => {
|
||
const fieldKey = `${node.id}_${field.field || field.label}`;
|
||
// 根据字段类型转换值
|
||
if (field.type === 'number') {
|
||
creationFormValues[fieldKey] = field.value ? Number(field.value) : null;
|
||
} else if (field.type === 'switch') {
|
||
creationFormValues[fieldKey] = Boolean(field.value);
|
||
} else {
|
||
creationFormValues[fieldKey] =
|
||
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
|
||
? Array.isArray(field.value)
|
||
? field.value
|
||
: field.value
|
||
? [field.value]
|
||
: []
|
||
: field.value || '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// 初始化其他配置字段(从 config 中读取)
|
||
if (node.config) {
|
||
Object.keys(node.config).forEach((key) => {
|
||
if (
|
||
![
|
||
'nodeCode',
|
||
'width',
|
||
'height',
|
||
'x',
|
||
'y',
|
||
'formConfig',
|
||
'inputSource',
|
||
'fieldMetadata',
|
||
'selectedModel',
|
||
'modelOutputFields',
|
||
].includes(key)
|
||
) {
|
||
const fieldKey = `${node.id}_${key}`;
|
||
creationFormValues[fieldKey] = node.config[key];
|
||
}
|
||
});
|
||
}
|
||
});
|
||
hydrateCreationFileFields(res.data.nodeInputParams);
|
||
}
|
||
|
||
ElMessage.success(`已进入创作模式`);
|
||
} else {
|
||
ElMessage.warning('该工作流没有内容');
|
||
}
|
||
} catch (error) {
|
||
// 后端错误会自动显示
|
||
}
|
||
}
|
||
};
|
||
// 预览节点
|
||
const previewNode = (d: TreeNode) => {
|
||
if (!d.fileUrl) return ElMessage.warning('当前节点没有可用预览地址');
|
||
const url = buildAssetUrl(d.fileUrl);
|
||
if (!url) return ElMessage.warning('当前节点没有可用预览地址');
|
||
|
||
const type = String(d.fileType || '').toLowerCase();
|
||
if (type === 'image') {
|
||
previewMode.value = 'image';
|
||
} else if (type === 'video') {
|
||
previewMode.value = 'video';
|
||
} else if (type === 'audio') {
|
||
previewMode.value = 'audio';
|
||
} else {
|
||
previewMode.value = 'iframe';
|
||
}
|
||
|
||
previewUrl.value = url;
|
||
previewDialogVisible.value = true;
|
||
};
|
||
// 下载节点
|
||
const downloadNode = async (d: TreeNode) => {
|
||
if (d.nodeType !== 'html' && d.nodeType !== 'image' && d.nodeType !== 'title') return;
|
||
if (!d.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
|
||
try {
|
||
// 下载失败时希望展示更贴近页面语义的提示,因此改为 page 模式。
|
||
const r = await downloadToFile({ fileURL: d.fileUrl });
|
||
const blob = r instanceof Blob ? r : r?.data;
|
||
if (!(blob instanceof Blob)) throw new Error('invalid blob');
|
||
const fileName = d.fileUrl.split('/').pop() || '';
|
||
const type = String(d.fileType || '').toLowerCase();
|
||
const defaultExt = type === 'video' ? 'mp4' : type === 'audio' ? 'mp3' : 'html';
|
||
const fileExt = fileName.split('.').pop()?.toLowerCase() || defaultExt;
|
||
const name = decodeURIComponent(fileName || `${d.label}.${fileExt}`);
|
||
const u = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = u;
|
||
a.download = name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(u);
|
||
ElMessage.success(`下载成功${type ? `(${type})` : ''}`);
|
||
} catch {
|
||
// 下载失败由 request 全局提示后端 message
|
||
}
|
||
};
|
||
const syncDsl = () => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
const data = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
|
||
flowDsl.value = { nodes: data.nodes || [], edges: data.edges || [] };
|
||
};
|
||
const getNodeText = (node: Item) => (typeof node?.text === 'string' ? node.text : node?.text?.value || '');
|
||
const getNodeCode = (node: Item) => String(node?.properties?.nodeCode || '');
|
||
const isStartNode = (node: Item) => getNodeCode(node) === START_NODE_CODE || getNodeText(node) === START_NODE_TEXT;
|
||
const isJudgeNode = (node: Item) => {
|
||
const code = getNodeCode(node).toLowerCase();
|
||
const text = getNodeText(node).toLowerCase();
|
||
return JUDGE_KEYWORDS.some((k) => code.includes(k) || text.includes(k));
|
||
};
|
||
// 判断字段是否应该显示为下拉选择
|
||
const isSelectField = (fieldItem: NodeLibraryFormItem) => {
|
||
// 如果字段类型是 select,直接返回 true
|
||
if (fieldItem.type === 'select') return true;
|
||
|
||
// 兼容旧的硬编码字段
|
||
return fieldItem.field === 'size' || fieldItem.field === 'resolution';
|
||
};
|
||
// 获取下拉选项
|
||
const getSelectOptions = (fieldItem: NodeLibraryFormItem) => {
|
||
// 优先使用字段配置中的 options
|
||
if (fieldItem.options && Array.isArray(fieldItem.options)) {
|
||
return fieldItem.options;
|
||
}
|
||
|
||
// 兼容旧的硬编码选项
|
||
if (fieldItem.field === 'size') {
|
||
return [
|
||
{ label: '1024x1024', value: '1024x1024' },
|
||
{ label: '512x512', value: '512x512' },
|
||
{ label: '256x256', value: '256x256' },
|
||
];
|
||
}
|
||
if (fieldItem.field === 'resolution') {
|
||
return [
|
||
{ label: '1920x1080', value: '1920x1080' },
|
||
{ label: '1280x720', value: '1280x720' },
|
||
{ label: '3840x2160', value: '3840x2160' },
|
||
];
|
||
}
|
||
return [];
|
||
};
|
||
// 添加自定义字段
|
||
const addCustomField = () => {
|
||
customFields.value.push({
|
||
label: '',
|
||
value: '',
|
||
type: 'input',
|
||
required: false,
|
||
fileList: [],
|
||
uploadKey: 0,
|
||
fileTypes: '',
|
||
maxFileSize: undefined,
|
||
maxFileCount: undefined,
|
||
});
|
||
};
|
||
const addOutputConfigField = () => {
|
||
outputConfigFields.value.push({ field: '', value: '' });
|
||
};
|
||
const removeOutputConfigField = (index: number) => {
|
||
outputConfigFields.value.splice(index, 1);
|
||
};
|
||
// 删除自定义字段
|
||
const removeCustomField = (index: number) => {
|
||
customFields.value.splice(index, 1);
|
||
};
|
||
// 获取自定义字段的文件列表
|
||
const _getCustomFieldFileList = (index: number) => {
|
||
const field = customFields.value[index];
|
||
if (!field || !field.fileList) return [];
|
||
return field.fileList;
|
||
};
|
||
// 获取键值对数组(使用响应式存储)
|
||
const getKeyValuePairs = (field: string) => {
|
||
// 如果还没有初始化,从 dynamicFormValues 中加载
|
||
if (!fieldKeyValuePairs[field]) {
|
||
const value = dynamicFormValues[field];
|
||
|
||
if (!value) {
|
||
fieldKeyValuePairs[field] = [{ key: '', value: '' }];
|
||
} else if (typeof value === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||
fieldKeyValuePairs[field] = Object.entries(parsed).map(([k, v]) => ({ key: k, value: String(v) }));
|
||
} else {
|
||
fieldKeyValuePairs[field] = [{ key: '', value: '' }];
|
||
}
|
||
} catch (e) {
|
||
fieldKeyValuePairs[field] = [{ key: '', value: '' }];
|
||
}
|
||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||
const pairs = Object.entries(value).map(([k, v]) => ({ key: k, value: String(v) }));
|
||
fieldKeyValuePairs[field] = pairs.length > 0 ? pairs : [{ key: '', value: '' }];
|
||
} else {
|
||
fieldKeyValuePairs[field] = [{ key: '', value: '' }];
|
||
}
|
||
}
|
||
|
||
return fieldKeyValuePairs[field];
|
||
};
|
||
// 添加键值对
|
||
const addKeyValuePair = (field: string) => {
|
||
const pairs = getKeyValuePairs(field);
|
||
pairs.push({ key: '', value: '' });
|
||
updateKeyValueFieldFromPairs(field);
|
||
};
|
||
// 删除键值对
|
||
const removeKeyValuePair = (field: string, index: number) => {
|
||
const pairs = getKeyValuePairs(field);
|
||
pairs.splice(index, 1);
|
||
// 如果删除后为空,保留一个空的键值对
|
||
if (pairs.length === 0) {
|
||
pairs.push({ key: '', value: '' });
|
||
}
|
||
updateKeyValueFieldFromPairs(field);
|
||
};
|
||
// 更新键值对字段
|
||
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;
|
||
const nodeId = selectedElement.value?.id;
|
||
if (!nodeId) 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) => {
|
||
const expandKey = `${nodeId}_responseType_expand_${f.field}`;
|
||
httpExpandFormValues[f.field] = dynamicFormValues[expandKey] !== undefined ? dynamicFormValues[expandKey] : '';
|
||
});
|
||
|
||
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<string, string> = {};
|
||
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 = () => {
|
||
const nodeId = selectedElement.value?.id;
|
||
if (!nodeId) return;
|
||
|
||
currentHttpExpandFields.value.forEach((f: any) => {
|
||
const expandKey = `${nodeId}_responseType_expand_${f.field}`;
|
||
dynamicFormValues[expandKey] = httpExpandFormValues[f.field];
|
||
});
|
||
|
||
showHttpExpandDialog.value = false;
|
||
ElMessage.success('主动拉取参数已保存');
|
||
};
|
||
const buildHttpResponseTypeExpandData = (responseTypeField: any, nodeId: string) => {
|
||
if (!Array.isArray(responseTypeField?.expand) || responseTypeField.expand.length === 0) return [];
|
||
|
||
return responseTypeField.expand.map((f: any) => {
|
||
const expandKey = `${nodeId}_responseType_expand_${f.field}`;
|
||
return {
|
||
...f,
|
||
value: dynamicFormValues[expandKey] !== undefined ? dynamicFormValues[expandKey] : '',
|
||
};
|
||
});
|
||
};
|
||
|
||
// 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;
|
||
// 回显上级节点引用:对象结构转回选择器可识别的字符串
|
||
if (rawItem.value && typeof rawItem.value === 'object' && !Array.isArray(rawItem.value)) {
|
||
const refNodeId = String(rawItem.value.nodeId || '').trim();
|
||
const refField = String(rawItem.value.field || '').trim();
|
||
if (refNodeId && refField) {
|
||
rawItem.value = `\${${refNodeId}.${refField}}`;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
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<string, any> = {};
|
||
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;
|
||
|
||
// 过滤掉空的键值对
|
||
const validPairs = pairs.filter((p) => p.key.trim() !== '');
|
||
|
||
// 转换为对象
|
||
const obj: Record<string, string> = {};
|
||
validPairs.forEach((p) => {
|
||
if (p.key.trim()) {
|
||
obj[p.key.trim()] = p.value;
|
||
}
|
||
});
|
||
|
||
// 保存为JSON字符串
|
||
dynamicFormValues[field] = Object.keys(obj).length > 0 ? JSON.stringify(obj) : '';
|
||
};
|
||
const buildOutputConfigPayload = () =>
|
||
outputConfigFields.value
|
||
.map((item) => {
|
||
const fieldName = String(item.field || '').trim();
|
||
if (!fieldName) return null;
|
||
return {
|
||
field: fieldName,
|
||
label: fieldName,
|
||
value: String(item.value || '').trim(),
|
||
};
|
||
})
|
||
.filter(Boolean) as Array<{ field: string; label: string; value: string }>;
|
||
// 存储字段的文件列表
|
||
const fieldFileLists = reactive<Record<string, any[]>>({});
|
||
// 存储字段的上传key(用于重置上传组件)
|
||
const fieldUploadKeys = reactive<Record<string, number>>({});
|
||
// 存储字段的键值对(用于响应式更新)
|
||
const fieldKeyValuePairs = reactive<Record<string, Array<{ key: string; value: string }>>>({});
|
||
// 获取字段的文件列表
|
||
const getFieldFileList = (field: string) => {
|
||
return fieldFileLists[field] || [];
|
||
};
|
||
// 获取字段的上传key
|
||
const getFieldUploadKey = (field: string) => {
|
||
return fieldUploadKeys[field] || 0;
|
||
};
|
||
// 处理字段文件上传
|
||
const handleFieldUpload = async (field: string, file: any, type: string) => {
|
||
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;
|
||
|
||
// 初始化文件列表
|
||
if (!fieldFileLists[field]) {
|
||
fieldFileLists[field] = [];
|
||
}
|
||
|
||
// 根据类型处理
|
||
if (type === 'upload') {
|
||
// 单个上传:替换现有文件
|
||
fieldFileLists[field] = [{ name: file.name, url: fileUrl }];
|
||
dynamicFormValues[field] = fileUrl;
|
||
} else if (type === 'uploadMultiple') {
|
||
// 多个上传:添加到数组
|
||
fieldFileLists[field].push({ name: file.name, url: fileUrl });
|
||
|
||
// 解析现有的 value
|
||
let urls: string[] = [];
|
||
if (dynamicFormValues[field]) {
|
||
try {
|
||
urls = JSON.parse(dynamicFormValues[field]);
|
||
if (!Array.isArray(urls)) {
|
||
urls = [dynamicFormValues[field]];
|
||
}
|
||
} catch (e) {
|
||
urls = dynamicFormValues[field] ? [dynamicFormValues[field]] : [];
|
||
}
|
||
}
|
||
|
||
// 添加新的 URL
|
||
urls.push(fileUrl);
|
||
dynamicFormValues[field] = JSON.stringify(urls);
|
||
}
|
||
|
||
ElMessage.success('文件上传成功');
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '文件上传失败');
|
||
// 上传失败日志已省略
|
||
// 上传失败时,递增 uploadKey 来重置上传组件
|
||
fieldUploadKeys[field] = (fieldUploadKeys[field] || 0) + 1;
|
||
}
|
||
};
|
||
// 删除字段的文件
|
||
const removeFieldFile = (field: string, fileIdx: number, type: string) => {
|
||
if (!fieldFileLists[field]) return;
|
||
|
||
// 删除文件
|
||
fieldFileLists[field].splice(fileIdx, 1);
|
||
|
||
// 更新 value
|
||
if (type === 'upload') {
|
||
// 单个上传:清空 value
|
||
dynamicFormValues[field] = '';
|
||
} else if (type === 'uploadMultiple') {
|
||
// 多个上传:从数组中删除对应的 URL
|
||
try {
|
||
let urls: string[] = [];
|
||
if (dynamicFormValues[field]) {
|
||
urls = JSON.parse(dynamicFormValues[field]);
|
||
if (!Array.isArray(urls)) {
|
||
urls = [];
|
||
}
|
||
}
|
||
urls.splice(fileIdx, 1);
|
||
dynamicFormValues[field] = urls.length > 0 ? JSON.stringify(urls) : '';
|
||
} catch (e) {
|
||
dynamicFormValues[field] = '';
|
||
}
|
||
}
|
||
|
||
// 递增 uploadKey 来重置上传组件
|
||
fieldUploadKeys[field] = (fieldUploadKeys[field] || 0) + 1;
|
||
};
|
||
// 判断是否可以添加自定义字段(排除判断节点、开始节点、HTTP节点等)
|
||
const canAddCustomFields = (element: SelectedState | null) => {
|
||
if (!element || element.kind !== 'node') return false;
|
||
const nodeCode = String(element.properties?.nodeCode || '').toLowerCase();
|
||
const text = String(element.text || '').toLowerCase();
|
||
|
||
// 排除判断节点
|
||
if (JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || text.includes(k))) return false;
|
||
|
||
// 排除开始节点
|
||
if (nodeCode === START_NODE_CODE || text === START_NODE_TEXT.toLowerCase()) return false;
|
||
|
||
// 排除HTTP节点(已有完整的表单配置,不需要自定义字段)
|
||
if (nodeCode === 'http') return false;
|
||
|
||
return true;
|
||
};
|
||
// 判断是否可以选择上级参数(排除表单参数节点、开始节点和 HTTP/HTTPS 接口节点)
|
||
const canSelectParentParams = (element: SelectedState | null) => {
|
||
if (!element || element.kind !== 'node') return false;
|
||
const nodeCode = String(element.properties?.nodeCode || '').toLowerCase();
|
||
const text = String(element.text || '').toLowerCase();
|
||
|
||
// 排除开始节点
|
||
if (nodeCode === START_NODE_CODE || text === START_NODE_TEXT.toLowerCase()) return false;
|
||
|
||
// 排除表单参数节点
|
||
if (nodeCode === 'form' || text.includes('表单参数')) return false;
|
||
|
||
// 排除 HTTP/HTTPS 接口节点
|
||
if (nodeCode === 'http' || nodeCode === 'https') return false;
|
||
|
||
return true;
|
||
};
|
||
// 添加上级参数到 inputSource
|
||
const addParentParam = (value: string) => {
|
||
if (!value) return;
|
||
if (!selectedElement.value) return;
|
||
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
|
||
const currentProps = selectedElement.value.properties || {};
|
||
let inputSource = Array.isArray(currentProps.inputSource) ? [...currentProps.inputSource] : [];
|
||
|
||
// 提取节点ID和参数名
|
||
const match = value.match(/\$\{([^.]+)\.(.+)\}/);
|
||
if (!match) return;
|
||
|
||
const nodeId = match[1];
|
||
const paramName = match[2];
|
||
|
||
// 查找是否已经有这个节点的引用
|
||
const existingIndex = inputSource.findIndex((item: any) => item.nodeId === nodeId);
|
||
|
||
if (existingIndex >= 0) {
|
||
// 已存在该节点,添加到 field 数组
|
||
const existing = inputSource[existingIndex];
|
||
if (!existing.field.includes(paramName)) {
|
||
inputSource[existingIndex] = {
|
||
...existing,
|
||
field: [...existing.field, paramName],
|
||
};
|
||
}
|
||
} else {
|
||
// 新节点,创建新的引用
|
||
inputSource.push({
|
||
nodeId: nodeId,
|
||
field: [paramName],
|
||
quoteOutput: false,
|
||
});
|
||
}
|
||
|
||
lf.setProperties(selectedElement.value.id, {
|
||
...currentProps,
|
||
inputSource,
|
||
});
|
||
|
||
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||
selectedElement.value.properties = {
|
||
...currentProps,
|
||
inputSource,
|
||
};
|
||
|
||
syncDsl();
|
||
ElMessage.success(`已添加上级参数:${paramName}`);
|
||
|
||
selectedParentParam.value = '';
|
||
};
|
||
// 删除 inputSource 中的参数
|
||
const removeInputSource = (nodeId: string, paramName: string) => {
|
||
if (!selectedElement.value) return;
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
|
||
const currentProps = selectedElement.value.properties || {};
|
||
let inputSource = Array.isArray(currentProps.inputSource) ? [...currentProps.inputSource] : [];
|
||
|
||
// 查找该节点
|
||
const nodeIndex = inputSource.findIndex((item: any) => item.nodeId === nodeId);
|
||
if (nodeIndex < 0) return;
|
||
|
||
// 从 field 数组中删除
|
||
const node = inputSource[nodeIndex];
|
||
const newField = node.field.filter((f: string) => f !== paramName);
|
||
|
||
if (newField.length > 0) {
|
||
// 还有其他参数,更新 field
|
||
inputSource[nodeIndex] = { ...node, field: newField };
|
||
} else {
|
||
// 没有参数了,删除整个节点引用
|
||
inputSource.splice(nodeIndex, 1);
|
||
}
|
||
|
||
const newInputSource = inputSource.length > 0 ? inputSource : null;
|
||
|
||
lf.setProperties(selectedElement.value.id, {
|
||
...currentProps,
|
||
inputSource: newInputSource,
|
||
});
|
||
|
||
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||
selectedElement.value.properties = {
|
||
...currentProps,
|
||
inputSource: newInputSource,
|
||
};
|
||
|
||
syncDsl();
|
||
ElMessage.success(`已删除参数:${paramName}`);
|
||
};
|
||
const ensureDefaultStartNode = () => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
const g = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
|
||
const nodes = g.nodes || [];
|
||
if (nodes.some((n) => isStartNode(n))) return;
|
||
lf.addNode({
|
||
type: 'rect',
|
||
x: 220,
|
||
y: 140,
|
||
text: START_NODE_TEXT,
|
||
properties: { nodeCode: START_NODE_CODE },
|
||
});
|
||
};
|
||
const validateFlowConstraints = () => {
|
||
const nodes = flowDsl.value.nodes || [];
|
||
const edges = flowDsl.value.edges || [];
|
||
if (!nodes.length) return { ok: true };
|
||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||
const startNode = nodes.find((n) => isStartNode(n));
|
||
if (!startNode) return { ok: false, message: '工作流必须包含开始节点' };
|
||
for (const e of edges) {
|
||
if (e.targetNodeId === startNode.id) {
|
||
return { ok: false, message: '开始节点不能被其他节点链接' };
|
||
}
|
||
if (e.sourceNodeId === startNode.id) {
|
||
const target = nodeMap.get(e.targetNodeId);
|
||
if (target && isJudgeNode(target)) {
|
||
return { ok: false, message: '开始节点下一个节点不能是判断节点' };
|
||
}
|
||
}
|
||
}
|
||
const hasOutEdge = new Set(edges.map((e) => e.sourceNodeId));
|
||
const endNodes = nodes.filter((n) => !hasOutEdge.has(n.id));
|
||
if (endNodes.some((n) => isJudgeNode(n))) {
|
||
return { ok: false, message: '结尾节点不能是判断节点' };
|
||
}
|
||
return { ok: true };
|
||
};
|
||
watch(
|
||
selectedElement,
|
||
(e) => {
|
||
formState.text = String(e?.text || '');
|
||
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;
|
||
outputConfigFields.value = [];
|
||
|
||
// 重置 dynamicFormValues(不删除固定字段键,动态 expand 键按节点切换清理)
|
||
for (const key in dynamicFormValues) {
|
||
if (key.includes('_responseType_expand_')) {
|
||
delete dynamicFormValues[key];
|
||
continue;
|
||
}
|
||
dynamicFormValues[key] = '';
|
||
}
|
||
const currentNodeCode = formState.nodeCode;
|
||
const baseFormFields = nodeSchemaMap.value[currentNodeCode] || [];
|
||
const baseFieldNames = new Set(baseFormFields.map((f) => f.field));
|
||
|
||
// 加载自定义字段和基础字段(从 formConfig)
|
||
customFields.value = [];
|
||
if (e?.properties?.formConfig && Array.isArray(e.properties.formConfig)) {
|
||
e.properties.formConfig.forEach((fieldConfig: any) => {
|
||
if (baseFieldNames.has(fieldConfig.field)) {
|
||
// 基础字段:加载到 dynamicFormValues,根据类型转换值
|
||
if (fieldConfig.type === 'number') {
|
||
// 数字类型:转换为数字或 null
|
||
dynamicFormValues[fieldConfig.field] = fieldConfig.value ? Number(fieldConfig.value) : null;
|
||
} else if (fieldConfig.type === 'switch') {
|
||
// 开关类型:转换为布尔值
|
||
dynamicFormValues[fieldConfig.field] = Boolean(fieldConfig.value);
|
||
} else {
|
||
// 其他类型:保持原值
|
||
dynamicFormValues[fieldConfig.field] = fieldConfig.value;
|
||
}
|
||
if (
|
||
String(e.properties.nodeCode || '').toLowerCase() === 'http' &&
|
||
fieldConfig.field === 'responseType' &&
|
||
Array.isArray(fieldConfig.expand)
|
||
) {
|
||
fieldConfig.expand.forEach((expandField: any) => {
|
||
const expandKey = `${e.id}_responseType_expand_${expandField.field}`;
|
||
dynamicFormValues[expandKey] = expandField.value !== undefined ? expandField.value : '';
|
||
});
|
||
}
|
||
} 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: 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,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
if (Array.isArray(e?.properties?.outputConfig)) {
|
||
outputConfigFields.value = e.properties.outputConfig.map((item: any) => ({
|
||
field: String(item?.field || item?.label || '').trim(),
|
||
value: String(item?.value || ''),
|
||
}));
|
||
}
|
||
|
||
// 初始化模型选择和模型相关数据(从 modelConfig)
|
||
const modelConfig = e?.properties?.modelConfig;
|
||
if (modelConfig && typeof modelConfig === 'object') {
|
||
// 从 modelConfig 加载
|
||
selectedModel.value = modelConfig.modelName || '';
|
||
selectedModelData.value = modelConfig.modelName ? { modelName: modelConfig.modelName } : null;
|
||
dynamicFormValues.modelApiKey = modelConfig.modelApiKey || '';
|
||
|
||
// 加载模型表单数据(数组格式)
|
||
if (modelConfig.modelForm && Array.isArray(modelConfig.modelForm)) {
|
||
modelConfig.modelForm.forEach((fieldConfig: any) => {
|
||
if (fieldConfig.field) {
|
||
// 根据字段类型转换值
|
||
if (fieldConfig.type === 'number') {
|
||
// 数字类型:转换为数字或 null
|
||
dynamicFormValues[fieldConfig.field] = fieldConfig.value ? Number(fieldConfig.value) : null;
|
||
} else if (fieldConfig.type === 'switch') {
|
||
// 开关类型:转换为布尔值
|
||
dynamicFormValues[fieldConfig.field] = Boolean(fieldConfig.value);
|
||
} else {
|
||
// 其他类型:保持原值
|
||
dynamicFormValues[fieldConfig.field] = fieldConfig.value;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
// 兼容旧数据格式
|
||
selectedModel.value = String(e?.properties?.selectedModel || '');
|
||
selectedModelData.value = e?.properties?.modelData || null;
|
||
dynamicFormValues.modelApiKey = e?.properties?.modelApiKey || '';
|
||
}
|
||
|
||
// 获取当前节点的模型配置
|
||
let nodeModelConfigs: any[] = [];
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
(group.items || []).forEach((item) => {
|
||
if (item.nodeCode === currentNodeCode) {
|
||
nodeModelConfigs = item.modelConfig || [];
|
||
}
|
||
});
|
||
});
|
||
|
||
// 如果没有选择模型但有模型配置,选择第一个
|
||
if (!selectedModel.value && nodeModelConfigs.length > 0) {
|
||
selectedModel.value = nodeModelConfigs[0].modelName;
|
||
}
|
||
|
||
// 恢复技能信息(只根据 skillName)
|
||
if (e?.properties?.skillName) {
|
||
// 只保存技能名称用于显示,完整信息在选择时已经保存到节点属性
|
||
selectedSkill.value = {
|
||
id: 0,
|
||
name: e.properties.skillName,
|
||
description: '',
|
||
category: '',
|
||
fileName: '',
|
||
fileUrl: '',
|
||
createdAt: '',
|
||
updatedAt: '',
|
||
};
|
||
} else {
|
||
selectedSkill.value = null;
|
||
}
|
||
|
||
// 恢复提示词内容
|
||
promptContent.value = String(e?.properties?.promptContent || '');
|
||
|
||
// 恢复对话模式状态
|
||
isSaveFileEnabled.value = e?.properties?.isSaveFileEnabled === true;
|
||
|
||
// 初始化所有表单字段(基础 + 模型)- 只设置还没有值的字段
|
||
allFormFields.value.forEach((fieldItem) => {
|
||
const currentValue = dynamicFormValues[fieldItem.field];
|
||
|
||
// 如果已经从 formConfig 或 modelConfig 加载过有效值,跳过
|
||
// 对于数字类型,空字符串不是有效值
|
||
if (fieldItem.type === 'number') {
|
||
if (currentValue !== undefined && currentValue !== '' && currentValue !== null) {
|
||
return;
|
||
}
|
||
} else {
|
||
if (currentValue !== undefined && currentValue !== '') {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 使用默认值
|
||
if (fieldItem.default !== undefined) {
|
||
dynamicFormValues[fieldItem.field] = fieldItem.default;
|
||
return;
|
||
}
|
||
|
||
// 根据字段类型设置默认值
|
||
if (fieldItem.type === 'switch') {
|
||
dynamicFormValues[fieldItem.field] = false;
|
||
} else if (fieldItem.type === 'number') {
|
||
// 数字字段默认为 null(而不是空字符串)
|
||
dynamicFormValues[fieldItem.field] = null;
|
||
} else {
|
||
dynamicFormValues[fieldItem.field] = '';
|
||
}
|
||
});
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
const applySelected = () => {
|
||
const lf = logicFlowInstance.value,
|
||
cur = selectedElement.value;
|
||
|
||
if (!lf || !cur) {
|
||
return;
|
||
}
|
||
|
||
if (cur.kind === 'node') {
|
||
const missingField = allFormFields.value.find(
|
||
(fieldItem) =>
|
||
fieldItem.required &&
|
||
(dynamicFormValues[fieldItem.field] === '' || dynamicFormValues[fieldItem.field] === undefined || dynamicFormValues[fieldItem.field] === null)
|
||
);
|
||
if (missingField) {
|
||
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 };
|
||
if (cur.kind === 'edge') {
|
||
formState.field ? (p.field = formState.field) : delete p.field;
|
||
} else {
|
||
// 保留重要的配置字段,删除其他字段
|
||
const keysToKeep = [
|
||
'nodeCode',
|
||
'fieldMetadata',
|
||
'modelConfig',
|
||
'modelOutputFields',
|
||
'inputSource',
|
||
'formConfig',
|
||
'skillName',
|
||
'promptContent',
|
||
'isSaveFileEnabled',
|
||
'width',
|
||
'height',
|
||
];
|
||
Object.keys(p).forEach((key) => {
|
||
if (!keysToKeep.includes(key)) {
|
||
delete p[key];
|
||
}
|
||
});
|
||
|
||
// 保存选中的模型和模型相关配置
|
||
if (selectedModel.value) {
|
||
// 从 selectedModelData 中获取模型的 form 数据
|
||
const modelFormFromData = selectedModelData.value?.modelFormData || selectedModelData.value?.form || [];
|
||
|
||
// 获取模型的 responseBody(必须来自 responseBody)
|
||
const responseBody = selectedModelData.value?.responseBody || {};
|
||
const responseMapping = selectedModelData.value?.responseMapping;
|
||
const modelOutputFields =
|
||
responseMapping && typeof responseMapping === 'object' && !Array.isArray(responseMapping)
|
||
? Object.keys(responseMapping).filter((key) => String(key || '').trim() !== '')
|
||
: [];
|
||
|
||
// 保存到 modelConfig,包含 modelResponse
|
||
p.modelConfig = {
|
||
modelName: selectedModel.value,
|
||
modelApiKey: dynamicFormValues.modelApiKey || '',
|
||
modelForm: modelFormFromData, // 使用模型列表数据里的 form
|
||
modelResponse: responseBody, // 将 modelResponse 放在 modelConfig 内部
|
||
};
|
||
|
||
if (modelOutputFields.length > 0) {
|
||
p.modelOutputFields = modelOutputFields;
|
||
} else {
|
||
delete p.modelOutputFields;
|
||
}
|
||
|
||
// 保存模型选择状态
|
||
p.selectedModel = selectedModel.value;
|
||
p.modelData = selectedModelData.value;
|
||
} else {
|
||
// 如果没有选择模型,删除 modelConfig 和模型状态
|
||
delete p.modelConfig;
|
||
delete p.modelOutputFields;
|
||
delete p.selectedModel;
|
||
delete p.modelData;
|
||
}
|
||
|
||
// 保存技能选择状态
|
||
if (selectedSkill.value) {
|
||
p.skillName = selectedSkill.value.name;
|
||
} else {
|
||
p.skillName = null;
|
||
}
|
||
|
||
// 保存提示词内容
|
||
if (promptContent.value.trim()) {
|
||
p.promptContent = promptContent.value.trim();
|
||
} else {
|
||
delete p.promptContent;
|
||
}
|
||
|
||
// 保存对话模式状态
|
||
p.isSaveFileEnabled = isSaveFileEnabled.value;
|
||
|
||
// 不再保存基础字段到根级别,所有字段都通过 formConfig 保存
|
||
|
||
// 保存字段元数据(label、type、required等)
|
||
const fieldMetadata: Record<string, any> = {};
|
||
allFormFields.value.forEach((fieldItem) => {
|
||
fieldMetadata[fieldItem.field] = {
|
||
label: fieldItem.label,
|
||
type: fieldItem.type,
|
||
required: fieldItem.required,
|
||
field: fieldItem.field,
|
||
};
|
||
});
|
||
if (Object.keys(fieldMetadata).length > 0) {
|
||
p.fieldMetadata = fieldMetadata;
|
||
}
|
||
|
||
// 保存 formConfig(包含基础字段 + 自定义字段,不包含模型字段)
|
||
const formConfig: Array<any> = [];
|
||
const outputConfig = buildOutputConfigPayload();
|
||
|
||
// 1. 添加基础表单字段(非模型字段)
|
||
// 重用上面的 modelFieldNames
|
||
currentNodeForm.value.forEach((fieldItem) => {
|
||
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: normalizedValue,
|
||
required: fieldItem.required || false,
|
||
...(String(formState.nodeCode || '').toLowerCase() === 'http' && fieldItem.field === 'responseType' && normalizedValue === 'pull'
|
||
? {
|
||
expand: buildHttpResponseTypeExpandData(fieldItem, cur.id),
|
||
}
|
||
: {}),
|
||
});
|
||
});
|
||
|
||
// 2. 添加自定义字段
|
||
customFields.value.forEach((field) => {
|
||
formConfig.push({
|
||
type: field.type,
|
||
field: field.label, // 自定义字段使用 label 作为 field
|
||
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 } : {}),
|
||
},
|
||
}
|
||
: {}),
|
||
});
|
||
});
|
||
|
||
// 保存 formConfig
|
||
if (formConfig.length > 0) {
|
||
p.formConfig = formConfig;
|
||
} else {
|
||
delete p.formConfig;
|
||
}
|
||
|
||
if (outputConfig.length > 0) {
|
||
p.outputConfig = outputConfig;
|
||
} else {
|
||
delete p.outputConfig;
|
||
}
|
||
}
|
||
|
||
try {
|
||
lf.setProperties(cur.id, p);
|
||
|
||
if (formState.text) {
|
||
lf.updateText(cur.id, formState.text);
|
||
}
|
||
|
||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||
const n = g.nodes.find((x) => x.id === cur.id),
|
||
e = g.edges.find((x) => x.id === cur.id);
|
||
selectedElement.value = n
|
||
? { id: n.id, type: n.type, kind: 'node', properties: n.properties || {}, text: typeof n.text === 'string' ? n.text : n.text?.value }
|
||
: e
|
||
? { id: e.id, type: e.type, kind: 'edge', properties: e.properties || {}, text: typeof e.text === 'string' ? e.text : e.text?.value }
|
||
: null;
|
||
|
||
syncDsl();
|
||
|
||
// 显示应用成功提示
|
||
ElMessage.success('应用成功');
|
||
} catch (error) {
|
||
ElMessage.error('应用配置失败,请查看控制台');
|
||
}
|
||
};
|
||
// 保存工作流
|
||
const saveWorkflowAction = async () => {
|
||
// 保存前先应用当前选中节点的编辑值,避免 UI 修改未写回节点属性
|
||
if (selectedElement.value && selectedElement.value.kind === 'node') {
|
||
applySelected();
|
||
}
|
||
|
||
syncDsl();
|
||
const validateResult = validateFlowConstraints();
|
||
if (!validateResult.ok) {
|
||
ElMessage.warning(validateResult.message);
|
||
return;
|
||
}
|
||
|
||
// 显示保存对话框
|
||
saveDialogVisible.value = true;
|
||
};
|
||
// 确认保存工作流
|
||
const confirmSaveWorkflow = async () => {
|
||
if (!saveForm.flowName.trim()) {
|
||
ElMessage.warning('请输入工作流名称');
|
||
return;
|
||
}
|
||
|
||
saving.value = true;
|
||
try {
|
||
// 判断是新建还是更新
|
||
if (currentEditingWorkflowId.value) {
|
||
// 更新现有工作流
|
||
await updateWorkflow({
|
||
id: currentEditingWorkflowId.value,
|
||
flowName: saveForm.flowName,
|
||
description: saveForm.description,
|
||
flowContent: workflowDsl.value,
|
||
});
|
||
ElMessage.success('工作流更新成功');
|
||
} else {
|
||
// 创建新工作流
|
||
await saveWorkflow({
|
||
flowName: saveForm.flowName,
|
||
description: saveForm.description,
|
||
flowContent: workflowDsl.value,
|
||
});
|
||
ElMessage.success('工作流保存成功');
|
||
}
|
||
saveDialogVisible.value = false;
|
||
// 清空表单和编辑状态
|
||
saveForm.flowName = '';
|
||
saveForm.description = '';
|
||
currentEditingWorkflowId.value = null;
|
||
// 刷新工作流列表
|
||
await fetchWorkflowList();
|
||
} catch (error) {
|
||
// 后端错误会自动显示
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
const addNodeFromLibrary = (nodeCode: string, nodeName: string) => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf || !logicFlowRef.value) return;
|
||
|
||
// 获取所有现有节点
|
||
const graphData = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
|
||
const existingNodes = graphData.nodes || [];
|
||
|
||
// 计算新节点位置,避免重叠
|
||
let spawnX = 220;
|
||
let spawnY = 140;
|
||
|
||
if (existingNodes.length > 0) {
|
||
// 如果已有节点,在最后一个节点右侧添加
|
||
const lastNode = existingNodes[existingNodes.length - 1];
|
||
spawnX = (lastNode.x || 220) + 180;
|
||
spawnY = lastNode.y || 140;
|
||
|
||
// 如果超出画布,换行
|
||
if (spawnX > 800) {
|
||
spawnX = 220;
|
||
spawnY += 120;
|
||
}
|
||
}
|
||
|
||
// 判断是否为判断节点
|
||
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.toLowerCase().includes(k) || nodeName.toLowerCase().includes(k));
|
||
const nodeType = isJudge ? 'diamond' : 'rect';
|
||
|
||
lf.addNode({
|
||
type: nodeType,
|
||
x: spawnX,
|
||
y: spawnY,
|
||
text: nodeName,
|
||
properties: { nodeCode },
|
||
});
|
||
nodeSpawnIndex.value += 1;
|
||
syncDsl();
|
||
};
|
||
const bindEvents = () => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
lf.on('node:click', ({ data }: { data: any }) => {
|
||
selectedElement.value = {
|
||
id: data.id,
|
||
type: data.type,
|
||
kind: 'node',
|
||
properties: data.properties || {},
|
||
text: typeof data.text === 'string' ? data.text : data.text?.value,
|
||
};
|
||
});
|
||
lf.on('edge:click', ({ data }: { data: any }) => {
|
||
selectedElement.value = {
|
||
id: data.id,
|
||
type: data.type,
|
||
kind: 'edge',
|
||
properties: data.properties || {},
|
||
text: typeof data.text === 'string' ? data.text : data.text?.value,
|
||
};
|
||
});
|
||
lf.on('blank:click', () => {
|
||
selectedElement.value = null;
|
||
});
|
||
lf.on('connection:not-allowed', ({ msg }: { msg?: string }) => {
|
||
ElMessage.warning(msg || '当前连线不允许');
|
||
});
|
||
['history:change', 'node:add', 'edge:add', 'node:delete', 'edge:delete'].forEach((n) => lf.on(n, syncDsl));
|
||
};
|
||
const initLogicFlow = () => {
|
||
if (!logicFlowRef.value) return;
|
||
LogicFlow.use(Control);
|
||
LogicFlow.use(SelectionSelect);
|
||
const lf = new LogicFlow({
|
||
container: logicFlowRef.value,
|
||
grid: { size: 16, visible: true, type: 'dot', config: { color: '#d7e0ef', thickness: 1 } },
|
||
background: { backgroundColor: '#fbfcfe' },
|
||
keyboard: { enabled: true },
|
||
adjustEdge: true,
|
||
edgeType: 'polyline',
|
||
style: {
|
||
rect: { width: 100, height: 44, radius: 8, stroke: '#334155', strokeWidth: 1.4, fill: '#fff' },
|
||
diamond: { width: 120, height: 100, stroke: '#334155', strokeWidth: 1.4, fill: '#fff' },
|
||
polyline: { stroke: '#475569', strokeWidth: 1.4 },
|
||
edgeText: { fill: '#64748b', fontSize: 12, textWidth: 100, background: { fill: '#fff' } },
|
||
text: { fontSize: 13, fill: '#1f2937' },
|
||
anchor: { stroke: '#2563eb', fill: '#fff', r: 4 },
|
||
nodeText: { fontSize: 13, overflowMode: 'ellipsis', lineHeight: 1.4 },
|
||
},
|
||
});
|
||
// 先设置实例引用
|
||
logicFlowInstance.value = lf;
|
||
|
||
// 渲染空画布
|
||
lf.render({ nodes: [], edges: [] });
|
||
|
||
// 添加默认开始节点
|
||
ensureDefaultStartNode();
|
||
|
||
// 设置连线验证规则
|
||
if (typeof lf.setValidateConnection === 'function') {
|
||
lf.setValidateConnection(({ sourceNode, targetNode }: any) => {
|
||
if (!sourceNode || !targetNode) return true;
|
||
const source = sourceNode?.model || sourceNode;
|
||
const target = targetNode?.model || targetNode;
|
||
if (isStartNode(target)) {
|
||
return { isAllPass: false, msg: '开始节点不能被其他节点链接' } as any;
|
||
}
|
||
if (isStartNode(source) && isJudgeNode(target)) {
|
||
return { isAllPass: false, msg: '开始节点下一个节点不能是判断节点' } as any;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// 绑定事件
|
||
bindEvents();
|
||
|
||
// 设置固定缩放比例,不使用 fitView 自动缩放
|
||
lf.zoom(1);
|
||
lf.translateCenter();
|
||
|
||
// 同步 DSL
|
||
syncDsl();
|
||
};
|
||
const resetFlow = () => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
lf.render({ nodes: [], edges: [] });
|
||
ensureDefaultStartNode();
|
||
lf.zoom(1);
|
||
lf.translateCenter();
|
||
nodeSpawnIndex.value = 0;
|
||
selectedElement.value = null;
|
||
syncDsl();
|
||
};
|
||
const cleanupReferencesToNode = (deletedNodeId: string) => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return 0;
|
||
|
||
const graphData = lf.getGraphData() as { nodes?: Item[] };
|
||
const nodes = graphData.nodes || [];
|
||
let affectedCount = 0;
|
||
|
||
nodes.forEach((node: any) => {
|
||
if (node.id === deletedNodeId) return;
|
||
const props = node.properties || {};
|
||
const inputSource = Array.isArray(props.inputSource) ? props.inputSource : [];
|
||
const nextInputSource = inputSource.filter((item: any) => item?.nodeId !== deletedNodeId);
|
||
|
||
if (nextInputSource.length === inputSource.length) return;
|
||
|
||
affectedCount += 1;
|
||
const normalizedInputSource = nextInputSource.length > 0 ? nextInputSource : null;
|
||
lf.setProperties(node.id, {
|
||
...props,
|
||
inputSource: normalizedInputSource,
|
||
});
|
||
|
||
if (selectedElement.value?.id === node.id && selectedElement.value) {
|
||
selectedElement.value = {
|
||
...selectedElement.value,
|
||
properties: {
|
||
...props,
|
||
inputSource: normalizedInputSource,
|
||
},
|
||
};
|
||
}
|
||
});
|
||
|
||
return affectedCount;
|
||
};
|
||
const getAffectedDownstreamNodeNames = (deletedNodeId: string) => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return [] as string[];
|
||
|
||
const graphData = lf.getGraphData() as { nodes?: Item[] };
|
||
const nodes = graphData.nodes || [];
|
||
const names: string[] = [];
|
||
|
||
nodes.forEach((node: any) => {
|
||
if (node.id === deletedNodeId) return;
|
||
const props = node.properties || {};
|
||
const inputSource = Array.isArray(props.inputSource) ? props.inputSource : [];
|
||
const referenced = inputSource.some((item: any) => item?.nodeId === deletedNodeId);
|
||
if (!referenced) return;
|
||
|
||
const nodeName = typeof node.text === 'string' ? node.text : node.text?.value || node.id;
|
||
names.push(String(nodeName));
|
||
});
|
||
|
||
return names;
|
||
};
|
||
const deleteSelectedElement = async () => {
|
||
const lf = logicFlowInstance.value;
|
||
const cur = selectedElement.value;
|
||
if (!lf || !cur) return;
|
||
|
||
if (cur.kind === 'node' && (cur.properties?.nodeCode === START_NODE_CODE || cur.text === START_NODE_TEXT)) {
|
||
ElMessage.warning('开始节点不能删除');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let affectedCount = 0;
|
||
if (cur.kind === 'node') {
|
||
const affectedNodeNames = getAffectedDownstreamNodeNames(cur.id);
|
||
if (affectedNodeNames.length > 0) {
|
||
const previewNames = affectedNodeNames.slice(0, 8);
|
||
const overflowText = affectedNodeNames.length > 8 ? `\n...等 ${affectedNodeNames.length} 个节点` : '';
|
||
await ElMessageBox.confirm(`删除该节点将清理以下下级节点中的引用:\n${previewNames.join('、')}${overflowText}`, '删除确认', {
|
||
confirmButtonText: '继续删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
});
|
||
}
|
||
|
||
affectedCount = cleanupReferencesToNode(cur.id);
|
||
lf.deleteNode(cur.id);
|
||
} else {
|
||
lf.deleteEdge(cur.id);
|
||
}
|
||
selectedElement.value = null;
|
||
ElMessage.success(affectedCount > 0 ? `删除成功,已清理 ${affectedCount} 个下级节点引用` : '删除成功');
|
||
} catch (error) {
|
||
if (error === 'cancel') return;
|
||
ElMessage.error('删除失败');
|
||
}
|
||
}; // 从后端 DSL 恢复工作流
|
||
const loadWorkflowFromDsl = (dsl: any) => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf || !dsl) return;
|
||
|
||
try {
|
||
// 转换后端 DSL 为 LogicFlow 格式
|
||
const nodes = (dsl.nodes || []).map((n: any) => {
|
||
// 判断是否为判断节点
|
||
const nodeCode = String(n.nodeCode || '').toLowerCase();
|
||
const nodeName = String(n.name || '').toLowerCase();
|
||
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeName.includes(k));
|
||
const nodeType = isJudge ? 'diamond' : 'rect';
|
||
|
||
return {
|
||
id: n.id,
|
||
type: nodeType,
|
||
x: n.config?.x || 220,
|
||
y: n.config?.y || 140,
|
||
text: n.name || '',
|
||
properties: {
|
||
// 保留 config 中的基础配置
|
||
...(n.config || {}),
|
||
// 添加节点级别的重要字段
|
||
nodeCode: n.nodeCode,
|
||
skillName: n.skillName || null,
|
||
formConfig: n.formConfig || null,
|
||
modelConfig: n.modelConfig || null,
|
||
outputConfig: n.outputConfig || null,
|
||
inputSource: n.inputSource || null,
|
||
// 加载提示词和保存文件配置
|
||
isSaveFileEnabled: n.isSaveFile ?? false,
|
||
promptContent: n.promptContent || '',
|
||
},
|
||
};
|
||
});
|
||
|
||
const edges = (dsl.edges || []).map((e: any) => ({
|
||
id: e.id,
|
||
type: e.type || 'polyline',
|
||
sourceNodeId: e.from,
|
||
targetNodeId: e.to,
|
||
properties: e.mapping || {},
|
||
}));
|
||
|
||
lf.render({ nodes, edges });
|
||
lf.zoom(1);
|
||
lf.translateCenter();
|
||
syncDsl();
|
||
} catch (error) {
|
||
ElMessage.error('工作流加载失败');
|
||
}
|
||
};
|
||
// 监听创作模式变化,自动切换左侧 Tab
|
||
watch(isCreationMode, (newValue) => {
|
||
if (newValue) {
|
||
// 进入创作模式,切换到"工作空间"Tab
|
||
leftPanelTab.value = 'workspace';
|
||
} else {
|
||
// 退出创作模式,切换到"当前选中"Tab
|
||
leftPanelTab.value = 'selected';
|
||
}
|
||
});
|
||
|
||
// 监听选中元素变化,恢复模型和技能状态
|
||
watch(selectedElement, (newElement) => {
|
||
if (newElement && newElement.kind === 'node') {
|
||
// 从节点属性的 modelConfig 中恢复模型选择状态
|
||
const modelName = newElement.properties.modelConfig?.modelName || '';
|
||
if (modelName) {
|
||
selectedModel.value = modelName;
|
||
selectedModelData.value = { modelName };
|
||
} else {
|
||
selectedModel.value = '';
|
||
selectedModelData.value = null;
|
||
}
|
||
|
||
// 从节点属性中恢复技能选择状态
|
||
if (newElement.properties.skillName) {
|
||
selectedSkill.value = { name: newElement.properties.skillName } as SkillItem;
|
||
} else {
|
||
selectedSkill.value = null;
|
||
}
|
||
|
||
// 从节点属性中恢复提示词内容
|
||
promptContent.value = String(newElement.properties.promptContent || '');
|
||
|
||
// 从节点属性中恢复对话模式状态
|
||
isSaveFileEnabled.value = newElement.properties.isSaveFileEnabled === true;
|
||
} else {
|
||
// 如果不是节点或没有选中元素,清空状态
|
||
selectedModel.value = '';
|
||
selectedModelData.value = null;
|
||
selectedSkill.value = null;
|
||
promptContent.value = '';
|
||
isSaveFileEnabled.value = false;
|
||
}
|
||
});
|
||
|
||
// 监听对话模型选择器打开,加载模型列表
|
||
watch(showChatModelSelector, (val) => {
|
||
if (val) {
|
||
chatModelPagination.pageNum = 1;
|
||
chatModelSearchKeyword.value = '';
|
||
fetchChatModelList();
|
||
} else {
|
||
selectedChatModel.value = null;
|
||
chatModelSearchKeyword.value = '';
|
||
}
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await getList();
|
||
await nextTick();
|
||
initLogicFlow();
|
||
await getNodeLibrary();
|
||
await fetchWorkflowList();
|
||
window.addEventListener('mousemove', handleGlobalMouseMove);
|
||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||
|
||
// 获取当前用户角色
|
||
try {
|
||
const res: any = await checkIsSuperAdmin();
|
||
isSuperAdmin.value = res.data?.isSuperAdmin || false;
|
||
} catch (error) {
|
||
isSuperAdmin.value = false;
|
||
}
|
||
});
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('mousemove', handleGlobalMouseMove);
|
||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||
logicFlowInstance.value?.destroy();
|
||
logicFlowInstance.value = null;
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.creation-page {
|
||
height: calc(100vh - 100px);
|
||
display: grid;
|
||
grid-template-columns: 360px minmax(0, 1fr) 360px;
|
||
gap: 14px;
|
||
padding: 14px;
|
||
background: #f6f8fb;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 左侧面板 */
|
||
.panel.left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
padding: 14px;
|
||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel.left.collapsed {
|
||
width: 70px;
|
||
padding: 14px 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.panel.left.collapsed .title {
|
||
display: none;
|
||
}
|
||
|
||
.panel.left.collapsed .panel-header {
|
||
justify-content: center;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.collapse-btn {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.panel.left.collapsed .collapse-btn {
|
||
width: 48px;
|
||
height: 48px;
|
||
}
|
||
|
||
.tree-wrap {
|
||
flex: 1;
|
||
overflow: auto;
|
||
}
|
||
|
||
.tree-node {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.ellipsis {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.editor-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.panel {
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
padding: 14px;
|
||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.panel.side {
|
||
min-height: 0;
|
||
max-height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.panel.side .title-sm {
|
||
flex-shrink: 0;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.panel.canvas-panel {
|
||
min-height: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.title-sm {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.sub {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
color: #64748b;
|
||
}
|
||
|
||
.editor-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.top {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.main {
|
||
display: flex;
|
||
gap: 14px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 创作模式:表单占满整个区域 */
|
||
.creation-mode .main {
|
||
display: block;
|
||
}
|
||
|
||
.creation-mode-container {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 14px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
.creation-form-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.creation-form-panel.collapsed {
|
||
display: none;
|
||
}
|
||
|
||
.creation-history-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.history-header {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.history-list-placeholder {
|
||
flex: 1;
|
||
overflow: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.history-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.history-item .role {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.history-item .bubble {
|
||
max-width: 86%;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
line-height: 1.6;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.history-item.assistant .bubble {
|
||
background: #f1f5f9;
|
||
color: #0f172a;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.history-item.user {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.history-item.user .bubble {
|
||
background: #3b82f6;
|
||
color: #fff;
|
||
}
|
||
|
||
.creation-form-panel.collapsed + .creation-history-panel {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
/* 画布模式:画布和侧边栏并排 */
|
||
.panel.canvas-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.panel.side {
|
||
width: 380px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
max-height: 100%;
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.meta-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.meta-title {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.meta-info {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.meta-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.canvas-layout {
|
||
display: grid;
|
||
grid-template-columns: 220px minmax(0, 1fr);
|
||
gap: 12px;
|
||
flex: 1;
|
||
min-height: 560px;
|
||
}
|
||
|
||
.node-library {
|
||
border: 1px solid #e8eef7;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
overflow: auto;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.node-library :deep(.el-empty) {
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.node-library-groups {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.node-group {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 10px;
|
||
padding: 10px 10px 8px;
|
||
}
|
||
|
||
.node-group-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #334155;
|
||
margin-bottom: 8px;
|
||
padding-left: 2px;
|
||
}
|
||
|
||
.node-group-items {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 4px;
|
||
}
|
||
|
||
.node-item {
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
margin: 0;
|
||
padding: 6px 8px;
|
||
color: #334155;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.node-item:hover {
|
||
background: #eef4ff;
|
||
}
|
||
|
||
.canvas-wrap {
|
||
flex: 1;
|
||
min-height: 560px;
|
||
border: 1px solid #e8eef7;
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, #fcfdff 0%, #f8fbff 100%);
|
||
}
|
||
|
||
.logicflow-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 560px;
|
||
}
|
||
|
||
.form-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.form-scroll-area {
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
max-height: 600px;
|
||
}
|
||
|
||
.form-scroll-area::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.form-scroll-area::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.form-scroll-area::-webkit-scrollbar-thumb:hover {
|
||
background: #94a3b8;
|
||
}
|
||
|
||
.form-actions {
|
||
padding-top: 12px;
|
||
border-top: 1px solid #e5e7eb;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.apply-button {
|
||
width: 100%;
|
||
height: 48px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||
transition: all 0.3s ease;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.apply-button:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||
}
|
||
|
||
.apply-button:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.json-preview {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.json-preview-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.form-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
color: #475569;
|
||
}
|
||
|
||
.prop-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.w100 {
|
||
width: 100%;
|
||
}
|
||
|
||
.custom-field-config {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
padding: 12px;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: #f9fafb;
|
||
}
|
||
|
||
.custom-field-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.custom-field-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.custom-field-type {
|
||
width: 90px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.custom-field-required {
|
||
flex-shrink: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.custom-field-delete {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.custom-field-value-full {
|
||
width: 100%;
|
||
}
|
||
|
||
.key-value-input-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
background: #f9fafb;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.key-value-pair {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.key-value-pair .key-input {
|
||
flex: 0 0 35%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.key-value-pair .value-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.key-value-pair .el-button {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.add-pair-btn {
|
||
align-self: flex-start;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.custom-field-upload-wrapper,
|
||
.field-upload-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.uploaded-files-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
background: #f9fafb;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.uploaded-file-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 10px;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 4px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.uploaded-file-item:hover {
|
||
border-color: #cbd5e1;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.uploaded-file-item .file-name {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
color: #374151;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.input-source-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.input-source-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
background: #f0f9ff;
|
||
border: 1px solid #bae6fd;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.input-source-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.input-source-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.input-source-key {
|
||
font-weight: 600;
|
||
color: #0369a1;
|
||
}
|
||
|
||
.input-source-arrow {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.input-source-ref {
|
||
color: #0891b2;
|
||
}
|
||
|
||
.input-source-raw {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
}
|
||
|
||
.input-source-field {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
}
|
||
|
||
.input-source-field:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.input-source-field-name {
|
||
font-size: 13px;
|
||
color: #475569;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.input-source-quote {
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.parent-nodes-output {
|
||
margin-bottom: 16px;
|
||
padding: 12px;
|
||
background: #f9fafb;
|
||
border-radius: 8px;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.parent-nodes-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.parent-node-output-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.parent-node-output-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.parent-node-name {
|
||
font-size: 13px;
|
||
color: #475569;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.custom-field-item {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.custom-field-label {
|
||
width: 100px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.custom-field-value {
|
||
flex: 1;
|
||
}
|
||
|
||
.json-box {
|
||
margin: 0;
|
||
padding: 12px;
|
||
border-radius: 12px;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
overflow: auto;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
:deep(.lf-control) {
|
||
right: 14px;
|
||
top: 14px;
|
||
left: auto;
|
||
}
|
||
|
||
:deep(.lf-node-selected .lf-basic-shape) {
|
||
stroke: #2563eb !important;
|
||
stroke-width: 1.8 !important;
|
||
}
|
||
|
||
:deep(.lf-edge-selected path) {
|
||
stroke: #2563eb !important;
|
||
}
|
||
|
||
@media (max-width: 1400px) {
|
||
.panel.side {
|
||
width: 320px;
|
||
}
|
||
|
||
.canvas-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.main {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.panel.side {
|
||
width: 100%;
|
||
max-height: 400px;
|
||
}
|
||
|
||
.top {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.actions {
|
||
width: 100%;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.creation-middle {
|
||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||
}
|
||
}
|
||
|
||
.workflow-list-panel {
|
||
padding: 16px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.workflow-list-panel.collapsed {
|
||
padding: 16px;
|
||
}
|
||
|
||
.workflow-list-panel.collapsed .workflow-list-container {
|
||
display: none;
|
||
}
|
||
|
||
.workflow-list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.collapse-btn-workflow {
|
||
padding: 8px 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.workflow-list-container {
|
||
min-height: 100px;
|
||
}
|
||
|
||
.workflow-pagination {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 12px 0;
|
||
background: #f8fafc;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.workflow-pagination :deep(.el-pagination) {
|
||
justify-content: center;
|
||
}
|
||
|
||
.workflow-pagination :deep(.el-pager li) {
|
||
min-width: 32px;
|
||
height: 32px;
|
||
line-height: 32px;
|
||
border-radius: 6px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.workflow-pagination :deep(.el-pager li.is-active) {
|
||
background: #3b82f6;
|
||
color: #fff;
|
||
}
|
||
|
||
.workflow-cards {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.workflow-card {
|
||
position: relative;
|
||
width: 180px;
|
||
height: 100px;
|
||
border: 1.5px solid #e5e7eb;
|
||
border-radius: 10px;
|
||
padding: 14px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.workflow-card:hover {
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.workflow-card-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #3b82f6;
|
||
text-align: center;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.workflow-card-actions {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
gap: 1px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
animation: fadeIn 0.2s ease;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.workflow-card-add {
|
||
border-style: dashed;
|
||
border-color: #cbd5e1;
|
||
background: #f8fafc;
|
||
gap: 8px;
|
||
}
|
||
|
||
.workflow-card-add:hover {
|
||
border-color: #3b82f6;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.workflow-card-add-text {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.creation-mode-container {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
.creation-main-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.creation-middle {
|
||
display: grid;
|
||
grid-template-rows: minmax(0, 1fr) 8px minmax(0, 1fr);
|
||
gap: 0;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.creation-middle.form-collapsed {
|
||
grid-template-rows: 1fr;
|
||
}
|
||
|
||
.middle-splitter {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: row-resize;
|
||
user-select: none;
|
||
}
|
||
|
||
.middle-splitter-line {
|
||
width: 100%;
|
||
height: 2px;
|
||
background: #dbe4ef;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.middle-splitter:hover .middle-splitter-line {
|
||
background: #8db4f7;
|
||
}
|
||
|
||
.creation-form-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
width: 100%;
|
||
margin: 0;
|
||
max-width: none;
|
||
padding: 0;
|
||
box-shadow: none;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.simple-form-scroll {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 12px;
|
||
}
|
||
|
||
.simple-creation-form :deep(.el-form-item) {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.creation-history-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
padding: 12px;
|
||
height: 100%;
|
||
}
|
||
|
||
.history-header {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.history-list-placeholder {
|
||
flex: 1;
|
||
overflow: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.history-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.history-item .role {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.history-item .bubble {
|
||
max-width: 86%;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
line-height: 1.6;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.history-item.assistant .bubble {
|
||
background: #f1f5f9;
|
||
color: #0f172a;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.history-item.user {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.history-item.user .bubble {
|
||
background: #3b82f6;
|
||
color: #fff;
|
||
}
|
||
|
||
.creation-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 16px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.creation-header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.creation-form-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding-right: 8px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.creation-form-scroll::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.creation-form-scroll::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.creation-form-scroll::-webkit-scrollbar-thumb:hover {
|
||
background: #94a3b8;
|
||
}
|
||
|
||
.creation-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.node-form-wrapper {
|
||
display: contents;
|
||
}
|
||
|
||
.node-form-section {
|
||
padding: 24px;
|
||
background: #ffffff;
|
||
border: 2px solid #e2e8f0;
|
||
border-radius: 16px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.node-form-section:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
border-color: #cbd5e1;
|
||
}
|
||
|
||
.node-form-title {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
margin-bottom: 24px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 3px solid #3b82f6;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: linear-gradient(90deg, #eff6ff 0%, transparent 100%);
|
||
padding: 12px 16px;
|
||
margin: -24px -24px 24px -24px;
|
||
border-radius: 14px 14px 0 0;
|
||
border-bottom: 3px solid #3b82f6;
|
||
}
|
||
|
||
.node-icon {
|
||
color: #3b82f6;
|
||
font-size: 22px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 20px 24px;
|
||
}
|
||
|
||
.form-item-full {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.form-item-medium {
|
||
grid-column: span 1;
|
||
}
|
||
|
||
.form-item-small {
|
||
grid-column: span 1;
|
||
}
|
||
|
||
.form-grid :deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.form-grid :deep(.el-form-item__label) {
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
margin-bottom: 10px;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.form-grid :deep(.el-form-item__label::before) {
|
||
color: #ef4444 !important;
|
||
}
|
||
|
||
.form-grid :deep(.el-input__wrapper) {
|
||
border-radius: 10px;
|
||
box-shadow: 0 0 0 1px #e2e8f0;
|
||
background-color: #f8fafc;
|
||
transition: all 0.2s ease;
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
.form-grid :deep(.el-input__wrapper:hover) {
|
||
box-shadow: 0 0 0 1px #cbd5e1;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.form-grid :deep(.el-input__wrapper.is-focus) {
|
||
box-shadow: 0 0 0 2px #3b82f6;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.form-grid :deep(.el-input__inner) {
|
||
color: #0f172a;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.form-grid :deep(.el-textarea__inner) {
|
||
border-radius: 10px;
|
||
border: 1px solid #e2e8f0;
|
||
background-color: #f8fafc;
|
||
transition: all 0.2s ease;
|
||
padding: 12px;
|
||
color: #0f172a;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.form-grid :deep(.el-textarea__inner:hover) {
|
||
border-color: #cbd5e1;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.form-grid :deep(.el-textarea__inner:focus) {
|
||
border-color: #3b82f6;
|
||
border-width: 2px;
|
||
background-color: #ffffff;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.form-grid :deep(.el-input-number) {
|
||
width: 100%;
|
||
}
|
||
|
||
.form-grid :deep(.el-input-number .el-input__wrapper) {
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.form-grid :deep(.el-switch) {
|
||
height: 28px;
|
||
}
|
||
|
||
.form-grid :deep(.el-switch__core) {
|
||
height: 28px;
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.form-grid :deep(.el-switch.is-checked .el-switch__core) {
|
||
background-color: #3b82f6;
|
||
}
|
||
|
||
.creation-actions {
|
||
margin-top: 24px;
|
||
padding-top: 24px;
|
||
border-top: 2px solid #e2e8f0;
|
||
}
|
||
|
||
.creation-actions :deep(.el-button) {
|
||
height: 52px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||
border: none;
|
||
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.creation-actions :deep(.el-button:hover) {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
|
||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.form-item-small,
|
||
.form-item-medium {
|
||
grid-column: span 1;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1400px) {
|
||
.panel.side {
|
||
width: 320px;
|
||
}
|
||
|
||
.canvas-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.main {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.panel.side {
|
||
width: 100%;
|
||
max-height: 400px;
|
||
}
|
||
|
||
.top {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.actions {
|
||
width: 100%;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.creation-middle {
|
||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||
}
|
||
}
|
||
|
||
/* 左侧Tab面板样式 */
|
||
.panel.left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.left-tabs {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.left-tabs :deep(.el-tabs__content) {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.left-tabs :deep(.el-tab-pane) {
|
||
height: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
.tree-wrap {
|
||
padding: 14px;
|
||
height: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
.output-config-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.output-config-card {
|
||
padding: 12px;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 12px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.output-config-inline-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.output-config-inline-input {
|
||
width: 100%;
|
||
}
|
||
|
||
.selected-panel {
|
||
padding: 14px;
|
||
height: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* 右侧工作流列表面板样式 */
|
||
.right-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
padding: 14px;
|
||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.workflow-tabs {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.workflow-tabs :deep(.el-tabs__content) {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.workflow-tabs :deep(.el-tab-pane) {
|
||
height: 100%;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.right-panel-header {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.workflow-list-vertical {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.workflow-list-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.workflow-list-scroll::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.workflow-list-scroll::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.workflow-item {
|
||
padding: 12px;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
background: #fff;
|
||
}
|
||
|
||
.workflow-item:hover {
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||
}
|
||
|
||
.workflow-item.active {
|
||
border-color: #3b82f6;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.workflow-item-content {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.workflow-item-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
margin-bottom: 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.workflow-item-desc {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.workflow-item-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.workflow-pagination {
|
||
padding: 12px 0 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
border-top: 1px solid #e5e7eb;
|
||
margin-top: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.skill-selector-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.selected-skill-tag {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.selected-skill-tag .el-tag {
|
||
font-size: 14px;
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
.model-selector-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.selected-model-tag {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.selected-model-tag .el-tag {
|
||
font-size: 14px;
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
.prompt-selector-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.selected-prompt-tag {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.selected-prompt-tag .el-tag {
|
||
font-size: 13px;
|
||
padding: 6px 12px;
|
||
max-width: 100%;
|
||
white-space: normal;
|
||
word-break: break-word;
|
||
height: auto;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* AI 创作输入区域样式 */
|
||
.creation-input-area {
|
||
padding: 20px;
|
||
background: #fff;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.selected-files-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.chat-input-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 24px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.chat-input-container:focus-within {
|
||
border-color: #3b82f6;
|
||
background: #fff;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.input-tools-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tool-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
padding: 0;
|
||
font-size: 18px;
|
||
color: #64748b;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.tool-btn:hover {
|
||
color: #3b82f6;
|
||
background: rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.chat-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.chat-input :deep(.el-input__wrapper) {
|
||
box-shadow: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.chat-input :deep(.el-input__inner) {
|
||
font-size: 14px;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.send-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.send-btn:disabled {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.selected-skill-bottom {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
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%;
|
||
height: 85vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.preview-iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
/* 树节点操作按钮样式 */
|
||
.tree-node-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
/* 对话模型选择器样式 */
|
||
.chat-model-selector {
|
||
min-height: 400px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chat-model-search {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.chat-model-search .el-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.chat-model-list {
|
||
flex: 1;
|
||
max-height: 450px;
|
||
overflow-y: auto;
|
||
margin-bottom: 16px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.chat-model-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.chat-model-list::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.chat-model-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.chat-model-card {
|
||
position: relative;
|
||
padding: 20px;
|
||
border: 2px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.chat-model-card:hover {
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.chat-model-card.selected,
|
||
.chat-model-card.is-chat-model {
|
||
border-color: #67c23a;
|
||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||
box-shadow: 0 4px 12px rgba(103, 194, 58, 0.2);
|
||
}
|
||
|
||
.chat-model-name {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
margin-bottom: 12px;
|
||
padding-right: 28px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.chat-model-url {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
line-height: 1.6;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
padding: 8px 12px;
|
||
background: rgba(148, 163, 184, 0.1);
|
||
border-radius: 6px;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
}
|
||
|
||
.chat-model-card .check-icon {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
font-size: 24px;
|
||
animation: scaleIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes scaleIn {
|
||
from {
|
||
transform: scale(0);
|
||
}
|
||
|
||
to {
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
.chat-model-pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding-top: 16px;
|
||
border-top: 2px solid #e5e7eb;
|
||
}
|
||
|
||
.chat-model-pagination :deep(.el-pagination) {
|
||
justify-content: center;
|
||
}
|
||
</style>
|