Files
admin-ui/src/views/digitalHuman/creation/index.vue
2910410219 2e6af6e06c feat: 添加执行列表功能以支持工作流执行管理
- 新增执行列表相关接口和数据结构,支持获取执行流和执行项
- 更新创作页面以展示执行流和预览功能,提升用户交互体验
- 优化树形结构展示,确保根据执行流动态生成节点
- 引入文件上传功能,支持用户上传文件并获取文件URL
2026-05-09 11:01:32 +08:00

3142 lines
89 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"
>
<template #default="{ data }">
<div class="tree-node">
<span class="ellipsis">{{ data.label }}</span>
<div v-if="data.nodeType === 'html' || data.nodeType === 'image'" 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="currentNodeModelConfig.length > 0" label="选择模型">
<el-select v-model="selectedModel" placeholder="请选择模型" class="w100">
<el-option
v-for="modelConfig in currentNodeModelConfig"
:key="modelConfig.modelName"
:label="modelConfig.modelName"
:value="modelConfig.modelName"
/>
</el-select>
</el-form-item>
<!-- 模型 API Key -->
<el-form-item v-if="selectedModel" label="模型 API Key">
<el-input v-model="dynamicFormValues.modelApiKey" placeholder="请输入模型 API Key" type="password" show-password />
</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 allFormFields" :key="fieldItem.field" :label="fieldItem.label">
<el-input
v-if="fieldItem.type === 'input' && !isSelectField(fieldItem.field)"
v-model="dynamicFormValues[fieldItem.field]"
:placeholder="fieldItem.required ? '必填' : '选填'"
/>
<el-select
v-else-if="fieldItem.type === 'input' && isSelectField(fieldItem.field)"
v-model="dynamicFormValues[fieldItem.field]"
:placeholder="fieldItem.required ? '必填' : '选填'"
class="w100"
>
<el-option v-for="opt in getSelectOptions(fieldItem.field)" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-input-number
v-else-if="fieldItem.type === 'number'"
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.required ? '必填' : '选填'"
/>
<el-switch v-else-if="fieldItem.type === 'switch'" v-model="dynamicFormValues[fieldItem.field]" />
<el-input v-else v-model="dynamicFormValues[fieldItem.field]" :placeholder="fieldItem.required ? '必填' : '选填'" />
</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 class="input-source-quote">
<el-switch
:model-value="sourceNode.quoteOutput === true"
@change="(val: boolean) => updateQuoteOutput(sourceNode.nodeId, val)"
size="small"
active-text="引入输出"
inactive-text=""
/>
</div>
</div>
</div>
</div>
<!-- 显示所有上级节点的输出引用选项 -->
<div v-if="availableParentNodes.length > 0" class="parent-nodes-output">
<div class="parent-nodes-title">上级节点输出</div>
<div v-for="parentNode in availableParentNodes" :key="parentNode.id" class="parent-node-output-item">
<span class="parent-node-name">{{ parentNode.name }}</span>
<el-switch
:model-value="isNodeOutputQuoted(parentNode.id)"
@change="(val: boolean) => toggleNodeOutput(parentNode.id, val)"
size="small"
active-text="引入输出"
inactive-text=""
/>
</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-select>
<el-checkbox v-model="customField.required" class="custom-field-required">必填</el-checkbox>
<el-button type="danger" link @click="removeCustomField(index)">删除</el-button>
</div>
<el-input 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" class="w100" @click="applySelected">应用到当前元素</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-form-panel">
<div class="creation-header">
<div>
<div class="title">{{ currentWorkflowForCreation?.flowName || '内容创作' }}</div>
<div class="sub">{{ currentWorkflowForCreation?.description || '填写表单参数进行内容创作' }}</div>
</div>
<el-button @click="backToCanvas">返回画布</el-button>
</div>
<div class="creation-form-scroll">
<el-form label-position="top" class="creation-form">
<template v-if="currentWorkflowForCreation?.nodeInputParams">
<div v-for="node in currentWorkflowForCreation.nodeInputParams" :key="node.id" class="node-form-wrapper">
<!-- 跳过开始节点 -->
<div v-if="node.nodeCode !== '__start__' && (node.formConfig?.length > 0 || hasVisibleFields(node))" class="node-form-section">
<div class="node-form-title">
<el-icon class="node-icon"><Document /></el-icon>
<span>{{ node.name }}</span>
</div>
<div class="form-grid">
<!-- 自定义表单字段 -->
<template v-if="node.formConfig && node.formConfig.length > 0">
<el-form-item
v-for="field in node.formConfig"
:key="`${node.id}_${field.label}`"
:label="field.label"
:required="field.required"
:class="getFieldClass(field.type)"
>
<el-input
v-if="field.type === 'input'"
v-model="creationFormValues[`${node.id}_${field.label}`]"
:placeholder="field.required ? '必填' : '选填'"
clearable
/>
<el-input-number
v-else-if="field.type === 'number'"
v-model="creationFormValues[`${node.id}_${field.label}`]"
class="w100"
:controls="true"
/>
<el-input
v-else-if="field.type === 'textarea'"
v-model="creationFormValues[`${node.id}_${field.label}`]"
type="textarea"
:rows="4"
:placeholder="field.required ? '必填' : '选填'"
show-word-limit
:maxlength="500"
/>
<el-switch
v-else-if="field.type === 'switch'"
v-model="creationFormValues[`${node.id}_${field.label}`]"
active-text="开启"
inactive-text="关闭"
/>
</el-form-item>
</template>
<!-- 其他配置字段排除已在 formConfig 中的字段 -->
<template v-if="node.config">
<el-form-item
v-for="(value, key) in node.config"
:key="`${node.id}_${key}`"
v-show="
!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'].includes(
String(key)
) && !(node.formConfig || []).some((f: any) => f.label === key || f.label === node.config.fieldMetadata?.[key]?.label)
"
:label="node.config.fieldMetadata?.[key]?.label || String(key)"
:required="node.config.fieldMetadata?.[key]?.required || false"
:class="getFieldClass(node.config.fieldMetadata?.[key]?.type || (typeof value === 'string' ? 'textarea' : 'number'))"
>
<el-input
v-if="typeof value === 'string'"
v-model="creationFormValues[`${node.id}_${key}`]"
:type="node.config.fieldMetadata?.[key]?.type === 'textarea' || String(value).length > 50 ? 'textarea' : 'text'"
:rows="4"
:placeholder="node.config.fieldMetadata?.[key]?.required ? '必填' : '选填'"
clearable
:show-word-limit="node.config.fieldMetadata?.[key]?.type === 'textarea'"
:maxlength="500"
/>
<el-input-number
v-else-if="typeof value === 'number'"
v-model="creationFormValues[`${node.id}_${key}`]"
class="w100"
:controls="true"
:precision="2"
:step="0.1"
/>
<el-switch
v-else-if="typeof value === 'boolean'"
v-model="creationFormValues[`${node.id}_${key}`]"
active-text="开启"
inactive-text="关闭"
/>
</el-form-item>
</template>
</div>
</div>
</div>
</template>
<el-empty v-else description="暂无表单配置" :image-size="100" />
</el-form>
</div>
<!-- AI 创作输入区域 -->
<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
type="primary"
:icon="Promotion"
:loading="isCreating"
:disabled="!userInput.trim()"
@click="sendMessage"
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 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="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 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="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 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>
<!-- 保存工作流对话框 -->
<el-dialog
v-model="saveDialogVisible"
:title="currentEditingWorkflowId ? '编辑工作流' : '保存工作流'"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="saveForm" label-position="top">
<el-form-item label="工作流名称" required>
<el-input v-model="saveForm.flowName" placeholder="请输入工作流名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="工作流描述">
<el-input v-model="saveForm.description" type="textarea" :rows="4" placeholder="请输入工作流描述(选填)" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmSaveWorkflow" :loading="saving">{{ currentEditingWorkflowId ? '确定更新' : '确定保存' }}</el-button>
</template>
</el-dialog>
<!-- 技能选择器 -->
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
<!-- 创作技能选择器 -->
<SkillSelector v-model="showCreationSkillSelector" :default-skill="selectedCreationSkill" @confirm="handleCreationSkillConfirm" />
<!-- 预览弹窗 -->
<el-dialog v-model="previewDialogVisible" title="预览" width="90%" :close-on-click-modal="false" destroy-on-close>
<div class="preview-container">
<iframe v-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 { Document, Plus, Paperclip, MagicStick, Promotion } 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 type { SkillItem } from '/@/api/digitalHuman/skill';
import {
downloadToFile,
getExecutionList,
getNodeLibraryList,
getWorkflowList,
getWorkflowDetail,
updateWorkflow,
deleteWorkflow,
saveWorkflow,
executeFlow,
type ExecutionTreeItem,
type NodeLibraryFormItem,
type NodeLibraryGroup,
type WorkflowItem,
type ExecuteFlowParams,
} from '/@/api/digitalHuman/creation';
import { uploadFile } from '/@/api/common/upload';
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;
}
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 }>>([]);
const selectedParentParam = ref('');
const selectedModel = ref('');
const showSkillSelector = ref(false);
const selectedSkill = ref<SkillItem | null>(null);
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 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 isCreating = ref(false);
// 预览相关状态
const previewDialogVisible = ref(false);
const previewUrl = ref('');
// 会话ID管理存储在 sessionStorage 中)
const getSessionId = () => {
let sessionId = sessionStorage.getItem('ai_creation_session_id');
if (!sessionId) {
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem('ai_creation_session_id', sessionId);
}
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 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 currentModelForm = computed<NodeLibraryFormItem[]>(() => {
if (!selectedModel.value) return [];
const modelConfig = currentNodeModelConfig.value.find((m: any) => m.modelName === selectedModel.value);
return modelConfig?.modelForm || [];
});
// 合并基础表单和模型表单
const allFormFields = computed<NodeLibraryFormItem[]>(() => {
return [...currentNodeForm.value, ...currentModelForm.value];
});
// 获取可用的上级节点参数
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 }> = [];
// 遍历所有上级节点
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;
// 只添加自定义字段formConfig
if (parentProps.formConfig && Array.isArray(parentProps.formConfig)) {
parentProps.formConfig.forEach((field: any) => {
if (field.label) {
params.push({
label: `${parentNodeName}.${field.label}`,
value: `\${${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,
formConfig: n.properties?.formConfig || null,
modelConfig: n.properties?.modelConfig || 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',
children: (f.items || []).map((item, ii) => ({
id: `item-${di}-${fi}-${ii}`,
label: item.label || `作品${ii + 1}`,
nodeType: 'title',
children: [
{
id: `html-${di}-${fi}-${ii}`,
label: 'HTML',
nodeType: 'html' as const,
fileUrl: item.content,
},
],
})),
})),
}));
const getList = async () => {
treeLoading.value = true;
try {
const res = await getExecutionList({ errorMode: 'page' });
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({ errorMode: 'page' });
nodeLibraryGroups.value = res.data?.groups || [];
} catch {
nodeLibraryGroups.value = [];
}
};
// 获取工作流列表
const fetchWorkflowList = async () => {
workflowListLoading.value = true;
try {
const res = await getWorkflowList({ errorMode: 'page' });
// 分别处理用户工作流和模板工作流
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 = () => {
// 切换回画布编辑模式
isCreationMode.value = false;
currentWorkflowForCreation.value = null;
// 清空当前编辑状态
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;
// 将技能名称保存到节点属性中(只保存 skillName
if (selectedElement.value && logicFlowInstance.value) {
const nodeData = logicFlowInstance.value.getNodeModelById(selectedElement.value.id);
if (nodeData) {
logicFlowInstance.value.setProperties(selectedElement.value.id, {
...nodeData.properties,
skillName: skill.name,
});
// 同步更新 selectedElement
selectedElement.value.properties.skillName = skill.name;
}
}
};
// 移除已选择的技能
const handleRemoveSkill = () => {
selectedSkill.value = null;
// 从节点属性中移除技能信息
if (selectedElement.value && logicFlowInstance.value) {
const nodeData = logicFlowInstance.value.getNodeModelById(selectedElement.value.id);
if (nodeData) {
const props = { ...nodeData.properties };
delete props.skillName;
logicFlowInstance.value.setProperties(selectedElement.value.id, props);
// 同步更新 selectedElement
delete selectedElement.value.properties.skillName;
}
}
};
// 使用工作流
const useWorkflow = async (workflow: WorkflowItem) => {
try {
// 调用详情接口获取最新的工作流数据
const res = await getWorkflowDetail(workflow.id, { errorMode: 'page' });
if (res.data) {
// 切换到创作模式
isCreationMode.value = true;
currentWorkflowForCreation.value = res.data;
// 初始化创作表单的值
Object.keys(creationFormValues).forEach((key) => delete creationFormValues[key]);
// 根据 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) => {
const fieldKey = `${node.id}_${field.label}`;
creationFormValues[fieldKey] = field.value || '';
});
}
// 初始化其他配置字段(从 config 中读取)
if (node.config) {
Object.keys(node.config).forEach((key) => {
if (!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'].includes(key)) {
const fieldKey = `${node.id}_${key}`;
creationFormValues[fieldKey] = node.config[key];
}
});
}
});
}
ElMessage.success(`已进入创作模式`);
} else {
ElMessage.warning('该工作流没有内容');
}
} catch (error) {
// 后端错误会自动显示
}
};
// 编辑工作流
const editWorkflow = async (workflow: WorkflowItem) => {
try {
// 调用详情接口获取最新的工作流数据
const res = await getWorkflowDetail(workflow.id, { errorMode: 'page' });
if (res.data?.flowContent) {
// 切换回画布编辑模式
isCreationMode.value = false;
currentWorkflowForCreation.value = null;
// 等待 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 () => {
isCreationMode.value = false;
currentWorkflowForCreation.value = null;
// 等待 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, { errorMode: 'page' });
ElMessage.success('工作流删除成功');
// 如果删除的是当前正在编辑的工作流,清空编辑状态
if (currentEditingWorkflowId.value === workflow.id) {
currentEditingWorkflowId.value = null;
saveForm.flowName = '';
saveForm.description = '';
}
// 刷新工作流列表
await fetchWorkflowList();
} catch (error) {
if (error === 'cancel') {
// 用户取消操作,不显示错误
}
}
};
// 处理文件选择
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 sendMessage = async () => {
if (!userInput.value.trim()) {
ElMessage.warning('请输入创作需求');
return;
}
if (!currentWorkflowForCreation.value) {
ElMessage.warning('请先选择一个工作流');
return;
}
isCreating.value = true;
try {
// 1. 先上传文件到 OSS获取文件 URL
const fileUrls: string[] = [];
if (selectedFiles.value.length > 0) {
for (const file of selectedFiles.value) {
try {
const uploadRes = await uploadFile(file, { errorMode: 'page' });
// 拼接完整的文件地址
const fullUrl = uploadRes.data.fileAddressPrefix
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
: uploadRes.data.fileURL;
fileUrls.push(fullUrl);
} catch (error) {
ElMessage.error(`文件 ${file.name} 上传失败`);
throw error;
}
}
}
// 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) => {
const fieldKey = `${node.id}_${field.label}`;
return {
...field,
value: 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 数组
};
// 5. 调用执行接口(不再使用 FormData直接传 JSON
await executeFlow(params, { errorMode: 'page' });
ElMessage.success('创作完成!');
// 6. 清空输入
userInput.value = '';
selectedFiles.value = [];
selectedCreationSkill.value = null;
} catch (error) {
ElMessage.error('创作失败,请重试');
} finally {
isCreating.value = false;
}
};
// 判断节点是否有可见字段
const hasVisibleFields = (node: any) => {
if (!node.config) return false;
const excludeKeys = ['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'];
return Object.keys(node.config).some((key) => !excludeKeys.includes(key));
};
// 根据字段类型返回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 previewNode = (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
const url = buildAssetUrl(d.fileUrl);
if (!url) return ElMessage.warning('当前节点没有可用预览地址');
previewUrl.value = url;
previewDialogVisible.value = true;
};
// 下载节点
const downloadNode = async (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
if (!d.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
try {
// 下载失败时希望展示更贴近页面语义的提示,因此改为 page 模式。
const r = await downloadToFile({ fileURL: d.fileUrl }, { errorMode: 'page' });
const blob = r instanceof Blob ? r : r?.data;
if (!(blob instanceof Blob)) throw new Error('invalid blob');
const name = decodeURIComponent(d.fileUrl.split('/').pop() || `${d.label}.${d.nodeType === 'html' ? 'html' : 'png'}`);
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('下载成功');
} catch {
// 下载接口使用 errorMode: 'page',后端错误会自动显示
}
};
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 = (field: string) => {
return field === 'size' || field === 'resolution';
};
// 获取下拉选项
const getSelectOptions = (field: string) => {
if (field === 'size') {
return [
{ label: '1024x1024', value: '1024x1024' },
{ label: '512x512', value: '512x512' },
{ label: '256x256', value: '256x256' },
];
}
if (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 });
};
// 删除自定义字段
const removeCustomField = (index: number) => {
customFields.value.splice(index, 1);
};
// 判断是否可以添加自定义字段(排除判断节点、开始节点)
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;
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}`);
};
// 更新指定节点的 quoteOutput 状态
const updateQuoteOutput = (nodeId: string, enabled: boolean) => {
if (!selectedElement.value || !nodeId) 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;
// 更新 quoteOutput
inputSource[nodeIndex] = {
...inputSource[nodeIndex],
quoteOutput: enabled,
};
lf.setProperties(selectedElement.value.id, {
...currentProps,
inputSource,
});
// 只更新 properties不重新赋值整个 selectedElement避免触发 watch 重置表单
selectedElement.value.properties = {
...currentProps,
inputSource,
};
syncDsl();
// 获取节点名称用于提示
const nodeName = formatParamReference(`\${${nodeId}.field}`).split('.')[0];
ElMessage.success(enabled ? `已开启引入 ${nodeName} 的输出` : `已关闭引入 ${nodeName} 的输出`);
};
// 获取所有上级节点(用于显示输出引用选项)
const availableParentNodes = 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 || [];
// 获取已经引用了字段的节点ID列表
const inputSource = selectedElement.value.properties?.inputSource;
const nodesWithFields = new Set<string>();
if (Array.isArray(inputSource)) {
inputSource.forEach((item: any) => {
if (item.field && item.field.length > 0) {
nodesWithFields.add(item.nodeId);
}
});
}
// 递归查找所有上级节点
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 parentNodes = allParentIds
.map((parentId) => {
const parentNode = nodes.find((n) => n.id === parentId);
if (!parentNode) return null;
const nodeName = typeof parentNode.text === 'string' ? parentNode.text : parentNode.text?.value || '';
const nodeCode = String(parentNode.properties?.nodeCode || '').toLowerCase();
const nodeText = nodeName.toLowerCase();
// 判断是否为判断节点
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeText.includes(k));
// 判断是否为开始节点
const isStart = nodeCode === START_NODE_CODE || nodeText === START_NODE_TEXT.toLowerCase();
// 排除判断节点、开始节点、以及已经引用了字段的节点
if (isJudge || isStart || nodesWithFields.has(parentId)) return null;
return {
id: parentId,
name: nodeName,
isJudge: false,
};
})
.filter(Boolean);
return parentNodes as Array<{ id: string; name: string; isJudge: boolean }>;
});
// 检查节点输出是否被引用
const isNodeOutputQuoted = (nodeId: string): boolean => {
if (!selectedElement.value) return false;
const inputSource = selectedElement.value.properties?.inputSource;
if (!Array.isArray(inputSource)) return false;
const node = inputSource.find((item: any) => item.nodeId === nodeId);
return node?.quoteOutput === true;
};
// 切换节点输出引用
const toggleNodeOutput = (nodeId: string, enabled: boolean) => {
if (!selectedElement.value || !nodeId) 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) {
// 节点已存在,更新 quoteOutput
inputSource[nodeIndex] = {
...inputSource[nodeIndex],
quoteOutput: enabled,
};
} else {
// 节点不存在,创建新的引用(只引用输出,不引用字段)
inputSource.push({
nodeId: nodeId,
field: [],
quoteOutput: enabled,
});
}
lf.setProperties(selectedElement.value.id, {
...currentProps,
inputSource,
});
// 只更新 properties不重新赋值整个 selectedElement避免触发 watch 重置表单
selectedElement.value.properties = {
...currentProps,
inputSource,
};
syncDsl();
// 获取节点名称用于提示
const parentNode = availableParentNodes.value.find((p) => p.id === nodeId);
const nodeName = parentNode?.name || '节点';
ElMessage.success(enabled ? `已开启引入 ${nodeName} 的输出` : `已关闭引入 ${nodeName} 的输出`);
};
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 || '');
// 重置 dynamicFormValues不删除键保持响应式
for (const key in dynamicFormValues) {
dynamicFormValues[key] = '';
}
// 获取当前节点的基础表单字段(直接从 nodeSchemaMap 获取,避免响应式延迟)
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
dynamicFormValues[fieldConfig.field] = fieldConfig.value;
} else {
// 自定义字段:加载到 customFields
customFields.value.push({
label: fieldConfig.label || '',
value: fieldConfig.value || '',
type: fieldConfig.type || 'input',
required: fieldConfig.required || false,
});
}
});
}
// 初始化模型选择和模型相关数据(从 modelConfig
const modelConfig = e?.properties?.modelConfig;
if (modelConfig && typeof modelConfig === 'object') {
// 从 modelConfig 加载
selectedModel.value = modelConfig.modelName || '';
dynamicFormValues.modelApiKey = modelConfig.modelApiKey || '';
// 加载模型表单数据(数组格式)
if (modelConfig.modelForm && Array.isArray(modelConfig.modelForm)) {
modelConfig.modelForm.forEach((fieldConfig: any) => {
if (fieldConfig.field) {
dynamicFormValues[fieldConfig.field] = fieldConfig.value;
}
});
}
} else {
// 兼容旧数据格式
selectedModel.value = String(e?.properties?.selectedModel || '');
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;
}
// 初始化所有表单字段(基础 + 模型)- 只设置还没有值的字段
allFormFields.value.forEach((fieldItem) => {
// 如果已经从 formConfig 或 modelConfig 加载过,跳过
if (dynamicFormValues[fieldItem.field] !== undefined && dynamicFormValues[fieldItem.field] !== '') {
return;
}
// 使用默认值
if (fieldItem.default !== undefined) {
dynamicFormValues[fieldItem.field] = fieldItem.default;
return;
}
// 根据字段类型设置默认值
if (fieldItem.type === 'switch') {
dynamicFormValues[fieldItem.field] = false;
} else if (fieldItem.type === 'number') {
// 所有数字字段默认为 1
dynamicFormValues[fieldItem.field] = 1;
} 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 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', 'inputSource', 'formConfig', 'skillName', 'width', 'height'];
Object.keys(p).forEach((key) => {
if (!keysToKeep.includes(key)) {
delete p[key];
}
});
// 保存选中的模型和模型相关配置
if (selectedModel.value) {
// 获取当前模型的表单字段
const currentModelFields = currentModelForm.value;
const modelForm: Array<any> = [];
// 保存模型相关的字段配置和值
currentModelFields.forEach((fieldItem) => {
const value = dynamicFormValues[fieldItem.field];
modelForm.push({
type: fieldItem.type,
field: fieldItem.field,
label: fieldItem.label,
value: value !== undefined && value !== null ? value : fieldItem.default || '',
required: fieldItem.required || false,
});
});
// 保存到 modelConfig
p.modelConfig = {
modelName: selectedModel.value,
modelApiKey: dynamicFormValues.modelApiKey || '',
modelForm: modelForm,
};
} else {
// 如果没有选择模型,删除 modelConfig
delete p.modelConfig;
}
// 不再保存基础字段到根级别,所有字段都通过 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 value = dynamicFormValues[fieldItem.field];
formConfig.push({
type: fieldItem.type,
field: fieldItem.field,
label: fieldItem.label,
value: value !== undefined && value !== null ? value : fieldItem.default || '',
required: fieldItem.required || false,
});
});
// 2. 添加自定义字段
customFields.value.forEach((field) => {
formConfig.push({
type: field.type,
field: field.label, // 自定义字段使用 label 作为 field
label: field.label,
value: field.value,
required: field.required,
});
});
// 保存 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();
// 静默更新,不显示提示
} catch (error) {
ElMessage.error('应用配置失败,请查看控制台');
}
};
// 保存工作流
const saveWorkflowAction = async () => {
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,
},
{ errorMode: 'page' }
);
ElMessage.success('工作流更新成功');
} else {
// 创建新工作流
await saveWorkflow(
{
flowName: saveForm.flowName,
description: saveForm.description,
flowContent: workflowDsl.value,
},
{ errorMode: 'page' }
);
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();
};
// 从后端 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,
},
};
});
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('工作流加载失败');
}
};
onMounted(async () => {
await getList();
await nextTick();
initLogicFlow();
await getNodeLibrary();
await fetchWorkflowList();
});
onBeforeUnmount(() => {
logicFlowInstance.value?.destroy();
logicFlowInstance.value = null;
});
</script>
<style scoped lang="scss">
.creation-page {
height: calc(100vh - 100px);
display: grid;
grid-template-columns: 320px minmax(0, 1fr) 280px;
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: flex;
flex: 1;
min-height: 0;
height: 100%;
}
.creation-form-panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* 画布模式画布和侧边栏并排 */
.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;
}
.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%;
}
.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;
}
}
.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;
}
.creation-form-panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.creation-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.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);
}
@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;
}
}
/* 左侧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;
}
/* 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;
}
/* 预览弹窗样式 */
.preview-container {
width: 100%;
height: 70vh;
display: flex;
align-items: center;
justify-content: center;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
background: #fff;
}
/* 树节点操作按钮样式 */
.tree-node-actions {
display: flex;
gap: 4px;
margin-left: 8px;
}
</style>