Files
admin-ui/src/views/digitalHuman/creation/index.vue
2910410219 63aa678ac0 feat: 添加工作流管理功能和动态表单支持
- 新增工作流相关接口和类型定义,包括创建、更新、删除和获取工作流列表的功能
- 更新界面,支持工作流的展示和操作,允许用户保存和管理工作流
- 优化动态表单,支持根据工作流节点动态生成表单项
- 修改按钮事件名称以提高代码可读性
2026-05-06 18:40:51 +08:00

2187 lines
63 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 }">
<!-- 工作空间 - 只在创作模式显示 -->
<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>