- 新增工作流相关接口和类型定义,包括创建、更新、删除和获取工作流列表的功能 - 更新界面,支持工作流的展示和操作,允许用户保存和管理工作流 - 优化动态表单,支持根据工作流节点动态生成表单项 - 修改按钮事件名称以提高代码可读性
2187 lines
63 KiB
Vue
2187 lines
63 KiB
Vue
<template>
|
||
<div class="creation-page" :class="{ 'creation-mode': isCreationMode }">
|
||
<!-- 工作空间 - 只在创作模式显示 -->
|
||
<div v-show="isCreationMode" class="panel left" :class="{ collapsed: workspaceCollapsed }">
|
||
<div class="panel-header">
|
||
<div v-show="!workspaceCollapsed" class="title">工作空间</div>
|
||
<el-tooltip :content="workspaceCollapsed ? '展开工作空间' : '收起工作空间'" placement="right">
|
||
<el-button :type="workspaceCollapsed ? 'primary' : 'default'" :circle="workspaceCollapsed" @click="toggleWorkspace" class="collapse-btn">
|
||
<el-icon :size="20" v-if="workspaceCollapsed"><Expand /></el-icon>
|
||
<el-icon :size="18" v-else><Fold /></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
<div v-show="!workspaceCollapsed" 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="handleNodeClick"
|
||
>
|
||
<template #default="{ data }">
|
||
<div class="tree-node">
|
||
<span class="ellipsis">{{ data.label }}</span>
|
||
<el-button
|
||
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
|
||
type="primary"
|
||
link
|
||
class="tree-download"
|
||
@click.stop="downloadNode(data)"
|
||
>下载</el-button
|
||
>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
</div>
|
||
</div>
|
||
<div class="editor-shell">
|
||
<div class="panel top">
|
||
<div>
|
||
<div class="title">作品创作工作流</div>
|
||
<div class="sub">从左侧节点库拖拽节点到画布,连接节点构建工作流,右侧配置节点属性。</div>
|
||
</div>
|
||
<div class="actions">
|
||
<el-button @click="resetFlow">清空画布</el-button>
|
||
<el-button type="primary" @click="saveWorkflowAction" :loading="saving">保存工作流</el-button>
|
||
</div>
|
||
</div>
|
||
<!-- 工作流列表展示区域 -->
|
||
<div class="panel workflow-list-panel" :class="{ collapsed: workflowListCollapsed }">
|
||
<div class="workflow-list-header">
|
||
<div class="title-sm">我的工作流</div>
|
||
<div class="header-actions">
|
||
<el-button type="primary" link @click="refreshWorkflowList">刷新</el-button>
|
||
<el-tooltip :content="workflowListCollapsed ? '展开列表' : '收起列表'" placement="left">
|
||
<el-button type="primary" @click="toggleWorkflowList" class="collapse-btn-workflow">
|
||
<el-icon :size="18" v-if="workflowListCollapsed"><ArrowDown /></el-icon>
|
||
<el-icon :size="18" v-else><ArrowUp /></el-icon>
|
||
<span style="margin-left: 4px">{{ workflowListCollapsed ? '展开' : '收起' }}</span>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
<div v-show="!workflowListCollapsed" class="workflow-list-container" v-loading="workflowListLoading">
|
||
<el-empty v-if="!workflowListLoading && workflowList.length === 0" description="暂无工作流" :image-size="60" />
|
||
<template v-else>
|
||
<div class="workflow-cards">
|
||
<div
|
||
v-for="workflow in paginatedWorkflowList"
|
||
:key="workflow.id"
|
||
class="workflow-card"
|
||
@mouseenter="hoveredWorkflowId = workflow.id"
|
||
@mouseleave="hoveredWorkflowId = null"
|
||
>
|
||
<div class="workflow-card-name">{{ workflow.flowName }}</div>
|
||
<div v-if="hoveredWorkflowId === workflow.id" class="workflow-card-actions">
|
||
<el-button type="primary" size="small" @click.stop="useWorkflow(workflow)">使用</el-button>
|
||
<el-button size="small" @click.stop="editWorkflow(workflow)">编辑</el-button>
|
||
<el-button type="danger" size="small" @click.stop="deleteWorkflowAction(workflow)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="workflow-card workflow-card-add" @click="addNewWorkflow">
|
||
<el-icon :size="24" color="#94a3b8"><Plus /></el-icon>
|
||
<div class="workflow-card-add-text">添加工作流</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="workflowList.length > workflowPagination.pageSize" class="workflow-pagination">
|
||
<el-pagination
|
||
:current-page="workflowPagination.pageNum"
|
||
:page-size="workflowPagination.pageSize"
|
||
:total="workflowList.length"
|
||
layout="prev, pager, next"
|
||
@current-change="handlePageChange"
|
||
small
|
||
/>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<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.config?.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.config?.formConfig && node.config.formConfig.length > 0">
|
||
<el-form-item
|
||
v-for="field in node.config.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>
|
||
|
||
<!-- 其他配置字段(使用 fieldMetadata 中的中文名称) -->
|
||
<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))
|
||
"
|
||
: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>
|
||
<div class="creation-actions">
|
||
<el-button type="primary" size="large" class="w100">开始创作</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 画布编辑模式 -->
|
||
<div v-show="!isCreationMode" class="panel canvas-panel">
|
||
<div class="meta">
|
||
<span>工作流画布</span><span>节点 {{ flowDsl.nodes.length }} / 连线 {{ flowDsl.edges.length }}</span>
|
||
</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 v-show="!isCreationMode" class="panel side">
|
||
<div class="title-sm">当前选中元素</div>
|
||
<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>
|
||
<!-- 基础表单 + 模型表单 -->
|
||
<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 && Object.keys(currentInputSource).length > 0" class="input-source-list">
|
||
<div v-for="(value, key) in currentInputSource" :key="key" class="input-source-item">
|
||
<div class="input-source-content">
|
||
<div class="input-source-label">
|
||
<span class="input-source-key">{{ key }}</span>
|
||
<span class="input-source-arrow">←</span>
|
||
<span class="input-source-ref">{{ formatParamReference(value) }}</span>
|
||
</div>
|
||
<div class="input-source-raw">{{ value }}</div>
|
||
</div>
|
||
<el-button type="danger" link size="small" @click="removeInputSource(String(key))">删除</el-button>
|
||
</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">
|
||
<div class="custom-field-row">
|
||
<el-input v-model="customField.label" placeholder="字段名" class="custom-field-input" />
|
||
<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>
|
||
</div>
|
||
</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>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { Plus, Document, Expand, Fold, ArrowDown, ArrowUp } 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 {
|
||
downloadToFile,
|
||
getCreationList,
|
||
getNodeLibraryList,
|
||
getWorkflowList,
|
||
getWorkflowDetail,
|
||
updateWorkflow,
|
||
deleteWorkflow,
|
||
saveWorkflow,
|
||
type CreationListParams,
|
||
type CreationTreeItem,
|
||
type NodeLibraryFormItem,
|
||
type NodeLibraryGroup,
|
||
type WorkflowItem,
|
||
} from '/@/api/digitalHuman/creation';
|
||
|
||
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 saving = ref(false);
|
||
const saveDialogVisible = ref(false);
|
||
const saveForm = reactive({
|
||
flowName: '',
|
||
description: '',
|
||
});
|
||
const workflowList = ref<WorkflowItem[]>([]);
|
||
const workflowListLoading = ref(false);
|
||
const hoveredWorkflowId = ref<string | null>(null);
|
||
const currentEditingWorkflowId = ref<string | null>(null);
|
||
const isCreationMode = ref(false); // 是否处于创作模式
|
||
const currentWorkflowForCreation = ref<any>(null); // 当前用于创作的工作流数据
|
||
const creationFormValues = reactive<Record<string, any>>({}); // 创作表单的值
|
||
const workflowListCollapsed = ref(false); // 工作流列表是否收起
|
||
const workspaceCollapsed = ref(false); // 工作空间是否收起
|
||
const workflowPagination = reactive({
|
||
pageNum: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
});
|
||
// 格式化参数引用显示
|
||
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(() => {
|
||
return selectedElement.value?.properties?.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>>({});
|
||
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 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 || {};
|
||
|
||
// 获取上级节点的所有属性(排除 nodeCode、width、height)
|
||
Object.keys(parentProps).forEach((key) => {
|
||
if (key !== 'nodeCode' && key !== 'width' && key !== 'height') {
|
||
params.push({
|
||
label: `${parentNodeName}.${key}`,
|
||
value: `\${${parentId}.${key}}`,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
return params;
|
||
});
|
||
const treeProps = { children: 'children', label: 'label' };
|
||
const queryParams: CreationListParams = { keyword: '', pageNum: 1, pageSize: 10 };
|
||
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',
|
||
config: {
|
||
...n.properties,
|
||
x: n.x || 0,
|
||
y: n.y || 0,
|
||
},
|
||
inputSource: n.properties?.inputSource || null,
|
||
formConfig: n.properties?.formConfig || null,
|
||
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: CreationTreeItem[]): TreeNode[] =>
|
||
tree.map((d, di) => ({
|
||
id: `date-${di}`,
|
||
label: d.createdDate,
|
||
nodeType: 'date',
|
||
children: (d.contentTypes || []).map((c, ci) => ({
|
||
id: `content-${di}-${ci}`,
|
||
label: c.contentType,
|
||
nodeType: 'contentType',
|
||
children: (c.themes || []).map((t, ti) => ({
|
||
id: `theme-${di}-${ci}-${ti}`,
|
||
label: t.theme,
|
||
nodeType: 'theme',
|
||
children: (t.titles || []).map((title, i) => ({
|
||
id: `title-${di}-${ci}-${ti}-${i}`,
|
||
label: title.title || `作品${i + 1}`,
|
||
nodeType: 'title',
|
||
children: [
|
||
...(title.htmlFileUrl
|
||
? [{ id: `html-${di}-${ci}-${ti}-${i}`, label: 'HTML', nodeType: 'html' as const, fileUrl: title.htmlFileUrl }]
|
||
: []),
|
||
...(title.imageUrls || []).map((img, ii) => ({
|
||
id: `img-${di}-${ci}-${ti}-${i}-${ii}`,
|
||
label: img.name || `图片 ${ii + 1}`,
|
||
nodeType: 'image' as const,
|
||
fileUrl: img.url,
|
||
})),
|
||
],
|
||
})),
|
||
})),
|
||
})),
|
||
}));
|
||
const getList = async () => {
|
||
treeLoading.value = true;
|
||
try {
|
||
// 这里改成 page,表示列表加载失败的文案由当前页面自己决定。
|
||
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined }, { errorMode: 'page' });
|
||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
|
||
} catch {
|
||
treeNodes.value = [];
|
||
imgAddressPrefix.value = '';
|
||
// 既然这个请求声明由页面自己处理错误,这里保留页面可读性更强的业务文案。
|
||
ElMessage.error('获取作品创作列表失败');
|
||
} finally {
|
||
treeLoading.value = false;
|
||
}
|
||
};
|
||
const getNodeLibrary = async () => {
|
||
try {
|
||
const res = await getNodeLibraryList({ errorMode: 'page' });
|
||
nodeLibraryGroups.value = res.data?.groups || [];
|
||
} catch {
|
||
nodeLibraryGroups.value = [];
|
||
ElMessage.error('获取工作流节点库失败');
|
||
}
|
||
};
|
||
// 获取工作流列表
|
||
const fetchWorkflowList = async () => {
|
||
workflowListLoading.value = true;
|
||
try {
|
||
const res = await getWorkflowList({ errorMode: 'page' });
|
||
workflowList.value = res.data?.list || [];
|
||
workflowPagination.total = res.data?.list?.length || 0;
|
||
} catch {
|
||
workflowList.value = [];
|
||
ElMessage.error('获取工作流列表失败');
|
||
} finally {
|
||
workflowListLoading.value = false;
|
||
}
|
||
};
|
||
// 刷新工作流列表
|
||
const refreshWorkflowList = () => {
|
||
fetchWorkflowList();
|
||
};
|
||
// 切换工作流列表展开/收起
|
||
const toggleWorkflowList = () => {
|
||
workflowListCollapsed.value = !workflowListCollapsed.value;
|
||
};
|
||
// 切换工作空间展开/收起
|
||
const toggleWorkspace = () => {
|
||
workspaceCollapsed.value = !workspaceCollapsed.value;
|
||
};
|
||
// 工作流分页显示的列表
|
||
const paginatedWorkflowList = computed(() => {
|
||
const start = (workflowPagination.pageNum - 1) * workflowPagination.pageSize;
|
||
const end = start + workflowPagination.pageSize;
|
||
return workflowList.value.slice(start, end);
|
||
});
|
||
// 处理分页变化
|
||
const handlePageChange = (page: number) => {
|
||
workflowPagination.pageNum = page;
|
||
};
|
||
// 使用工作流
|
||
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) => {
|
||
if (node.config?.formConfig && Array.isArray(node.config.formConfig)) {
|
||
node.config.formConfig.forEach((field: any) => {
|
||
const fieldKey = `${node.id}_${field.label}`;
|
||
creationFormValues[fieldKey] = field.value || '';
|
||
});
|
||
}
|
||
|
||
// 初始化其他配置字段
|
||
if (node.config) {
|
||
Object.keys(node.config).forEach((key) => {
|
||
if (!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource'].includes(key)) {
|
||
const fieldKey = `${node.id}_${key}`;
|
||
creationFormValues[fieldKey] = node.config[key];
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
ElMessage.success(`已进入创作模式:${res.data.flowName}`);
|
||
} else {
|
||
ElMessage.warning('该工作流没有内容');
|
||
}
|
||
} catch (error) {
|
||
ElMessage.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;
|
||
saveForm.description = res.data.description || '';
|
||
ElMessage.success(`正在编辑工作流:${res.data.flowName}`);
|
||
} else {
|
||
ElMessage.warning('该工作流没有内容');
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('加载工作流失败');
|
||
}
|
||
};
|
||
// 添加新工作流
|
||
const addNewWorkflow = () => {
|
||
// 切换回画布编辑模式
|
||
isCreationMode.value = false;
|
||
currentWorkflowForCreation.value = null;
|
||
|
||
nextTick(() => {
|
||
resetFlow();
|
||
});
|
||
|
||
currentEditingWorkflowId.value = null;
|
||
saveForm.flowName = '';
|
||
saveForm.description = '';
|
||
ElMessage.info('已清空画布,可以开始创建新工作流');
|
||
};
|
||
// 返回画布编辑模式
|
||
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();
|
||
}
|
||
|
||
ElMessage.info('已返回画布编辑模式');
|
||
};
|
||
// 判断节点是否有可见字段
|
||
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 deleteWorkflowAction = async (workflow: WorkflowItem) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确定要删除工作流"${workflow.flowName}"吗?此操作不可恢复。`, '删除确认', {
|
||
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') {
|
||
ElMessage.error('删除工作流失败');
|
||
}
|
||
}
|
||
};
|
||
const handleNodeClick = (d: TreeNode) => {
|
||
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
|
||
const url = buildAssetUrl(d.fileUrl);
|
||
if (!url) return ElMessage.warning('当前节点没有可用预览地址');
|
||
window.open(url, '_blank');
|
||
};
|
||
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 {
|
||
// 下载接口已经声明由页面自己处理错误,所以这里只会出现一条下载失败提示。
|
||
ElMessage.error('下载失败');
|
||
}
|
||
};
|
||
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;
|
||
|
||
// 排除表单参数节点
|
||
if (nodeCode === 'form' || text.includes('表单参数')) 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;
|
||
// 直接保存到当前节点的 inputSource 属性
|
||
if (selectedElement.value) {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
|
||
const currentProps = selectedElement.value.properties || {};
|
||
const inputSource = currentProps.inputSource || {};
|
||
|
||
// 提取参数名
|
||
const match = value.match(/\$\{[^.]+\.(.+)\}/);
|
||
const paramName = match ? match[1] : value;
|
||
|
||
// 保存到 inputSource
|
||
inputSource[paramName] = value;
|
||
|
||
lf.setProperties(selectedElement.value.id, {
|
||
...currentProps,
|
||
inputSource,
|
||
});
|
||
|
||
// 更新 selectedElement 以触发界面刷新
|
||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
||
if (n) {
|
||
selectedElement.value = {
|
||
id: n.id,
|
||
type: n.type,
|
||
kind: 'node',
|
||
properties: n.properties || {},
|
||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
||
};
|
||
}
|
||
|
||
syncDsl();
|
||
ElMessage.success(`已添加上级参数:${paramName}`);
|
||
}
|
||
|
||
selectedParentParam.value = '';
|
||
};
|
||
// 删除 inputSource 中的参数
|
||
const removeInputSource = (key: string) => {
|
||
if (!selectedElement.value) return;
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf) return;
|
||
|
||
const currentProps = selectedElement.value.properties || {};
|
||
const inputSource = { ...(currentProps.inputSource || {}) };
|
||
delete inputSource[key];
|
||
|
||
lf.setProperties(selectedElement.value.id, {
|
||
...currentProps,
|
||
inputSource,
|
||
});
|
||
|
||
// 更新 selectedElement 以触发界面刷新
|
||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
||
if (n) {
|
||
selectedElement.value = {
|
||
id: n.id,
|
||
type: n.type,
|
||
kind: 'node',
|
||
properties: n.properties || {},
|
||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
||
};
|
||
}
|
||
|
||
syncDsl();
|
||
ElMessage.success(`已删除参数:${key}`);
|
||
};
|
||
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(dynamicFormValues).forEach((k) => delete dynamicFormValues[k]);
|
||
|
||
// 加载自定义字段(从 formConfig)
|
||
customFields.value = [];
|
||
if (e?.properties?.formConfig && Array.isArray(e.properties.formConfig)) {
|
||
customFields.value = e.properties.formConfig.map((field: any) => ({
|
||
label: field.label || '',
|
||
value: field.value || '',
|
||
type: field.type || 'input',
|
||
required: field.required || false,
|
||
}));
|
||
}
|
||
|
||
// 初始化模型选择
|
||
selectedModel.value = String(e?.properties?.selectedModel || '');
|
||
if (!selectedModel.value && currentNodeModelConfig.value.length > 0) {
|
||
selectedModel.value = currentNodeModelConfig.value[0].modelName;
|
||
}
|
||
|
||
// 初始化所有表单字段(基础 + 模型)
|
||
allFormFields.value.forEach((fieldItem) => {
|
||
const currentValue = e?.properties?.[fieldItem.field];
|
||
if (currentValue !== undefined) {
|
||
dynamicFormValues[fieldItem.field] = 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') {
|
||
// 所有数字字段默认为 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 {
|
||
Object.keys(p).forEach((key) => {
|
||
if (key !== 'nodeCode' && key !== 'fieldMetadata') delete p[key];
|
||
});
|
||
// 保存选中的模型
|
||
if (selectedModel.value) {
|
||
p.selectedModel = selectedModel.value;
|
||
}
|
||
|
||
// 保存字段值
|
||
Object.entries(dynamicFormValues).forEach(([key, value]) => {
|
||
if (value !== '' && value !== undefined && value !== null) {
|
||
p[key] = value;
|
||
}
|
||
});
|
||
|
||
// 保存字段元数据(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
|
||
if (customFields.value.length > 0) {
|
||
p.formConfig = customFields.value.map((field) => ({
|
||
label: field.label,
|
||
value: field.value,
|
||
type: field.type,
|
||
required: field.required,
|
||
}));
|
||
}
|
||
}
|
||
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('已更新当前元素配置');
|
||
};
|
||
// 保存工作流
|
||
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) {
|
||
ElMessage.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();
|
||
ElMessage.success('流程已重置');
|
||
};
|
||
// 从后端 DSL 恢复工作流
|
||
const loadWorkflowFromDsl = (dsl: any) => {
|
||
const lf = logicFlowInstance.value;
|
||
if (!lf || !dsl) return;
|
||
|
||
try {
|
||
// 转换后端 DSL 为 LogicFlow 格式
|
||
const nodes = (dsl.nodes || []).map((n: any) => ({
|
||
id: n.id,
|
||
type: n.type || 'rect',
|
||
x: n.config?.x || 220,
|
||
y: n.config?.y || 140,
|
||
text: n.name || '',
|
||
properties: n.config || {},
|
||
}));
|
||
|
||
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();
|
||
ElMessage.success('工作流已加载');
|
||
} 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: flex;
|
||
gap: 14px;
|
||
padding: 14px;
|
||
background: #f6f8fb;
|
||
box-sizing: border-box;
|
||
}
|
||
/* 画布模式:不显示工作空间 */
|
||
.creation-page:not(.creation-mode) .panel.left {
|
||
display: none;
|
||
}
|
||
/* 创作模式:显示工作空间 */
|
||
.creation-page.creation-mode {
|
||
display: grid;
|
||
grid-template-columns: 280px minmax(0, 1fr);
|
||
transition: grid-template-columns 0.3s ease;
|
||
}
|
||
.creation-page.creation-mode:has(.panel.left.collapsed) {
|
||
grid-template-columns: 70px minmax(0, 1fr);
|
||
}
|
||
.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;
|
||
transition: all 0.3s ease;
|
||
}
|
||
.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;
|
||
margin-bottom: 10px;
|
||
}
|
||
.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: grid;
|
||
grid-template-columns: 1fr 100px 60px auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.custom-field-input {
|
||
min-width: 0;
|
||
}
|
||
.custom-field-type {
|
||
width: 100px;
|
||
}
|
||
.custom-field-required {
|
||
flex-shrink: 0;
|
||
margin: 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;
|
||
}
|
||
.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: 20px;
|
||
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;
|
||
}
|
||
}
|
||
</style>
|