Files
admin-ui/src/views/settings/creation/index.vue

5934 lines
174 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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="selectedPrompt" class="selected-prompt-tag">
<el-tag type="success" size="large" closable @close="handleRemovePrompt">
{{ selectedPrompt.prompt ? selectedPrompt.prompt.substring(0, 50) + '...' : '提示词' }}
</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>
<!-- 基础表单 + 模型表单 -->
<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 availableParentParams" :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="selectedPrompt" :node-type="formState.nodeCode" @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 availableParentParams" :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 { PromptItem } from '/@/api/settings/promptManager';
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 selectedParentParam = ref('');
const selectedModel = ref('');
const showSkillSelector = ref(false);
const selectedSkill = ref<SkillItem | null>(null);
const showPromptSelector = ref(false);
const selectedPrompt = ref<PromptItem | null>(null);
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'); // 工作流 Tabuser 或 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 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 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,
isSaveFile: n.properties?.isSaveFileEnabled ?? null,
promptContent: n.properties?.promptData?.prompt || null,
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;
};
// 移除已选择的技能(只更新临时状态)
const handleRemoveSkill = () => {
selectedSkill.value = null;
};
// 处理模型选择确认(只更新临时状态,不保存到节点)
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 = (prompt: PromptItem) => {
selectedPrompt.value = prompt;
};
// 移除已选择的提示词
const handleRemovePrompt = () => {
selectedPrompt.value = null;
};
// 获取对话模型列表
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) => {
const nodeParam: any = {
id: node.id,
nodeCode: node.nodeCode,
name: node.name,
};
// 添加表单配置和值
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,
};
});
}
// 添加其他配置
if (node.config) {
nodeParam.config = { ...node.config };
// 更新 config 中的值
Object.keys(node.config).forEach((key) => {
const fieldKey = `${node.id}_${key}`;
if (creationFormValues[fieldKey] !== undefined) {
nodeParam.config[key] = creationFormValues[fieldKey];
}
});
}
// 添加其他字段
if (node.inputSource) nodeParam.inputSource = node.inputSource;
if (node.modelConfig) nodeParam.modelConfig = node.modelConfig;
if (node.skillName) nodeParam.skillName = node.skillName;
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 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) {
if (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 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;
};
// 判断是否可以选择上级参数(排除表单参数节点和开始节点)
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;
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;
// 重置 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,
});
}
});
}
// 初始化模型选择和模型相关数据(从 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;
}
// 恢复提示词信息
if (e?.properties?.promptData) {
selectedPrompt.value = e.properties.promptData;
} else {
selectedPrompt.value = null;
}
// 恢复对话模式状态
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',
'promptData',
'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 {
delete p.skillName;
}
// 保存提示词选择状态
if (selectedPrompt.value) {
p.promptData = selectedPrompt.value;
} else {
delete p.promptData;
}
// 保存对话模式状态
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> = [];
// 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;
}
}
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,
inputSource: n.inputSource || null,
// 加载提示词和保存文件配置
isSaveFileEnabled: n.isSaveFile ?? false,
promptData: n.promptContent ? { id: 0, prompt: n.promptContent, nodeType: n.nodeCode } : null,
},
};
});
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;
}
// 从节点属性中恢复提示词选择状态
selectedPrompt.value = newElement.properties.promptData || null;
// 从节点属性中恢复对话模式状态
isSaveFileEnabled.value = newElement.properties.isSaveFileEnabled === true;
} else {
// 如果不是节点或没有选中元素,清空状态
selectedModel.value = '';
selectedModelData.value = null;
selectedSkill.value = null;
selectedPrompt.value = null;
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;
}
.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>