正所谓百家争鸣、见仁见智、众说纷纭、各有千秋!在工作流bpmn2.0可视化建模工具实现的细分领域,网上扑面而来的是 bpmn.js 这个渲染工具包和web建模器,而笔者却认为使用flowable官方开源 editor-app 才是王道。

Flowable 开源版本中的 web 版流程设计器editor-app,展示风格和功能基本跟 activiti-modeler 一样,集成简单,开发工作量小,界面美观大方,功能强大,用户体验友好。

通过以下两张Gif动图来个PK,您的直观感受如何呢?
bpmn.js运行效果图(gif动图取自互联网)

Flowable editor-app运行效果:

boot-admin 是一款采用前后端分离模式、基于SpringCloud微服务架构的SaaS后台管理框架。系统内置基础管理、权限管理、运行管理、定义管理、代码生成器和办公管理6个功能模块,集成分布式事务Seata、工作流引擎Flowable、业务规则引擎Drools、后台作业调度框架Quartz等,技术栈包括Mybatis-plus、Redis、Nacos、Seata、Flowable、Drools、Quartz、SpringCloud、Springboot Admin Gateway、Liquibase、jwt、Openfeign、I18n等。
gitee源码地址
github源码地址

下面介绍 boot-admin 对flowable官方bpmn2.0可视化建模工具 editor-app 的集成改造步骤:

获取前端源码

  • 下载官方数据包flowable-6.4.1.zip
  • 从压缩包中解压出flowable-6.4.1\wars下面的flowable-modeler.war
  • 从flowable-modeler.war中解压出 WEB-INF\classes\static\editor-app 文件夹
  • 将数据包中 editor-app 文件夹复制到 boot-admin项目 前端工程的 public 文件夹下面
  • 在 boot-admin项目 前端工程 public 文件夹下面创建 modeler.html 作为编辑器入口

modeler.html内容:

 <!--            Activiti Editor                                    <!-- 
{{'GENERAL.MAIN-TITLE' | translate}} -->
{{alerts.current.message}}
0"> {{alerts.queue.length + 1}}

整合改造前端源码

  1. 修改 ACTIVITI.CONFIG ,设置网关 URL
var ACTIVITI = ACTIVITI || {};ACTIVITI.CONFIG = {'contextRoot' : 'http://网关IP:网关端口号/api/workflow/auth/activiti',};
  1. 修改 configuration\url-config.js,设置各具体访问点URL
var KISBPM = KISBPM || {};KISBPM.URL = {  //通过modelId,获取已保存模型的json数据  getModel: function(modelId) {    return ACTIVITI.CONFIG.contextRoot + '/model/json?modelId=' + modelId;  },  //获取汉化资源json数据  getStencilSet: function() {    return ACTIVITI.CONFIG.contextRoot + '/editor/stencilset?version=' + Date.now();  },  //保存模型数据  putModel: function(modelId) {    return ACTIVITI.CONFIG.contextRoot + '/model/save?modelId=' + modelId;  },  //从cookie中读取令牌  getToken: function() {    var cookies = document.cookie;    var list = cookies.split("; "); // 解析出名/值对列表    for (var i = 0; i < list.length; i++) {      var arr = list[i].split("="); // 解析出名和值      if (arr[0] == "Admin-Token") {        var cookieVal = decodeURIComponent(arr[1]); // 对cookie值解码        break;      }    }    return 'Bearer' + cookieVal;  }};
  1. 修改 /public/editor-app/stencil-controller.js 中获取汉化包的方法,由源码中自由访问修改为携带令牌访问后台资源
            $http({method: 'GET',            headers: {                  'X-Token': KISBPM.URL.getToken()              },            url: KISBPM.URL.getStencilSet()})            .success(function (data, status, headers, config) {            var quickMenuDefinition = ['UserTask', 'EndNoneEvent', 'ExclusiveGateway',                                       'CatchTimerEvent', 'ThrowNoneEvent', 'TextAnnotation',                                       'SequenceFlow', 'Association'];            var ignoreForPaletteDefinition = ['SequenceFlow', 'MessageFlow', 'Association', 'DataAssociation', 'DataStore', 'SendTask'];            var quickMenuItems = [];            var morphRoles = [];                for (var i = 0; i < data.rules.morphingRules.length; i++)                {                    var role = data.rules.morphingRules[i].role;                    var roleItem = {'role': role, 'morphOptions': []};                    morphRoles.push(roleItem);                }                // Check all received items                for (var stencilIndex = 0; stencilIndex  0) {                            currentGroup = findGroup(currentGroupName, stencilItemGroups); // Find group in root groups array                            if (currentGroup === null) {                                currentGroup = addGroup(currentGroupName, stencilItemGroups);                            }                            // Add all child groups (if any)                            for (var groupIndex = 1; groupIndex  0) {                        stencilItem.customIcon = true;                        stencilItem.icon = data.stencils[stencilIndex].customIconId;                    }                    if (!removed) {                        if (quickMenuDefinition.indexOf(stencilItem.id) >= 0) {                        quickMenuItems[quickMenuDefinition.indexOf(stencilItem.id)] = stencilItem;                        }                    }                    if (stencilItem.id === 'TextAnnotation' || stencilItem.id === 'BoundaryCompensationEvent') {                    stencilItem.canConnectAssociation = true;                    }                    for (var i = 0; i < data.stencils[stencilIndex].roles.length; i++) {                    var stencilRole = data.stencils[stencilIndex].roles[i];                    if (stencilRole === 'sequence_start') {                    stencilItem.canConnect = true;                    } else if (stencilRole === 'sequence_end') {                    stencilItem.canConnectTo = true;                    }                    for (var j = 0; j < morphRoles.length; j++) {                    if (stencilRole === morphRoles[j].role) {                        if (!removed) {                         morphRoles[j].morphOptions.push(stencilItem);                    }                    stencilItem.morphRole = morphRoles[j].role;                    break;                    }                    }                    }                    if (currentGroup) {                    // Add the stencil item to the correct group                    currentGroup.items.push(stencilItem);                    if (ignoreForPaletteDefinition.indexOf(stencilItem.id) < 0) {                    currentGroup.paletteItems.push(stencilItem);                    }                    } else {                        // It's a root stencil element                        if (!removed) {                            stencilItemGroups.push(stencilItem);                        }                    }                }                for (var i = 0; i < stencilItemGroups.length; i++)                {                if (stencilItemGroups[i].paletteItems && stencilItemGroups[i].paletteItems.length == 0)                {                stencilItemGroups[i].visible = false;                }                }                $scope.stencilItemGroups = stencilItemGroups;                var containmentRules = [];                for (var i = 0; i < data.rules.containmentRules.length; i++)                {                    var rule = data.rules.containmentRules[i];                    containmentRules.push(rule);                }                $scope.containmentRules = containmentRules;                // remove quick menu items which are not available anymore due to custom pallette                var availableQuickMenuItems = [];                for (var i = 0; i < quickMenuItems.length; i++)                {                    if (quickMenuItems[i]) {                        availableQuickMenuItems[availableQuickMenuItems.length] = quickMenuItems[i];                    }                }                $scope.quickMenuItems = availableQuickMenuItems;                $scope.morphRoles = morphRoles;            }).            error(function (data, status, headers, config) {                console.log('Something went wrong when fetching stencil items:' + JSON.stringify(data));            });
  1. 修改 /public/editor-app/app.js 中获取模型数据的方法,由源码中自由访问修改为携带令牌访问后台资源
            function fetchModel(modelId) {                var modelUrl = KISBPM.URL.getModel(modelId);                $http({method: 'GET',                headers: {'X-Token': KISBPM.URL.getToken()},                url: modelUrl}).                    success(function (data, status, headers, config) {                        $rootScope.editor = new ORYX.Editor(data);                        $rootScope.modelData = angular.fromJson(data);                        $rootScope.editorFactory.resolve();                    }).                    error(function (data, status, headers, config) {                      console.log('Error loading model with id ' + modelId + ' ' + data);                    });            }
  1. 修改 /public/editor-app/configuration/toolbar-default-actions.js 中保存模型的方法,由源码中自由访问修改为携带令牌访问后台资源
        $http({    method: 'PUT',            data: params,            ignoreErrors: true,            headers: {'Accept': 'application/json',                      'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',                      'X-Token': KISBPM.URL.getToken()},            transformRequest: function (obj) {                var str = [];                for (var p in obj) {                    str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));                }                return str.join("&");            },            url: KISBPM.URL.putModel(modelMetaData.modelId)})            .success(function (data, status, headers, config) {                $scope.editor.handleEvents({                    type: ORYX.CONFIG.EVENT_SAVED                });                $scope.modelData.name = $scope.saveDialog.name;                $scope.modelData.lastUpdated = data.lastUpdated;                $scope.status.loading = false;                $scope.$hide();                // Fire event to all who is listening                var saveEvent = {                    type: KISBPM.eventBus.EVENT_TYPE_MODEL_SAVED,                    model: params,                    modelId: modelMetaData.modelId,            eventType: 'update-model'                };                KISBPM.eventBus.dispatch(KISBPM.eventBus.EVENT_TYPE_MODEL_SAVED, saveEvent);                // Reset state                $scope.error = undefined;                $scope.status.loading = false;                // Execute any callback                if (successCallback) {                    successCallback();                }            })            .error(function (data, status, headers, config) {                $scope.error = {};                console.log('Something went wrong when updating the process model:' + JSON.stringify(data));                $scope.status.loading = false;            });
  1. 创建 Modeler.vue 组件,以 iframe 形式将 editor-app 嵌入 vue-element-ui的弹窗 el-dialog 中
  
export default { name: 'Modeler', data() { return { dialogVisible: false, contents: "/modeler.html?modelId=0" } }, mounted() { }, methods: { setSrc(src){ this.contents="/modeler.html?modelId="+src }, showDialog() { this.dialogVisible = true }, closeDialog(){ this.$emit("refreshTable",true) } } }
  1. 模型管理VUE文件
  
查询 重置 关闭
刷新 新建 查询
{{ props.row.id }} {{ props.row.key }} {{ props.row.name }} {{ props.row.version }} {{ $commonUtils.dateTimeFormat(props.row.createTime) }} {{ $commonUtils.dateTimeFormat(props.row.lastUpdateTime) }} <!-- --> dateTimeColFormatter(row,column,cellValue)"> dateTimeColFormatter(row,column,cellValue)"> 修改 删除 部署 XML
关闭 创建 重置
关闭 生成文件 import Modeler from './components/Modeler' import { fetchModelPage, saveNewModel, delModel, deployModel, fetchXml } from '@/api/workflow-model' import { getDictionaryOptionsByItemType, lazyFetchDictionaryNode } from '@/api/dictionary' export default { name: 'model', computed: {}, components: { Modeler }, data() { const that = this; return { loading: true, mainTableData: [], mainDataForm: { editingRecord: { key: '', name: '', version: '', enabled: '1', deleted: '1', description: '无', }, mainDataFormDialogVisible: false, mainDataFormDialogTitle: '连续新增' }, sourceCodeForm: { editingRecord: { sourceCode: '' }, dialogVisible: false, }, filterDrawer: { dialogVisible: false, formLabelWidth: '100px', formData: { id: '', key: '', name: '', version: null, createTime: null, lastUpdateTime: null, datestamp: null, enabled: '', deleted: '', description: '', currentPage: 1, pageSize: 10, total: 0, }, }, optionMap: new Map(), //本页需要加载的option数据类型罗列在下面的数组中 optionKey: [ this.$commonDicType.ENABLED(), this.$commonDicType.DELETED(), ], cascaderValue: {}, rules: { id: [{ required: true, message: '请输入主键', trigger: 'blur' }], key: [{ required: true, message: '请输入模型标识', trigger: 'blur' }], name: [{ required: true, message: '请输入模型名称', trigger: 'blur' }], version: [{ required: true, message: '请输入版本号', trigger: 'blur' }], createTime: [{ required: true, message: '请输入记录创建时间', trigger: 'blur' }], lastUpdateTime: [{ required: true, message: '请输入记录最后修改时间', trigger: 'blur' }], } } }, created() {}, mounted() { this.loadAllOptions() this.getMainTableData() }, watch: {}, inject: ['reload'], methods: { refresh() { this.reload() }, loadAllOptions() { for (var i = 0; i { this.$refs.modelerComponent.setSrc(row.id) this.$refs.modelerComponent.showDialog() }) }, handleDeleteRow(row) { this.$confirm('此操作将删除选中的数据, 是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { this.awaitDelModel(row.id) }).catch(() => { this.$message({ type: 'info', message: '已取消删除' }); }); }, handleDeployModel(row) { this.$confirm('此操作将部署选中的模型, 是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { this.awaitDeployModel(row.id) }).catch(() => { this.$message({ type: 'info', message: '已取消部署' }); }); }, async handleFetchXml(row){ const guidVO = { guid: row.id } const result = await fetchXml(guidVO) if (this.$commonResultCode.SUCCESS() == result.code) { this.sourceCodeForm.editingRecord.sourceCode = result.data this.sourceCodeForm.dialogVisible = true } this.$message({ message: result.message, type: 'warning' }) }, async awaitDelModel(guid) { const guidVO = { guid } const result = await delModel(guidVO) if (this.$commonResultCode.SUCCESS() == result.code) { this.getMainTableData() } this.$message({ message: result.message, type: 'warning' }) }, async awaitDeployModel(guid) { const guidVO = { guid } const result = await deployModel(guidVO) this.$message({ message: result.message, type: 'warning' }) }, handleClickAddButton() { this.mainDataForm.mainDataFormDialogTitle = '创建新的模型' this.initmainDataForm() this.mainDataForm.mainDataFormDialogVisible = true }, initmainDataForm() { this.mainDataForm.editingRecord.id = '' this.mainDataForm.editingRecord.key = '' this.mainDataForm.editingRecord.name = '' this.mainDataForm.editingRecord.description = '' }, handleSubmitMainDataForm() { this.$refs['mainEditForm'].validate((valid) => { if (valid) { this.submitMainDataForm() } else { console.log('未通过表单校验!!'); return false; } }); }, async submitMainDataForm() { const response = await saveNewModel(this.mainDataForm.editingRecord) if (response.code !== 100) { this.$message({ message: response.message, type: 'warning' }) return } const { data } = response this.mainDataForm.mainDataFormDialogVisible = false this.$nextTick(() => { this.$refs.modelerComponent.setSrc(data) this.$refs.modelerComponent.showDialog() }) }, handleCloseMainDataFormDialog() { this.getMainTableData() this.mainDataForm.mainDataFormDialogVisible = false }, async loadLazyCodeNode(dicType, code, resolve) { this.listLoading = true const response = await lazyFetchDictionaryNode(dicType, code) this.listLoading = false if (response.code !== 100) { this.$message({ message: response.message, type: 'warning' }) return } const { data } = response // 通过调用resolve将子节点数据返回,通知组件数据加载完成 resolve(data); }, handleCloseSourceCodeDialog(){ this.sourceCodeForm.dialogVisible = false } } } .demo-table-expand { font-size: 0; } .demo-table-expand label { width: 190px; color: #99a9bf; } .demo-table-expand .el-form-item { text-align: left; margin-right: 0; margin-bottom: 0; width: 100%; } /*1.显示滚动条:当内容超出容器的时候,可以拖动:*/ .el-drawer__body { overflow: auto; /* overflow-x: auto; */ } /*2.隐藏滚动条,太丑了*/ .el-drawer__container ::-webkit-scrollbar { display: none; }

workflow-model.js

import request from '@/utils/request'//分页获取模型数据export function fetchModelPage(data) {  return request({    url: '/api/workflow/auth/activiti/model/page',    method: 'post',    data  })}//保存模型export function saveNewModel(data) {  return request({    url: '/api/workflow/auth/activiti/model/add',    method: 'post',    data  })}//删除模型数据export function delModel(data) {  return request({    url: '/api/workflow/auth/activiti/model/del',    method: 'post',    data  })}//部署模型export function deployModel(data) {  return request({    url: '/api/workflow/auth/activiti/model/deploy',    method: 'post',    data  })}//获取模型XMLexport function fetchXml(data) {  return request({    url: '/api/workflow/auth/activiti/model/xml',    method: 'post',    data  })}

后端功能实现

对应前端需求,后端主要实现使用flowable引擎,获取汉化资源、读取模型数据、保存模型数据三个功能。

具体内容参见下一篇博文

项目源码仓库github
项目源码仓库gitee

本文来自博客园,作者:soft1314,转载请注明原文链接:https://www.cnblogs.com/soft1314/p/17338370.html