861 lines
22 KiB
Vue
861 lines
22 KiB
Vue
<template>
|
||
<div class="workflow-canvas-page">
|
||
<!-- 工具栏 -->
|
||
<div class="toolbar">
|
||
<div class="toolbar-left">
|
||
<h2>工作流画布编辑器</h2>
|
||
<span class="info">节点: {{ nodes.length }} / 连线: {{ edges.length }}</span>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<el-button size="small" @click="createNewWorkflow">新建工作流</el-button>
|
||
<el-button size="small" type="danger" :disabled="!selectedNode" @click="deleteSelectedNode">删除选中</el-button>
|
||
<el-button size="small" type="primary" @click="saveWorkflowAction" :loading="saving">保存工作流</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区:三栏布局 -->
|
||
<div class="content">
|
||
<!-- 左侧:配置面板 -->
|
||
<NodeConfigPanel
|
||
:selected-node="selectedNode"
|
||
:node-config="currentNodeConfig"
|
||
@update:selected-node="updateSelectedNode"
|
||
@open-model-selector="showModelSelector = true"
|
||
@remove-model="handleRemoveModel"
|
||
@open-skill-selector="showSkillSelector = true"
|
||
@remove-skill="handleRemoveSkill"
|
||
@update:patch-layout="handleTogglePatchLayout"
|
||
/>
|
||
|
||
<!-- 中间:VueFlow 画布(节点库在画布内) -->
|
||
<div class="flow-wrapper">
|
||
<NodeLibraryPanel
|
||
:node-library-groups="nodeLibraryGroups"
|
||
:collapsed="nodeLibraryCollapsed"
|
||
@update:collapsed="nodeLibraryCollapsed = $event"
|
||
@add-node="addNodeFromLibrary"
|
||
/>
|
||
|
||
<!-- VueFlow 画布 -->
|
||
<VueFlow
|
||
v-model:nodes="nodes"
|
||
v-model:edges="edges"
|
||
:default-viewport="{ zoom: 1 }"
|
||
:nodes-connectable="true"
|
||
:edges-updatable="true"
|
||
@connect="onConnect"
|
||
@node-click="onNodeClick"
|
||
>
|
||
<Background pattern-color="#cbd5e1" :gap="16" />
|
||
<Controls />
|
||
<MiniMap />
|
||
</VueFlow>
|
||
</div>
|
||
|
||
<!-- 右侧:工作流列表 -->
|
||
<WorkflowListPanel
|
||
:user-workflow-list="userWorkflowList"
|
||
:template-workflow-list="templateWorkflowList"
|
||
:current-editing-id="currentEditingWorkflowId"
|
||
:loading="workflowListLoading"
|
||
@edit="editWorkflow"
|
||
@delete="deleteWorkflowAction"
|
||
@create="createNewWorkflow"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 保存工作流对话框 -->
|
||
<SaveWorkflowDialog
|
||
v-model="saveDialogVisible"
|
||
:save-form="saveForm"
|
||
:current-editing-workflow-id="currentEditingWorkflowId"
|
||
:saving="saving"
|
||
@confirm="confirmSaveWorkflow"
|
||
/>
|
||
|
||
<!-- 模型选择器 -->
|
||
<ModelSelector v-model="showModelSelector" :default-model="selectedModelData" @confirm="handleModelConfirm" />
|
||
|
||
<!-- 技能选择器 -->
|
||
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkillData" @confirm="handleSkillConfirm" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue';
|
||
import { VueFlow, useVueFlow } from '@vue-flow/core';
|
||
import { Background } from '@vue-flow/background';
|
||
import { Controls } from '@vue-flow/controls';
|
||
import { MiniMap } from '@vue-flow/minimap';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import type { Node, Edge, Connection } from '@vue-flow/core';
|
||
import {
|
||
getWorkflowList,
|
||
getWorkflowDetail,
|
||
saveWorkflow,
|
||
updateWorkflow,
|
||
deleteWorkflow,
|
||
type WorkflowItem,
|
||
} from '/@/api/settings/creation';
|
||
import { getNodeLibraryList, type NodeLibraryGroup } from '/@/api/settings/workflow';
|
||
import NodeConfigPanel from './component/NodeConfigPanel.vue';
|
||
import NodeLibraryPanel from './component/NodeLibraryPanel.vue';
|
||
import WorkflowListPanel from './component/WorkflowListPanel.vue';
|
||
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
|
||
import ModelSelector from './component/ModelSelector.vue';
|
||
import SkillSelector from './component/SkillSelector.vue';
|
||
|
||
interface NodeData {
|
||
label?: string;
|
||
nodeCode?: string;
|
||
desc?: string;
|
||
formConfig?: any[];
|
||
modelConfig?: any;
|
||
skillName?: string;
|
||
patchLayout?: boolean;
|
||
}
|
||
|
||
const { addNodes, addEdges, findNode, removeNodes, getNodes, updateNode } = useVueFlow();
|
||
|
||
// 常量定义
|
||
const START_NODE_CODE = '__start__';
|
||
const JUDGE_KEYWORDS = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'];
|
||
|
||
// 节点库相关状态
|
||
const nodeLibraryGroups = ref<NodeLibraryGroup[]>([]);
|
||
const nodeLibraryCollapsed = ref(false);
|
||
|
||
// 节点配置映射:nodeCode -> 节点配置
|
||
const nodeConfigMap = computed(() => {
|
||
const map = new Map<
|
||
string,
|
||
{
|
||
formConfig: any[];
|
||
modelConfigOption: boolean;
|
||
formConfigOption: boolean;
|
||
skillOption: boolean;
|
||
promptOption: boolean;
|
||
isSaveFileOption: boolean;
|
||
}
|
||
>();
|
||
nodeLibraryGroups.value.forEach((group) => {
|
||
group.nodes.forEach((item) => {
|
||
map.set(item.key, {
|
||
formConfig: item.presetOption || [],
|
||
modelConfigOption: item.modelConfigOption || false,
|
||
formConfigOption: item.formConfigOption || false,
|
||
skillOption: item.skillOption || false,
|
||
promptOption: item.promptOption || false,
|
||
isSaveFileOption: item.isSaveFileOption || false,
|
||
});
|
||
});
|
||
});
|
||
return map;
|
||
});
|
||
|
||
// 当前选中节点的配置
|
||
const currentNodeConfig = computed(() => {
|
||
if (!selectedNode.value?.data?.nodeCode) return null;
|
||
return nodeConfigMap.value.get(selectedNode.value.data.nodeCode) || null;
|
||
});
|
||
|
||
// 工作流列表相关状态
|
||
const workflowListLoading = ref(false);
|
||
const userWorkflowList = ref<WorkflowItem[]>([]);
|
||
const templateWorkflowList = ref<WorkflowItem[]>([]);
|
||
const currentEditingWorkflowId = ref<string | null>(null);
|
||
|
||
// 保存对话框相关状态
|
||
const saveDialogVisible = ref(false);
|
||
const saving = ref(false);
|
||
const saveForm = ref({
|
||
flowName: '',
|
||
description: '',
|
||
});
|
||
|
||
const nodes = ref<Node<NodeData, any, string>[]>([]);
|
||
const edges = ref<Edge[]>([]);
|
||
const selectedNode = ref<Node<NodeData, any, string> | null>(null);
|
||
let nodeId = 0;
|
||
|
||
// 模型选择器相关状态
|
||
const showModelSelector = ref(false);
|
||
const selectedModelData = ref<any>(null);
|
||
|
||
// 技能选择器相关状态
|
||
const showSkillSelector = ref(false);
|
||
const selectedSkillData = ref<any>(null);
|
||
|
||
const deleteSelectedNode = async () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
if (selectedNode.value.data.nodeCode === '__start__') {
|
||
ElMessage.warning('开始节点不能删除');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm('确定要删除选中的节点吗?', '删除确认', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
});
|
||
|
||
removeNodes([selectedNode.value.id]);
|
||
selectedNode.value = null;
|
||
ElMessage.success('节点已删除');
|
||
} catch {
|
||
// 用户取消删除
|
||
}
|
||
};
|
||
|
||
const onConnect = (connection: Connection) => {
|
||
const source = findNode(connection.source);
|
||
const target = findNode(connection.target);
|
||
|
||
if (!source?.data || !target?.data) return;
|
||
|
||
if (target.data.nodeCode === '__start__') {
|
||
ElMessage.warning('开始节点不能被连接');
|
||
return;
|
||
}
|
||
|
||
if (source.data.nodeCode === '__start__' && target.data.nodeCode === 'judge') {
|
||
ElMessage.warning('开始节点后不能接判断节点');
|
||
return;
|
||
}
|
||
|
||
addEdges([
|
||
{
|
||
id: `edge-${connection.source}-${connection.target}`,
|
||
source: connection.source,
|
||
target: connection.target,
|
||
type: 'smoothstep',
|
||
animated: true,
|
||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||
},
|
||
]);
|
||
ElMessage.success('连接成功');
|
||
};
|
||
|
||
const onNodeClick = (event: { node: Node<NodeData, any, string> }) => {
|
||
selectedNode.value = event.node;
|
||
// 同步选择器预选项,避免展示上一个节点的模型/技能
|
||
const mc = event.node.data?.modelConfig;
|
||
selectedModelData.value = mc?.modelName ? { modelName: mc.modelName, modelType: mc.modelType } : null;
|
||
const sk = event.node.data?.skillName;
|
||
selectedSkillData.value = sk ? { name: sk } : null;
|
||
};
|
||
|
||
const updateSelectedNode = (updatedNode: Node<NodeData, any, string>) => {
|
||
selectedNode.value = updatedNode;
|
||
// 使用 VueFlow 的 API 更新节点,确保内部状态同步
|
||
updateNode(updatedNode.id, updatedNode);
|
||
// 同步更新到 nodes 数组
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
};
|
||
|
||
// 模型选择确认
|
||
const handleModelConfirm = (model: any) => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
modelConfig: {
|
||
modelName: model.modelName,
|
||
modelType: model.modelType,
|
||
modelApiKey: '',
|
||
modelForm: model.modelForm || [],
|
||
modelResponse: model.responseBody || {},
|
||
},
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedModelData.value = model;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('模型已选择');
|
||
};
|
||
|
||
// 移除模型
|
||
const handleRemoveModel = () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
modelConfig: undefined,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedModelData.value = null;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('模型已移除');
|
||
};
|
||
|
||
// 技能选择确认
|
||
const handleSkillConfirm = (skill: any) => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
skillName: skill.name,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedSkillData.value = skill;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('技能已选择');
|
||
};
|
||
|
||
// 移除技能
|
||
const handleRemoveSkill = () => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
skillName: undefined,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
selectedSkillData.value = null;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
|
||
ElMessage.success('技能已移除');
|
||
};
|
||
|
||
// 切换贴片布局
|
||
const handleTogglePatchLayout = (value: boolean) => {
|
||
if (!selectedNode.value?.data) return;
|
||
|
||
const updatedNode: Node<NodeData> = {
|
||
...selectedNode.value,
|
||
data: {
|
||
...selectedNode.value.data,
|
||
patchLayout: value,
|
||
},
|
||
};
|
||
|
||
selectedNode.value = updatedNode;
|
||
// 同步更新到 VueFlow 内部状态
|
||
updateNode(updatedNode.id, updatedNode);
|
||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||
if (index >= 0) {
|
||
nodes.value[index] = updatedNode;
|
||
}
|
||
};
|
||
|
||
// 辅助函数:判断是否为开始节点
|
||
const isStartNode = (node: Node<NodeData, any, string>) => {
|
||
return node.data?.nodeCode === START_NODE_CODE;
|
||
};
|
||
|
||
// 辅助函数:判断是否为判断节点
|
||
const isJudgeNode = (node: Node<NodeData, any, string>) => {
|
||
const nodeCode = (node.data?.nodeCode || '').toLowerCase();
|
||
const nodeName = (node.data?.label || '').toLowerCase();
|
||
return JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeName.includes(k));
|
||
};
|
||
|
||
// 约束校验函数
|
||
const validateFlowConstraints = () => {
|
||
const currentNodes = nodes.value;
|
||
const currentEdges = edges.value;
|
||
|
||
if (!currentNodes.length) return { ok: true };
|
||
|
||
// 创建节点映射
|
||
const nodeMap = new Map(currentNodes.map((n) => [n.id, n]));
|
||
|
||
// 检查是否有开始节点
|
||
const startNode = currentNodes.find((n) => isStartNode(n));
|
||
if (!startNode) {
|
||
return { ok: false, message: '工作流必须包含开始节点' };
|
||
}
|
||
|
||
// 检查边的约束
|
||
for (const edge of currentEdges) {
|
||
const targetNode = nodeMap.get(edge.target);
|
||
|
||
// 开始节点不能被其他节点链接
|
||
if (targetNode && isStartNode(targetNode)) {
|
||
return { ok: false, message: '开始节点不能被其他节点链接' };
|
||
}
|
||
|
||
// 开始节点后不能接判断节点
|
||
const sourceNode = nodeMap.get(edge.source);
|
||
if (sourceNode && targetNode && isStartNode(sourceNode) && isJudgeNode(targetNode)) {
|
||
return { ok: false, message: '开始节点下一个节点不能是判断节点' };
|
||
}
|
||
}
|
||
|
||
// 检查结尾节点不能是判断节点
|
||
const hasOutEdge = new Set(currentEdges.map((e) => e.source));
|
||
const endNodes = currentNodes.filter((n) => !hasOutEdge.has(n.id));
|
||
if (endNodes.some((n) => isJudgeNode(n))) {
|
||
return { ok: false, message: '结尾节点不能是判断节点' };
|
||
}
|
||
|
||
return { ok: true };
|
||
};
|
||
|
||
// 获取节点库
|
||
const getNodeLibrary = async () => {
|
||
try {
|
||
const res = await getNodeLibraryList();
|
||
nodeLibraryGroups.value = res.data?.groups || [];
|
||
} catch {
|
||
nodeLibraryGroups.value = [];
|
||
ElMessage.error('节点库加载失败');
|
||
}
|
||
};
|
||
|
||
// 从节点库添加节点
|
||
const addNodeFromLibrary = (nodeCode: string, nodeName: string) => {
|
||
const existingNodes = getNodes.value;
|
||
let spawnX = 250;
|
||
let spawnY = 140;
|
||
|
||
if (existingNodes.length > 0) {
|
||
const lastNode = existingNodes[existingNodes.length - 1];
|
||
spawnX = (lastNode.position?.x || 250) + 180;
|
||
spawnY = lastNode.position?.y || 140;
|
||
|
||
if (spawnX > 800) {
|
||
spawnX = 250;
|
||
spawnY += 120;
|
||
}
|
||
}
|
||
|
||
addNodes([
|
||
{
|
||
id: `node-${++nodeId}`,
|
||
type: 'default',
|
||
position: { x: spawnX, y: spawnY },
|
||
data: { label: nodeName, nodeCode, desc: '', patchLayout: false },
|
||
style: { background: '#fff', border: '2px solid #3b82f6', borderRadius: '8px', padding: '10px 20px' },
|
||
},
|
||
]);
|
||
ElMessage.success(`已添加${nodeName}`);
|
||
};
|
||
|
||
// 获取工作流列表
|
||
const fetchWorkflowList = async () => {
|
||
workflowListLoading.value = true;
|
||
try {
|
||
const res = await getWorkflowList();
|
||
userWorkflowList.value = res.data?.listFlowUserRes?.list || [];
|
||
templateWorkflowList.value = res.data?.listFlowTemplateRes?.list || [];
|
||
} catch {
|
||
userWorkflowList.value = [];
|
||
templateWorkflowList.value = [];
|
||
} finally {
|
||
workflowListLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 添加默认开始节点
|
||
const addDefaultStartNode = () => {
|
||
const startNode = {
|
||
id: 'start-node',
|
||
type: 'input',
|
||
position: { x: 200, y: 200 },
|
||
data: { label: '开始', nodeCode: '__start__' },
|
||
style: { background: '#10b981', color: '#fff', border: '2px solid #059669', borderRadius: '8px', padding: '10px 20px' },
|
||
};
|
||
addNodes([startNode]);
|
||
nodes.value.push(startNode);
|
||
};
|
||
|
||
// 新建工作流
|
||
const createNewWorkflow = () => {
|
||
currentEditingWorkflowId.value = null;
|
||
nodes.value = [];
|
||
edges.value = [];
|
||
selectedNode.value = null;
|
||
nodeId = 0;
|
||
|
||
// 添加默认开始节点
|
||
addDefaultStartNode();
|
||
|
||
ElMessage.success('已清空画布,可以开始创建新工作流');
|
||
};
|
||
|
||
// 编辑工作流
|
||
const editWorkflow = async (workflow: WorkflowItem) => {
|
||
try {
|
||
const res = await getWorkflowDetail(workflow.id);
|
||
if (res.data?.flowContent) {
|
||
loadWorkflowFromDsl(res.data.flowContent);
|
||
currentEditingWorkflowId.value = res.data.id;
|
||
saveForm.value.flowName = res.data.flowName || res.data.flowTemplateName || '';
|
||
saveForm.value.description = res.data.description || '';
|
||
ElMessage.success('工作流已加载');
|
||
}
|
||
} catch {
|
||
// 错误已由全局拦截器处理
|
||
}
|
||
};
|
||
|
||
// 从 DSL 加载工作流
|
||
const loadWorkflowFromDsl = (dsl: any) => {
|
||
if (!dsl) return;
|
||
|
||
try {
|
||
const loadedNodes = (dsl.nodes || []).map((n: any) => {
|
||
return {
|
||
id: n.id,
|
||
type: 'default',
|
||
position: { x: n.config?.x || 220, y: n.config?.y || 140 },
|
||
data: {
|
||
label: n.name || '',
|
||
nodeCode: n.nodeCode,
|
||
desc: n.desc || '',
|
||
formConfig: n.formConfig || null,
|
||
modelConfig: n.modelConfig || null,
|
||
skillName: n.skillName || null,
|
||
patchLayout: n.patchLayout || false,
|
||
},
|
||
style: { background: '#fff', border: '2px solid #3b82f6', borderRadius: '8px', padding: '10px 20px' },
|
||
};
|
||
});
|
||
|
||
const loadedEdges = (dsl.edges || []).map((e: any) => ({
|
||
id: e.id,
|
||
source: e.from,
|
||
target: e.to,
|
||
type: 'smoothstep',
|
||
animated: true,
|
||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||
}));
|
||
|
||
nodes.value = loadedNodes;
|
||
edges.value = loadedEdges;
|
||
|
||
if (loadedNodes.length > 0) {
|
||
nodeId = Math.max(
|
||
...loadedNodes.map((n: any) => {
|
||
const match = n.id.match(/node-(\d+)/);
|
||
return match ? parseInt(match[1]) : 0;
|
||
})
|
||
);
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('工作流加载失败');
|
||
}
|
||
};
|
||
|
||
// 保存工作流
|
||
const saveWorkflowAction = () => {
|
||
if (nodes.value.length === 0) {
|
||
ElMessage.warning('画布为空,无法保存');
|
||
return;
|
||
}
|
||
|
||
// 保存前进行约束校验
|
||
const validateResult = validateFlowConstraints();
|
||
if (!validateResult.ok) {
|
||
ElMessage.warning(validateResult.message);
|
||
return;
|
||
}
|
||
|
||
saveDialogVisible.value = true;
|
||
};
|
||
|
||
// 确认保存工作流
|
||
const confirmSaveWorkflow = async () => {
|
||
if (!saveForm.value.flowName.trim()) {
|
||
ElMessage.warning('请输入工作流名称');
|
||
return;
|
||
}
|
||
|
||
const startNode = nodes.value.find((n) => isStartNode(n));
|
||
const workflowDsl = {
|
||
version: '1.0.0',
|
||
startNodeId: startNode?.id || '',
|
||
nodes: nodes.value.map((n) => ({
|
||
id: n.id,
|
||
nodeCode: n.data?.nodeCode || 'unknown',
|
||
name: n.data?.label || '',
|
||
type: n.type || 'default',
|
||
desc: n.data?.desc || null,
|
||
skillName: n.data?.skillName || null,
|
||
patchLayout: n.data?.patchLayout || false,
|
||
config: {
|
||
nodeCode: n.data?.nodeCode || 'unknown',
|
||
x: n.position?.x || 0,
|
||
y: n.position?.y || 0,
|
||
},
|
||
formConfig: n.data?.formConfig || null,
|
||
modelConfig: n.data?.modelConfig || null,
|
||
outputResult: null,
|
||
})),
|
||
edges: edges.value.map((e) => ({
|
||
id: e.id,
|
||
from: e.source,
|
||
to: e.target,
|
||
type: e.type || 'smoothstep',
|
||
mapping: {},
|
||
})),
|
||
};
|
||
|
||
saving.value = true;
|
||
try {
|
||
if (currentEditingWorkflowId.value) {
|
||
await updateWorkflow({
|
||
id: currentEditingWorkflowId.value,
|
||
flowName: saveForm.value.flowName,
|
||
description: saveForm.value.description,
|
||
flowContent: workflowDsl,
|
||
});
|
||
ElMessage.success('工作流更新成功');
|
||
} else {
|
||
await saveWorkflow({
|
||
flowName: saveForm.value.flowName,
|
||
description: saveForm.value.description,
|
||
flowContent: workflowDsl,
|
||
});
|
||
ElMessage.success('工作流保存成功');
|
||
}
|
||
saveDialogVisible.value = false;
|
||
saveForm.value.flowName = '';
|
||
saveForm.value.description = '';
|
||
currentEditingWorkflowId.value = null;
|
||
await fetchWorkflowList();
|
||
} catch {
|
||
// 错误已由全局拦截器处理
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
// 删除工作流
|
||
const deleteWorkflowAction = async (workflow: WorkflowItem) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确定要删除工作流"${workflow.flowName}"吗?`, '删除确认', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
});
|
||
|
||
await deleteWorkflow(workflow.id);
|
||
ElMessage.success('工作流删除成功');
|
||
|
||
if (currentEditingWorkflowId.value === workflow.id) {
|
||
currentEditingWorkflowId.value = null;
|
||
saveForm.value.flowName = '';
|
||
saveForm.value.description = '';
|
||
}
|
||
|
||
await fetchWorkflowList();
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
// 错误已由全局拦截器处理
|
||
}
|
||
}
|
||
};
|
||
|
||
// 初始化
|
||
onMounted(async () => {
|
||
await getNodeLibrary();
|
||
await fetchWorkflowList();
|
||
|
||
// 添加默认开始节点(固定在页面中间偏左)
|
||
addDefaultStartNode();
|
||
nodeId = 0;
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.workflow-canvas-page {
|
||
height: calc(100vh - 100px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f3f5f9;
|
||
position: relative;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 14px 20px;
|
||
background: #ffffff;
|
||
border-bottom: 1px solid #e6eaf0;
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
position: relative;
|
||
z-index: 10;
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.info {
|
||
font-size: 12px;
|
||
color: #475569;
|
||
padding: 4px 10px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
|
||
:deep(.el-button) {
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
padding: 8px 14px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
}
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: 340px 1fr 380px;
|
||
gap: 12px;
|
||
min-height: 0;
|
||
padding: 12px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
// 中间:画布区域
|
||
.flow-wrapper {
|
||
position: relative;
|
||
background: #ffffff;
|
||
border-radius: 10px;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||
border: 1px solid #e6eaf0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
@media (max-width: 1400px) {
|
||
.content {
|
||
grid-template-columns: 300px 1fr 340px;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.content {
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: auto 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
@import '@vue-flow/core/dist/style.css';
|
||
@import '@vue-flow/core/dist/theme-default.css';
|
||
@import '@vue-flow/controls/dist/style.css';
|
||
@import '@vue-flow/minimap/dist/style.css';
|
||
|
||
/* 连接点样式优化 - 更大尺寸,hover 放大但不位移 */
|
||
.vue-flow__handle {
|
||
width: 14px !important;
|
||
height: 14px !important;
|
||
background: #3b82f6 !important;
|
||
border: 2px solid #fff !important;
|
||
opacity: 1 !important;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2) !important;
|
||
cursor: crosshair !important;
|
||
z-index: 10 !important;
|
||
transition: all 0.15s ease !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
.vue-flow__handle:hover {
|
||
width: 22px !important;
|
||
height: 22px !important;
|
||
margin: -4px !important;
|
||
background: #2563eb !important;
|
||
box-shadow: 0 3px 12px rgba(37, 99, 235, 0.6) !important;
|
||
}
|
||
|
||
/* 节点悬停时确保连接点可见 */
|
||
.vue-flow__node:hover .vue-flow__handle {
|
||
opacity: 1 !important;
|
||
}
|
||
|
||
/* 连接线样式 */
|
||
.vue-flow__edge-path {
|
||
stroke-width: 2px;
|
||
}
|
||
|
||
.vue-flow__edge.selected .vue-flow__edge-path {
|
||
stroke: #ef4444 !important;
|
||
stroke-width: 3px;
|
||
}
|
||
|
||
/* 节点样式优化 */
|
||
.vue-flow__node {
|
||
cursor: move;
|
||
}
|
||
|
||
.vue-flow__node:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||
}
|
||
|
||
/* 确保连接线在拖动时可见 */
|
||
.vue-flow__connection-path {
|
||
stroke: #3b82f6 !important;
|
||
stroke-width: 2px !important;
|
||
}
|
||
</style>
|