Files
admin-ui/src/views/settings/workflow/index.vue
T
2026-08-06 09:31:34 +08:00

861 lines
22 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="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>