瀏覽代碼

Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/iot

YunaiV 8 月之前
父節點
當前提交
d8468590e3
共有 100 個文件被更改,包括 9940 次插入9206 次删除
  1. 0 2
      .env.dev
  2. 0 2
      .env.local
  3. 0 2
      .env.prod
  4. 0 2
      .env.stage
  5. 0 2
      .env.test
  6. 二進制
      .image/工作流设计器-bpmn.jpg
  7. 二進制
      .image/工作流设计器-simple.jpg
  8. 1 1
      .vscode/settings.json
  9. 15 11
      README.md
  10. 15 13
      package.json
  11. 2244 6345
      pnpm-lock.yaml
  12. 0 8
      src/api/bpm/activity/index.ts
  13. 10 0
      src/api/bpm/category/index.ts
  14. 17 3
      src/api/bpm/model/index.ts
  15. 45 1
      src/api/bpm/processInstance/index.ts
  16. 15 0
      src/api/bpm/simple/index.ts
  17. 51 4
      src/api/bpm/task/index.ts
  18. 6 1
      src/api/login/index.ts
  19. 1 1
      src/api/login/oauth2/index.ts
  20. 7 0
      src/api/login/types.ts
  21. 5 1
      src/api/mall/promotion/kefu/conversation/index.ts
  22. 3 3
      src/api/mall/promotion/kefu/message/index.ts
  23. 91 0
      src/api/mall/promotion/point/index.ts
  24. 6 1
      src/api/mall/promotion/reward/rewardActivity.ts
  25. 6 0
      src/api/mall/promotion/seckill/seckillActivity.ts
  26. 9 3
      src/api/mall/trade/delivery/pickUpStore/index.ts
  27. 2 0
      src/api/pay/app/index.ts
  28. 8 2
      src/api/pay/order/index.ts
  29. 1 0
      src/assets/svgs/bpm/add-user.svg
  30. 0 0
      src/assets/svgs/bpm/approve.svg
  31. 1 0
      src/assets/svgs/bpm/auditor.svg
  32. 0 0
      src/assets/svgs/bpm/cancel.svg
  33. 1 0
      src/assets/svgs/bpm/condition.svg
  34. 1 0
      src/assets/svgs/bpm/copy.svg
  35. 0 0
      src/assets/svgs/bpm/finish.svg
  36. 1 0
      src/assets/svgs/bpm/parallel.svg
  37. 0 0
      src/assets/svgs/bpm/reject.svg
  38. 0 0
      src/assets/svgs/bpm/running.svg
  39. 1 0
      src/assets/svgs/bpm/simple-process-bg.svg
  40. 1 0
      src/assets/svgs/bpm/starter.svg
  41. 8 0
      src/components/AppLinkInput/data.ts
  42. 1 1
      src/components/ContentWrap/src/ContentWrap.vue
  43. 1 1
      src/components/DiyEditor/components/mobile/ProductCard/index.vue
  44. 1 1
      src/components/DiyEditor/components/mobile/ProductList/index.vue
  45. 96 0
      src/components/DiyEditor/components/mobile/PromotionPoint/config.ts
  46. 202 0
      src/components/DiyEditor/components/mobile/PromotionPoint/index.vue
  47. 154 0
      src/components/DiyEditor/components/mobile/PromotionPoint/property.vue
  48. 37 5
      src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts
  49. 161 95
      src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue
  50. 68 16
      src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue
  51. 4 1
      src/components/DiyEditor/components/mobile/TabBar/property.vue
  52. 3 2
      src/components/Editor/src/Editor.vue
  53. 0 1
      src/components/FormCreate/src/components/useApiSelect.tsx
  54. 1 1
      src/components/FormCreate/src/config/useDictSelectRule.ts
  55. 43 0
      src/components/FormCreate/src/utils/index.ts
  56. 1 0
      src/components/RouterSearch/index.vue
  57. 0 237
      src/components/SimpleProcessDesigner/src/addNode.vue
  58. 0 297
      src/components/SimpleProcessDesigner/src/nodeWrap.vue
  59. 0 165
      src/components/SimpleProcessDesigner/src/util.ts
  60. 0 1292
      src/components/SimpleProcessDesigner/theme/workflow.css
  61. 214 0
      src/components/SimpleProcessDesignerV2/src/NodeHandler.vue
  62. 118 0
      src/components/SimpleProcessDesignerV2/src/ProcessNodeTree.vue
  63. 179 0
      src/components/SimpleProcessDesignerV2/src/SimpleProcessDesigner.vue
  64. 140 0
      src/components/SimpleProcessDesignerV2/src/SimpleProcessModel.vue
  65. 48 0
      src/components/SimpleProcessDesignerV2/src/SimpleProcessViewer.vue
  66. 570 0
      src/components/SimpleProcessDesignerV2/src/consts.ts
  67. 5 0
      src/components/SimpleProcessDesignerV2/src/index.ts
  68. 495 0
      src/components/SimpleProcessDesignerV2/src/node.ts
  69. 419 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/ConditionNodeConfig.vue
  70. 374 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/CopyTaskNodeConfig.vue
  71. 135 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/StartUserNodeConfig.vue
  72. 905 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/UserTaskNodeConfig.vue
  73. 97 0
      src/components/SimpleProcessDesignerV2/src/nodes/CopyTaskNode.vue
  74. 102 0
      src/components/SimpleProcessDesignerV2/src/nodes/EndEventNode.vue
  75. 229 0
      src/components/SimpleProcessDesignerV2/src/nodes/ExclusiveNode.vue
  76. 233 0
      src/components/SimpleProcessDesignerV2/src/nodes/InclusiveNode.vue
  77. 184 0
      src/components/SimpleProcessDesignerV2/src/nodes/ParallelNode.vue
  78. 154 0
      src/components/SimpleProcessDesignerV2/src/nodes/StartUserNode.vue
  79. 174 0
      src/components/SimpleProcessDesignerV2/src/nodes/UserTaskNode.vue
  80. 41 0
      src/components/SimpleProcessDesignerV2/src/utils.ts
  81. 二進制
      src/components/SimpleProcessDesignerV2/theme/iconfont.ttf
  82. 二進制
      src/components/SimpleProcessDesignerV2/theme/iconfont.woff
  83. 二進制
      src/components/SimpleProcessDesignerV2/theme/iconfont.woff2
  84. 750 0
      src/components/SimpleProcessDesignerV2/theme/simple-process-designer.scss
  85. 9 14
      src/components/UploadFile/src/UploadImgs.vue
  86. 20 11
      src/components/UploadFile/src/useUpload.ts
  87. 152 0
      src/components/UserSelectForm/index.vue
  88. 327 612
      src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue
  89. 70 0
      src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json
  90. 5 1
      src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue
  91. 283 0
      src/components/bpmnProcessDesigner/package/penal/custom-config/ElementCustomConfig.vue
  92. 3 3
      src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue
  93. 1 1
      src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue
  94. 1 1
      src/components/bpmnProcessDesigner/package/theme/element-variables.scss
  95. 117 2
      src/components/bpmnProcessDesigner/package/theme/index.scss
  96. 3 3
      src/components/bpmnProcessDesigner/package/theme/process-designer.scss
  97. 4 8
      src/config/axios/index.ts
  98. 21 26
      src/config/axios/service.ts
  99. 11 0
      src/directives/index.ts
  100. 2 1
      src/directives/permission/hasPermi.ts

+ 0 - 2
.env.dev

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.local

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.prod

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.stage

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.test

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

二進制
.image/工作流设计器-bpmn.jpg


二進制
.image/工作流设计器-simple.jpg


+ 1 - 1
.vscode/settings.json

@@ -87,7 +87,7 @@
     "source.fixAll.stylelint": "explicit"
   },
   "[vue]": {
-    "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
   },
   "i18n-ally.localesPaths": ["src/locales"],
   "i18n-ally.keystyle": "nested",

+ 15 - 11
README.md

@@ -69,11 +69,11 @@
 
 支持 Spring Boot、Spring Cloud 两种架构:
 
-① Spring Boot 单体架构:<https://github.com/YunaiV/ruoyi-vue-pro>
+① Spring Boot 单体架构:<https://doc.iocoder.cn>
 
 ![架构图](/.image/common/ruoyi-vue-pro-architecture.png)
 
-② Spring Cloud 微服务架构:<https://github.com/YunaiV/yudao-cloud>
+② Spring Cloud 微服务架构:<https://cloud.iocoder.cn>
 
 ![架构图](/.image/common/yudao-cloud-architecture.png)
 
@@ -120,18 +120,22 @@
 
 ### 工作流程
 
-|     | 功能    | 描述                                     |
-|-----|-------|----------------------------------------|
-| 🚀  | 流程模型  | 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 |
-| 🚀  | 流程表单  | 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 |
-| 🚀  | 用户分组  | 自定义用户分组,可用于工作流的审批分组                    |
-| 🚀  | 我的流程  | 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线    |
-| 🚀  | 待办任务  | 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作    |
-| 🚀  | 已办任务  | 查看自己【已】审批的工作任务,未来会支持回退操作               |
-| 🚀  | OA 请假 | 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 |
+|    | 功能    | 描述                                      |
+|----|-------|-----------------------------------------|
+| 🚀 | 流程模型  | 配置工作流的流程模型,支持 BPMN 和仿钉钉/飞书设计器           |
+| 🚀 | 流程表单  | 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件  |
+| 🚀 | 用户分组  | 自定义用户分组,可用于工作流的审批分组                     |
+| 🚀 | 我的流程  | 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线     |
+| 🚀 | 待办任务  | 查看自己【未】审批的工作任务,支持通过、不通过、转派、委派、退回、加减签等操作 |
+| 🚀 | 已办任务  | 查看自己【已】审批的工作任务,支持流程预测,展示未来审批人信息         |
+| 🚀 | OA 请假 | 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批  |
 
 ![功能图](/.image/common/bpm-feature.png)
 
+| BPMN 设计器                     | 钉钉/飞书设计器                       |
+|------------------------------|--------------------------------|
+| ![](/.image/工作流设计器-bpmn.jpg) | ![](/.image/工作流设计器-simple.jpg) |
+
 ### 支付系统
 
 |     | 功能   | 描述                        |

+ 15 - 13
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yudao-ui-admin-vue3",
-  "version": "2.2.0-snapshot",
+  "version": "2.3.0-snapshot",
   "description": "基于vue3、vite4、element-plus、typesScript",
   "author": "xingyu",
   "private": false,
@@ -9,11 +9,11 @@
     "dev": "vite --mode env.local",
     "dev-server": "vite --mode dev",
     "ts:check": "vue-tsc --noEmit",
-    "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build",
-    "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
-    "build:test": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode test",
-    "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage",
-    "build:prod": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode prod",
+    "build:local": "node --max_old_space_size=4096 ./node_modules/vite/bin/vite.js build",
+    "build:dev": "node --max_old_space_size=4096 ./node_modules/vite/bin/vite.js build --mode dev",
+    "build:test": "node --max_old_space_size=4096 ./node_modules/vite/bin/vite.js build --mode test",
+    "build:stage": "node --max_old_space_size=4096 ./node_modules/vite/bin/vite.js build --mode stage",
+    "build:prod": "node --max_old_space_size=4096 ./node_modules/vite/bin/vite.js build --mode prod",
     "serve:dev": "vite preview --mode dev",
     "serve:prod": "vite preview --mode prod",
     "preview": "pnpm build:local && vite preview",
@@ -26,8 +26,8 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.1.0",
-    "@form-create/designer": "^3.1.3",
-    "@form-create/element-ui": "^3.1.24",
+    "@form-create/designer": "^3.2.6",
+    "@form-create/element-ui": "^3.2.11",
     "@iconify/iconify": "^3.1.1",
     "@microsoft/fetch-event-source": "^2.0.1",
     "@videojs-player/vue": "^1.0.0",
@@ -47,7 +47,7 @@
     "driver.js": "^1.3.1",
     "echarts": "^5.5.0",
     "echarts-wordcloud": "^2.1.0",
-    "element-plus": "2.8.0",
+    "element-plus": "2.8.4",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",
@@ -64,13 +64,14 @@
     "pinia-plugin-persistedstate": "^3.2.1",
     "qrcode": "^1.5.3",
     "qs": "^6.12.0",
+    "sortablejs": "^1.15.3",
     "steady-xml": "^0.1.0",
     "url": "^0.11.3",
     "video.js": "^7.21.5",
-    "vue": "3.4.21",
+    "vue": "3.5.12",
     "vue-dompurify-html": "^4.1.4",
     "vue-i18n": "9.10.2",
-    "vue-router": "^4.3.0",
+    "vue-router": "4.4.5",
     "vue-types": "^5.1.1",
     "vuedraggable": "^4.1.0",
     "web-storage-cache": "^1.1.1",
@@ -95,7 +96,7 @@
     "@vitejs/plugin-vue": "^5.0.4",
     "@vitejs/plugin-vue-jsx": "^3.1.0",
     "autoprefixer": "^10.4.17",
-    "bpmn-js": "8.9.0",
+    "bpmn-js": "8.10.0",
     "bpmn-js-properties-panel": "0.46.0",
     "consola": "^3.2.3",
     "eslint": "^8.57.0",
@@ -130,7 +131,7 @@
     "vite-plugin-progress": "^0.0.7",
     "vite-plugin-purge-icons": "^0.10.0",
     "vite-plugin-svg-icons": "^2.0.1",
-    "vite-plugin-top-level-await": "^1.3.1",
+    "vite-plugin-top-level-await": "^1.4.4",
     "vue-eslint-parser": "^9.3.2",
     "vue-tsc": "^1.8.27"
   },
@@ -143,6 +144,7 @@
     "url": "https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues"
   },
   "homepage": "https://gitee.com/yudaocode/yudao-ui-admin-vue3",
+  "web-types": "./web-types.json",
   "engines": {
     "node": ">= 16.0.0",
     "pnpm": ">=8.6.0"

File diff suppressed because it is too large
+ 2244 - 6345
pnpm-lock.yaml


+ 0 - 8
src/api/bpm/activity/index.ts

@@ -1,8 +0,0 @@
-import request from '@/config/axios'
-
-export const getActivityList = async (params) => {
-  return await request.get({
-    url: '/bpm/activity/list',
-    params
-  })
-}

+ 10 - 0
src/api/bpm/category/index.ts

@@ -36,6 +36,16 @@ export const CategoryApi = {
     return await request.put({ url: `/bpm/category/update`, data })
   },
 
+  // 批量修改流程分类的排序
+  updateCategorySortBatch: async (ids: number[]) => {
+    return await request.put({
+      url: `/bpm/category/update-sort-batch`,
+      params: {
+        ids: ids.join(',')
+      }
+    })
+  },
+
   // 删除流程分类
   deleteCategory: async (id: number) => {
     return await request.delete({ url: `/bpm/category/delete?id=` + id })

+ 17 - 3
src/api/bpm/model/index.ts

@@ -26,11 +26,11 @@ export type ModelVO = {
   bpmnXml: string
 }
 
-export const getModelPage = async (params) => {
-  return await request.get({ url: '/bpm/model/page', params })
+export const getModelList = async (name: string | undefined) => {
+  return await request.get({ url: '/bpm/model/list', params: { name } })
 }
 
-export const getModel = async (id: number) => {
+export const getModel = async (id: string) => {
   return await request.get({ url: '/bpm/model/get?id=' + id })
 }
 
@@ -38,6 +38,20 @@ export const updateModel = async (data: ModelVO) => {
   return await request.put({ url: '/bpm/model/update', data: data })
 }
 
+// 批量修改流程分类的排序
+export const updateModelSortBatch = async (ids: number[]) => {
+  return await request.put({
+    url: `/bpm/model/update-sort-batch`,
+    params: {
+      ids: ids.join(',')
+    }
+  })
+}
+
+export const updateModelBpmn = async (data: ModelVO) => {
+  return await request.put({ url: '/bpm/model/update-bpmn', data: data })
+}
+
 // 任务状态修改
 export const updateModelState = async (id: number, state: number) => {
   const data = {

+ 45 - 1
src/api/bpm/processInstance/index.ts

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 import { ProcessDefinitionVO } from '@/api/bpm/model'
-
+import { NodeType, CandidateStrategy } from '@/components/SimpleProcessDesignerV2/src/consts'
 export type Task = {
   id: string
   name: string
@@ -22,6 +22,35 @@ export type ProcessInstanceVO = {
   processDefinition?: ProcessDefinitionVO
 }
 
+// 用户信息
+export type User = {
+  id: number
+  nickname: string
+  avatar: string
+}
+
+// 审批任务信息
+export type ApprovalTaskInfo = {
+  id: number
+  ownerUser: User
+  assigneeUser: User
+  status: number
+  reason: string
+}
+
+// 审批节点信息
+export type ApprovalNodeInfo = {
+  id: number
+  name: string
+  nodeType: NodeType
+  candidateStrategy?: CandidateStrategy
+  status: number
+  startTime?: Date
+  endTime?: Date
+  candidateUsers?: User[]
+  tasks: ApprovalTaskInfo[]
+}
+
 export const getProcessInstanceMyPage = async (params: any) => {
   return await request.get({ url: '/bpm/process-instance/my-page', params })
 }
@@ -57,3 +86,18 @@ export const getProcessInstance = async (id: string) => {
 export const getProcessInstanceCopyPage = async (params: any) => {
   return await request.get({ url: '/bpm/process-instance/copy/page', params })
 }
+
+// 获取审批详情
+export const getApprovalDetail = async (params: any) => {
+  return await request.get({ url: 'bpm/process-instance/get-approval-detail' , params })
+}
+
+// 获取表单字段权限
+export const getFormFieldsPermission = async (params: any) => {
+  return await request.get({ url: '/bpm/process-instance/get-form-fields-permission', params })
+}
+
+// 获取流程实例的 BPMN 模型视图
+export const getProcessInstanceBpmnModelView = async (id: string) => {
+  return await request.get({ url: '/bpm/process-instance/get-bpmn-model-view?id=' + id })
+}

+ 15 - 0
src/api/bpm/simple/index.ts

@@ -0,0 +1,15 @@
+import request from '@/config/axios'
+
+
+export const updateBpmSimpleModel = async (data) => {
+  return await request.post({
+    url: '/bpm/model/simple/update',
+    data: data
+  })
+}
+
+export const getBpmSimpleModel = async (id) => {
+  return await request.get({
+    url: '/bpm/model/simple/get?id=' + id
+  })
+}

+ 51 - 4
src/api/bpm/task/index.ts

@@ -1,7 +1,44 @@
 import request from '@/config/axios'
 
-export type TaskVO = {
-  id: number
+/**
+ * 任务状态枚举
+ */
+export enum TaskStatusEnum {
+  /**
+   * 未开始
+   */
+  NOT_START = -1,
+
+  /**
+   * 待审批
+   */
+  WAIT = 0,
+  /**
+   * 审批中
+   */
+  RUNNING = 1,
+  /**
+   * 审批通过
+   */
+  APPROVE = 2,
+
+  /**
+   * 审批不通过
+   */
+  REJECT = 3,
+
+  /**
+   * 已取消
+   */
+  CANCEL = 4,
+  /**
+   * 已退回
+   */
+  RETURN = 5,
+  /**
+   * 审批通过中
+   */
+  APPROVING = 7
 }
 
 export const getTaskTodoPage = async (params: any) => {
@@ -30,12 +67,12 @@ export const getTaskListByProcessInstanceId = async (processInstanceId: string)
   })
 }
 
-// 获取所有可退的节点
+// 获取所有可退的节点
 export const getTaskListByReturn = async (id: string) => {
   return await request.get({ url: '/bpm/task/list-by-return', params: { id } })
 }
 
-// 退
+// 退
 export const returnTask = async (data: any) => {
   return await request.put({ url: '/bpm/task/return', data })
 }
@@ -60,6 +97,16 @@ export const signDeleteTask = async (data: any) => {
   return await request.delete({ url: '/bpm/task/delete-sign', data })
 }
 
+// 抄送
+export const copyTask = async (data: any) => {
+  return await request.put({ url: '/bpm/task/copy', data })
+}
+
+// 获取我的待办任务
+export const myTodoTask = async (processInstanceId: string) => {
+  return await request.get({ url: '/bpm/task/my-todo?processInstanceId=' + processInstanceId })
+}
+
 // 获取减签任务列表
 export const getChildrenTaskList = async (id: string) => {
   return await request.get({ url: '/bpm/task/list-by-parent-task-id?parentTaskId=' + id })

+ 6 - 1
src/api/login/index.ts

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 import { getRefreshToken } from '@/utils/auth'
-import type { UserLoginVO } from './types'
+import type { RegisterVO, UserLoginVO } from './types'
 
 export interface SmsCodeVO {
   mobile: string
@@ -17,6 +17,11 @@ export const login = (data: UserLoginVO) => {
   return request.post({ url: '/system/auth/login', data })
 }
 
+// 注册
+export const register = (data: RegisterVO) => {
+  return request.post({ url: '/system/auth/register', data })
+}
+
 // 刷新访问令牌
 export const refreshToken = () => {
   return request.post({ url: '/system/auth/refresh-token?refreshToken=' + getRefreshToken() })

+ 1 - 1
src/api/login/oauth2/index.ts

@@ -27,7 +27,7 @@ export const authorize = (
   return request.post({
     url: '/system/oauth2/authorize',
     headers: {
-      'Content-type': 'application/x-www-form-urlencoded'
+      'Content-Type': 'application/x-www-form-urlencoded'
     },
     params: {
       response_type: responseType,

+ 7 - 0
src/api/login/types.ts

@@ -29,3 +29,10 @@ export type UserVO = {
   loginIp: string
   loginDate: string
 }
+
+export type RegisterVO = {
+  tenantName: string
+  username: string
+  password: string
+  captchaVerification: string
+}

+ 5 - 1
src/api/mall/promotion/kefu/conversation/index.ts

@@ -21,6 +21,10 @@ export const KeFuConversationApi = {
   getConversationList: async () => {
     return await request.get({ url: '/promotion/kefu-conversation/list' })
   },
+  // 获得客服会话
+  getConversation: async (id: number) => {
+    return await request.get({ url: `/promotion/kefu-conversation/get?id=` + id })
+  },
   // 客服会话置顶
   updateConversationPinned: async (data: any) => {
     return await request.put({
@@ -30,6 +34,6 @@ export const KeFuConversationApi = {
   },
   // 删除客服会话
   deleteConversation: async (id: number) => {
-    return await request.delete({ url: `/promotion/kefu-conversation/delete?id=${id}`})
+    return await request.delete({ url: `/promotion/kefu-conversation/delete?id=${id}` })
   }
 }

+ 3 - 3
src/api/mall/promotion/kefu/message/index.ts

@@ -29,8 +29,8 @@ export const KeFuMessageApi = {
       url: '/promotion/kefu-message/update-read-status?conversationId=' + conversationId
     })
   },
-  // 获得消息分页数据
-  getKeFuMessagePage: async (params: any) => {
-    return await request.get({ url: '/promotion/kefu-message/page', params })
+  // 获得消息列表(流式加载)
+  getKeFuMessageList: async (params: any) => {
+    return await request.get({ url: '/promotion/kefu-message/list', params })
   }
 }

+ 91 - 0
src/api/mall/promotion/point/index.ts

@@ -0,0 +1,91 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu' // 积分商城活动 VO
+
+// 积分商城活动 VO
+export interface PointActivityVO {
+  id: number // 积分商城活动编号
+  spuId: number // 积分商城活动商品
+  status: number // 活动状态
+  stock: number // 积分商城活动库存
+  totalStock: number // 积分商城活动总库存
+  remark?: string // 备注
+  sort: number // 排序
+  createTime: string // 创建时间
+  products: PointProductVO[] // 积分商城商品
+
+  // ========== 商品字段 ==========
+  spuName: string // 商品名称
+  picUrl: string // 商品主图
+  marketPrice: number // 商品市场价,单位:分
+
+  //======================= 显示所需兑换积分最少的 sku 信息 =======================
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+}
+
+// 秒杀活动所需属性
+export interface PointProductVO {
+  id?: number // 积分商城商品编号
+  activityId?: number // 积分商城活动 id
+  spuId?: number // 商品 SPU 编号
+  skuId: number // 商品 SKU 编号
+  count: number // 可兑换数量
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+  stock: number // 积分商城商品库存
+  activityStatus?: number // 积分商城商品状态
+}
+
+// 扩展 Sku 配置
+export type SkuExtension = Sku & {
+  productConfig: PointProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+export interface SpuExtension0 extends Spu {
+  pointStock: number // 积分商城活动库存
+  pointTotalStock: number // 积分商城活动总库存
+  point: number // 兑换积分
+  pointPrice: number // 兑换金额,单位:分
+}
+
+// 积分商城活动 API
+export const PointActivityApi = {
+  // 查询积分商城活动分页
+  getPointActivityPage: async (params: any) => {
+    return await request.get({ url: `/promotion/point-activity/page`, params })
+  },
+
+  // 查询积分商城活动详情
+  getPointActivity: async (id: number) => {
+    return await request.get({ url: `/promotion/point-activity/get?id=` + id })
+  },
+
+  // 查询积分商城活动列表,基于活动编号数组
+  getPointActivityListByIds: async (ids: number[]) => {
+    return request.get({ url: `/promotion/point-activity/list-by-ids?ids=${ids}` })
+  },
+
+  // 新增积分商城活动
+  createPointActivity: async (data: PointActivityVO) => {
+    return await request.post({ url: `/promotion/point-activity/create`, data })
+  },
+
+  // 修改积分商城活动
+  updatePointActivity: async (data: PointActivityVO) => {
+    return await request.put({ url: `/promotion/point-activity/update`, data })
+  },
+
+  // 删除积分商城活动
+  deletePointActivity: async (id: number) => {
+    return await request.delete({ url: `/promotion/point-activity/delete?id=` + id })
+  },
+
+  // 关闭秒杀活动
+  closePointActivity: async (id: number) => {
+    return await request.put({ url: '/promotion/point-activity/close?id=' + id })
+  }
+}

+ 6 - 1
src/api/mall/promotion/reward/rewardActivity.ts

@@ -47,7 +47,12 @@ export const getReward = async (id: number) => {
   return await request.get({ url: '/promotion/reward-activity/get?id=' + id })
 }
 
-// 删除限时折扣活动
+// 删除满减送活动
 export const deleteRewardActivity = async (id: number) => {
   return await request.delete({ url: '/promotion/reward-activity/delete?id=' + id })
 }
+
+// 关闭满减送活动
+export const closeRewardActivity = async (id: number) => {
+  return await request.put({ url: '/promotion/reward-activity/close?id=' + id })
+}

+ 6 - 0
src/api/mall/promotion/seckill/seckillActivity.ts

@@ -18,6 +18,7 @@ export interface SeckillActivityVO {
   singleLimitCount?: number
   stock?: number
   totalStock?: number
+  seckillPrice?: number
   products?: SeckillProductVO[]
 }
 
@@ -43,6 +44,11 @@ export const getSeckillActivityPage = async (params) => {
   return await request.get({ url: '/promotion/seckill-activity/page', params })
 }
 
+// 查询秒杀活动列表,基于活动编号数组
+export const getSeckillActivityListByIds = (ids: number[]) => {
+  return request.get({ url: `/promotion/seckill-activity/list-by-ids?ids=${ids}` })
+}
+
 // 查询秒杀活动详情
 export const getSeckillActivity = async (id: number) => {
   return await request.get({ url: '/promotion/seckill-activity/get?id=' + id })

+ 9 - 3
src/api/mall/trade/delivery/pickUpStore/index.ts

@@ -13,10 +13,11 @@ export interface DeliveryPickUpStoreVO {
   latitude: number
   longitude: number
   status: number
+  verifyUserIds: number[] // 绑定用户编号组数
 }
 
 // 查询自提门店列表
-export const getDeliveryPickUpStorePage = async (params) => {
+export const getDeliveryPickUpStorePage = async (params: any) => {
   return await request.get({ url: '/trade/delivery/pick-up-store/page', params })
 }
 
@@ -26,8 +27,8 @@ export const getDeliveryPickUpStore = async (id: number) => {
 }
 
 // 查询自提门店精简列表
-export const getListAllSimple = async (): Promise<DeliveryPickUpStoreVO[]> => {
-  return await request.get({ url: '/trade/delivery/pick-up-store/list-all-simple' })
+export const getSimpleDeliveryPickUpStoreList = async (): Promise<DeliveryPickUpStoreVO[]> => {
+  return await request.get({ url: '/trade/delivery/pick-up-store/simple-list' })
 }
 
 // 新增自提门店
@@ -44,3 +45,8 @@ export const updateDeliveryPickUpStore = async (data: DeliveryPickUpStoreVO) =>
 export const deleteDeliveryPickUpStore = async (id: number) => {
   return await request.delete({ url: '/trade/delivery/pick-up-store/delete?id=' + id })
 }
+
+// 绑定自提店员
+export const bindStoreStaffId = async (data: any) => {
+  return await request.post({ url: '/trade/delivery/pick-up-store/bind', data })
+}

+ 2 - 0
src/api/pay/app/index.ts

@@ -8,6 +8,7 @@ export interface AppVO {
   remark: string
   payNotifyUrl: string
   refundNotifyUrl: string
+  transferNotifyUrl: string
   merchantId: number
   merchantName: string
   createTime: Date
@@ -19,6 +20,7 @@ export interface AppPageReqVO extends PageParam {
   remark?: string
   payNotifyUrl?: string
   refundNotifyUrl?: string
+  transferNotifyUrl?: string
   merchantName?: string
   createTime?: Date[]
 }

+ 8 - 2
src/api/pay/order/index.ts

@@ -84,8 +84,14 @@ export const getOrderPage = async (params: OrderPageReqVO) => {
 }
 
 // 查询详情支付订单
-export const getOrder = async (id: number) => {
-  return await request.get({ url: '/pay/order/get?id=' + id })
+export const getOrder = async (id: number, sync?: boolean) => {
+  return await request.get({
+    url: '/pay/order/get',
+    params: {
+      id,
+      sync
+    }
+  })
 }
 
 // 获得支付订单的明细

+ 1 - 0
src/assets/svgs/bpm/add-user.svg

@@ -0,0 +1 @@
+<svg t="1731390087280" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4297" width="200" height="200"><path d="M639.9 541.7c76.4-44.2 127.9-126.8 127.9-221.5C767.7 179 653.2 64.5 512 64.5S256.3 179 256.3 320.2c0 89.6 46.1 168.4 115.8 214.1C193.5 593 64.5 761.2 64.5 959.5h63.9c0-211.5 172.1-383.6 383.6-383.6 44.9 0 87.8 8.1 127.9 22.4v-56.6zM320.2 320.2c0-105.8 86-191.8 191.8-191.8s191.8 86 191.8 191.8S617.7 512 512 512s-191.8-86-191.8-191.8zM831.6 767.7V639.9h-63.9v127.8H639.9v63.9h127.8v127.9h63.9V831.6h127.9v-63.9z" fill="#5f6266" p-id="4298"></path></svg>

+ 0 - 0
src/assets/svgs/bpm/audit2.svg → src/assets/svgs/bpm/approve.svg


+ 1 - 0
src/assets/svgs/bpm/auditor.svg

@@ -0,0 +1 @@
+<svg t="1729561718271" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8640" width="200" height="200"><path d="M908.5952 920.4224H164.7616a31.0784 31.0784 0 0 1-30.976-30.976c0-17.0496 13.9264-30.976 30.976-30.976h743.8336c17.0496 0 31.0272 13.9264 31.0272 30.976a31.0784 31.0784 0 0 1-31.0272 30.976z m0-123.9552H164.7616a31.0784 31.0784 0 0 1-30.976-30.976v-154.9824c0-51.1488 41.8304-92.9792 92.9792-92.9792h198.3488c-6.1952-37.1712-24.7808-72.8064-51.1488-103.8336a216.576 216.576 0 0 1-54.2208-144.128c0-58.88 23.2448-114.688 66.6112-156.4672C429.7728 71.168 485.5296 51.0976 545.9968 52.6848c111.5648 4.608 206.08 100.6592 207.616 212.2752 1.536 55.808-20.1216 110.0288-57.344 151.8592-26.3168 27.904-41.8304 61.952-48.0256 100.7104h198.3488c51.2 0 93.0304 41.8304 93.0304 92.9792v154.9824a31.0784 31.0784 0 0 1-31.0272 30.976z m-712.8064-61.952H877.568v-124.0064a31.0784 31.0784 0 0 0-31.0272-30.976h-232.448a31.0784 31.0784 0 0 1-30.976-31.0272c0-65.024 23.2448-127.0784 66.6624-173.568 27.8528-29.3888 41.8304-68.1472 41.8304-108.4416-1.536-80.5888-68.1984-148.7872-148.7872-151.8592a150.528 150.528 0 0 0-113.152 43.3664 153.6 153.6 0 0 0-48.0256 111.616c0 37.1712 13.9776 74.3424 38.7584 102.2464 44.9536 51.1488 69.7344 113.152 69.7344 176.64a31.0784 31.0784 0 0 1-30.976 31.0272h-232.448a31.0784 31.0784 0 0 0-30.976 30.976v123.9552z" fill="#fff" p-id="8641"></path></svg>

File diff suppressed because it is too large
+ 0 - 0
src/assets/svgs/bpm/cancel.svg


+ 1 - 0
src/assets/svgs/bpm/condition.svg

@@ -0,0 +1 @@
+<svg t="1729585232424" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1602" width="200" height="200"><path d="M925.5 898.9H804.9c-19 0-34.5-15.4-34.5-34.4V761.3c0-19 15.4-34.4 34.5-34.4h34.5V572.2c0-19-15.4-34.4-34.5-34.4H529.2V727h34.5c19 0 34.5 15.4 34.5 34.4v103.2c0 19-15.4 34.4-34.5 34.4H443.1c-19 0-34.5-15.4-34.5-34.4V761.3c0-19 15.4-34.4 34.5-34.4h34.5V537.8H219.1c-19 0-34.5 15.4-34.5 34.4V727h34.5c19 0 34.5 15.4 34.5 34.4v103.2c0 19-15.4 34.4-34.5 34.4H98.5c-19 0-34.5-15.4-34.5-34.4V761.3c0-19 15.4-34.4 34.5-34.4H133V555c0-38 30.9-68.8 68.9-68.8h275.7V297.1h-34.5c-19 0-34.5-15.4-34.5-34.4V159.5c0-19 15.4-34.4 34.5-34.4h120.6c19 0 34.5 15.4 34.5 34.4v103.2c0 19-15.4 34.4-34.5 34.4h-34.5v189.2h292.9c38.1 0 68.9 30.8 68.9 68.8v172h34.5c19 0 34.5 15.4 34.5 34.4v103.2c0 18.8-15.4 34.2-34.5 34.2z m0 0" p-id="1603" fill="#fff"></path></svg>

+ 1 - 0
src/assets/svgs/bpm/copy.svg

@@ -0,0 +1 @@
+<svg t="1729649333541" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1644" width="200" height="200"><path d="M647.888 893.84L491.904 571.52l393.888-393.888-237.904 716.208zM872.32 123.232L459.872 535.68 134.96 380.88 872.32 123.232z m90.72-68.32a23.968 23.968 0 0 0-24.784-5.568L64.08 354.816a23.984 23.984 0 0 0-2.4 44.32l381.392 181.728 187.36 387.088a24.048 24.048 0 0 0 23.152 13.504 24.032 24.032 0 0 0 21.232-16.4L968.96 79.552c2.88-8.672 0.592-18.24-5.92-24.64z" fill="#fff" p-id="1645"></path></svg>

File diff suppressed because it is too large
+ 0 - 0
src/assets/svgs/bpm/finish.svg


+ 1 - 0
src/assets/svgs/bpm/parallel.svg

@@ -0,0 +1 @@
+<svg t="1729585239190" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1755" width="200" height="200"><path d="M901.489435 536.822664v-0.931601l-1.001722-198.240726c-0.100172-19.162936-9.21584-37.474409-25.043042-50.246361-14.024104-11.349507-32.265456-17.60025-51.348255-17.610268l-618.062295-0.18031c-19.142902 0-37.424323 6.280795-51.478478 17.690405-15.827203 12.842072-24.902802 31.2437-24.892785 50.486775v196.798247A114.987635 114.987635 0 1 0 195.295664 536.922836V338.782282c1.15198-1.252152 4.808264-3.596181 10.768509-3.596181l276.725622 0.090155v199.753326a114.987635 114.987635 0 1 0 65.612772 1.412428V335.326342l275.693849 0.080138c6.01033 0 9.626546 2.344029 10.768508 3.596181l1.001722 195.70637a114.987635 114.987635 0 1 0 65.592737 2.113633zM214.979496 645.910158a56.437001 56.437001 0 1 1-56.437001-56.437001 56.507122 56.507122 0 0 1 56.437001 56.437001z m354.689623 0a56.437001 56.437001 0 1 1-56.437001-56.437001 56.507122 56.507122 0 0 1 56.437001 56.437001z m295.507904 56.437001a56.437001 56.437001 0 1 1 56.437001-56.437001 56.507122 56.507122 0 0 1-56.457035 56.437001z" p-id="1756" fill='#fff'></path></svg>

+ 0 - 0
src/assets/svgs/bpm/audit3.svg → src/assets/svgs/bpm/reject.svg


+ 0 - 0
src/assets/svgs/bpm/audit1.svg → src/assets/svgs/bpm/running.svg


+ 1 - 0
src/assets/svgs/bpm/simple-process-bg.svg

@@ -0,0 +1 @@
+<svg width="22" height="22" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#FAFAFA" d="M0 0h22v22H0z"/><circle fill="#919BAE" cx="1" cy="1" r="1"/></g></svg>

+ 1 - 0
src/assets/svgs/bpm/starter.svg

@@ -0,0 +1 @@
+<svg t="1729561814171" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1359" width="200" height="200"><path d="M674.496 603.456c120.256 0 218.176 90.752 221.44 203.84l0.064 5.888v125.888c0 11.52-9.92 20.928-22.144 20.928h-44.352a21.568 21.568 0 0 1-22.144-20.928v-125.888c0-67.712-56.512-123.264-128-125.76l-4.928-0.064H349.568c-71.488 0-130.176 53.504-132.864 121.152l-0.064 4.672v125.888c0 11.52-9.92 20.928-22.144 20.928h-44.352A21.568 21.568 0 0 1 128 939.072v-125.888c0-113.92 95.872-206.528 215.36-209.664l6.208-0.064h324.928zM497.216 128c122.368 0 221.568 93.888 221.568 209.728s-99.2 209.792-221.568 209.792c-122.304 0-221.44-93.952-221.44-209.728C275.712 221.952 374.848 128 497.152 128z m0 83.904c-73.408 0-132.864 56.32-132.864 125.888 0 69.504 59.52 125.824 132.864 125.824 73.408 0 132.928-56.32 132.928-125.824 0-69.504-59.52-125.888-132.928-125.888z" fill="#fff" p-id="1360"></path></svg>

+ 8 - 0
src/components/AppLinkInput/data.ts

@@ -5,6 +5,7 @@ export interface AppLinkGroup {
   // 链接列表
   links: AppLink[]
 }
+
 // APP 链接
 export interface AppLink {
   // 链接名称
@@ -21,6 +22,8 @@ export const enum APP_LINK_TYPE_ENUM {
   ACTIVITY_COMBINATION,
   // 秒杀活动
   ACTIVITY_SECKILL,
+  // 积分商城活动
+  ACTIVITY_POINT,
   // 文章详情
   ARTICLE_DETAIL,
   // 优惠券详情
@@ -130,6 +133,11 @@ export const APP_LINK_GROUP_LIST = [
         path: '/pages/activity/seckill/list',
         type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL
       },
+      {
+        name: '积分商城活动',
+        path: '/pages/activity/point/list',
+        type: APP_LINK_TYPE_ENUM.ACTIVITY_POINT
+      },
       {
         name: '签到中心',
         path: '/pages/app/sign'

+ 1 - 1
src/components/ContentWrap/src/ContentWrap.vue

@@ -11,7 +11,7 @@ const prefixCls = getPrefixCls('content-wrap')
 defineProps({
   title: propTypes.string.def(''),
   message: propTypes.string.def(''),
-  bodyStyle: propTypes.object.def({ padding: '20px' })
+  bodyStyle: propTypes.object.def({ padding: '10px' })
 })
 </script>
 

+ 1 - 1
src/components/DiyEditor/components/mobile/ProductCard/index.vue

@@ -67,7 +67,7 @@
             class="text-16px"
             :style="{ color: property.fields.price.color }"
           >
-            ¥{{ fenToYuan(spu.price) }}
+            ¥{{ fenToYuan(spu.price as any) }}
           </span>
           <!-- 市场价 -->
           <span

+ 1 - 1
src/components/DiyEditor/components/mobile/ProductList/index.vue

@@ -65,7 +65,7 @@
 <script setup lang="ts">
 import { ProductListProperty } from './config'
 import * as ProductSpuApi from '@/api/mall/product/spu'
-import { fenToYuan } from './index'
+import { fenToYuan } from '@/utils'
 
 /** 商品栏 */
 defineOptions({ name: 'ProductList' })

+ 96 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/config.ts

@@ -0,0 +1,96 @@
+import {ComponentStyle, DiyComponent} from '@/components/DiyEditor/util'
+
+/** 积分商城属性 */
+export interface PromotionPointProperty {
+  // 布局类型:单列 | 三列
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: PromotionPointFieldProperty
+    // 商品简介
+    introduction: PromotionPointFieldProperty
+    // 商品价格
+    price: PromotionPointFieldProperty
+    // 市场价
+    marketPrice: PromotionPointFieldProperty
+    // 商品销量
+    salesCount: PromotionPointFieldProperty
+    // 商品库存
+    stock: PromotionPointFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 秒杀活动编号
+  activityIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 商品字段
+export interface PromotionPointFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'PromotionPoint',
+  name: '积分商城',
+  icon: 'ep:present',
+  property: {
+    layoutType: 'oneColBigImg',
+    fields: {
+      name: { show: true, color: '#000' },
+      introduction: { show: true, color: '#999' },
+      price: { show: true, color: '#ff3000' },
+      marketPrice: { show: true, color: '#c4c4c4' },
+      salesCount: { show: true, color: '#c4c4c4' },
+      stock: { show: false, color: '#c4c4c4' }
+    },
+    badge: { show: false, imgUrl: '' },
+    btnBuy: {
+      type: 'text',
+      text: '立即兑换',
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<PromotionPointProperty>

+ 202 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/index.vue

@@ -0,0 +1,202 @@
+<template>
+  <div ref="containerRef" :class="`box-content min-h-30px w-full flex flex-row flex-wrap`">
+    <div
+      v-for="(spu, index) in spuList"
+      :key="index"
+      :style="{
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
+      }"
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+    >
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image :src="property.badge.imgUrl" class="h-26px w-38px" fit="cover" />
+      </div>
+      <!-- 商品封面图 -->
+      <div
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <el-image :src="spu.picUrl" class="h-full w-full" fit="cover" />
+      </div>
+      <div
+        :class="[
+          ' flex flex-col gap-8px p-8px box-border',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <!-- 商品名称 -->
+        <div
+          v-if="property.fields.name.show"
+          :class="[
+            'text-14px ',
+            {
+              truncate: property.layoutType !== 'oneColSmallImg',
+              'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
+            }
+          ]"
+          :style="{ color: property.fields.name.color }"
+        >
+          {{ spu.name }}
+        </div>
+        <!-- 商品简介 -->
+        <div
+          v-if="property.fields.introduction.show"
+          :style="{ color: property.fields.introduction.color }"
+          class="truncate text-12px"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 积分 -->
+          <span
+            v-if="property.fields.price.show"
+            :style="{ color: property.fields.price.color }"
+            class="text-16px"
+          >
+            {{ spu.point }}积分
+            {{ !spu.pointPrice || spu.pointPrice === 0 ? '' : `+${fenToYuan(spu.pointPrice)}元` }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            :style="{ color: property.fields.marketPrice.color }"
+            class="ml-4px text-10px line-through"
+          >
+            ¥{{ fenToYuan(spu.marketPrice) }}
+          </span>
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
+          >
+            已兑{{ (spu.pointTotalStock || 0) - (spu.pointStock || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.pointTotalStock || 0 }}
+          </span>
+        </div>
+      </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          :src="property.btnBuy.imgUrl"
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { PromotionPointProperty } from './config'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { PointActivityApi, PointActivityVO, SpuExtension0 } from '@/api/mall/promotion/point'
+import { fenToYuan } from '@/utils'
+
+/** 积分商城卡片 */
+defineOptions({ name: 'PromotionPoint' })
+// 定义属性
+const props = defineProps<{ property: PromotionPointProperty }>()
+// 商品列表
+const spuList = ref<SpuExtension0[]>([])
+const spuIdList = ref<number[]>([])
+const pointActivityList = ref<PointActivityVO[]>([])
+
+watch(
+  () => props.property.activityIds,
+  async () => {
+    try {
+      // 新添加的积分商城组件,是没有活动ID的
+      const activityIds = props.property.activityIds
+      // 检查活动ID的有效性
+      if (Array.isArray(activityIds) && activityIds.length > 0) {
+        // 获取积分商城活动详情列表
+        pointActivityList.value = await PointActivityApi.getPointActivityListByIds(activityIds)
+
+        // 获取积分商城活动的 SPU 详情列表
+        spuList.value = []
+        spuIdList.value = pointActivityList.value.map((activity) => activity.spuId)
+        if (spuIdList.value.length > 0) {
+          spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value)
+        }
+
+        // 更新 SPU 的最低兑换积分和所需兑换金额
+        pointActivityList.value.forEach((activity) => {
+          // 匹配spuId
+          const spu = spuList.value.find((spu) => spu.id === activity.spuId)
+          if (spu) {
+            spu.pointStock = activity.stock
+            spu.pointTotalStock = activity.totalStock
+            spu.point = activity.point
+            spu.pointPrice = activity.price
+          }
+        })
+      }
+    } catch (error) {
+      console.error('获取积分商城活动细节或 SPU 细节时出错:', error)
+    }
+  },
+  {
+    immediate: true,
+    deep: true
+  }
+)
+
+/**
+ * 计算商品的间距
+ * @param index 商品索引
+ */
+const calculateSpace = (index: number) => {
+  // 商品的列数
+  const columns = props.property.layoutType === 'twoCol' ? 2 : 1
+  // 第一列没有左边距
+  const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
+  // 第一行没有上边距
+  const marginTop = index < columns ? '0' : props.property.space + 'px'
+
+  return { marginLeft, marginTop }
+}
+
+// 容器
+const containerRef = ref()
+// 计算商品的宽度
+const calculateWidth = () => {
+  let width = '100%'
+  // 双列时每列的宽度为:(总宽度 - 间距)/ 2
+  if (props.property.layoutType === 'twoCol') {
+    width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
+  }
+  return { width }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 154 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/property.vue

@@ -0,0 +1,154 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form :model="formData" label-width="80px">
+      <el-card class="property-group" header="积分商城活动" shadow="never">
+        <PointShowcase v-model="formData.activityIds" />
+      </el-card>
+      <el-card class="property-group" header="商品样式" shadow="never">
+        <el-form-item label="布局" prop="type">
+          <el-radio-group v-model="formData.layoutType">
+            <el-tooltip class="item" content="单列大图" placement="bottom">
+              <el-radio-button value="oneColBigImg">
+                <Icon icon="fluent:text-column-one-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button value="oneColSmallImg">
+                <Icon icon="fluent:text-column-two-left-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="双列" placement="bottom">
+              <el-radio-button value="twoCol">
+                <Icon icon="fluent:text-column-two-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <!--<el-tooltip class="item" content="三列" placement="bottom">
+              <el-radio-button value="threeCol">
+                <Icon icon="fluent:text-column-three-24-filled" />
+              </el-radio-button>
+            </el-tooltip>-->
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="商品名称" prop="fields.name.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.name.color" />
+            <el-checkbox v-model="formData.fields.name.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品简介" prop="fields.introduction.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.introduction.color" />
+            <el-checkbox v-model="formData.fields.introduction.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品价格" prop="fields.price.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.price.color" />
+            <el-checkbox v-model="formData.fields.price.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="市场价" prop="fields.marketPrice.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.marketPrice.color" />
+            <el-checkbox v-model="formData.fields.marketPrice.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品销量" prop="fields.salesCount.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.salesCount.color" />
+            <el-checkbox v-model="formData.fields.salesCount.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品库存" prop="fields.stock.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.stock.color" />
+            <el-checkbox v-model="formData.fields.stock.show" />
+          </div>
+        </el-form-item>
+      </el-card>
+      <el-card class="property-group" header="角标" shadow="never">
+        <el-form-item label="角标" prop="badge.show">
+          <el-switch v-model="formData.badge.show" />
+        </el-form-item>
+        <el-form-item v-if="formData.badge.show" label="角标" prop="badge.imgUrl">
+          <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
+            <template #tip> 建议尺寸:36 * 22</template>
+          </UploadImg>
+        </el-form-item>
+      </el-card>
+      <el-card class="property-group" header="按钮" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button value="text">文字</el-radio-button>
+            <el-radio-button value="img">图片</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="formData.btnBuy.type === 'text'">
+          <el-form-item label="按钮文字" prop="btnBuy.text">
+            <el-input v-model="formData.btnBuy.text" />
+          </el-form-item>
+          <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
+            <ColorInput v-model="formData.btnBuy.bgBeginColor" />
+          </el-form-item>
+          <el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
+            <ColorInput v-model="formData.btnBuy.bgEndColor" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="图片" prop="btnBuy.imgUrl">
+            <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
+              <template #tip> 建议尺寸:56 * 56</template>
+            </UploadImg>
+          </el-form-item>
+        </template>
+      </el-card>
+      <el-card class="property-group" header="商品样式" shadow="never">
+        <el-form-item label="上圆角" prop="borderRadiusTop">
+          <el-slider
+            v-model="formData.borderRadiusTop"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+        <el-form-item label="下圆角" prop="borderRadiusBottom">
+          <el-slider
+            v-model="formData.borderRadiusBottom"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+        <el-form-item label="间隔" prop="space">
+          <el-slider
+            v-model="formData.space"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script lang="ts" setup>
+import { PromotionPointProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import PointShowcase from '@/views/mall/promotion/point/components/PointShowcase.vue'
+
+// 秒杀属性面板
+defineOptions({ name: 'PromotionPointProperty' })
+
+const props = defineProps<{ modelValue: PromotionPointProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style lang="scss" scoped></style>

+ 37 - 5
src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts

@@ -3,13 +3,21 @@ import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 /** 秒杀属性 */
 export interface PromotionSeckillProperty {
   // 布局类型:单列 | 三列
-  layoutType: 'oneCol' | 'threeCol'
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
   // 商品字段
   fields: {
     // 商品名称
     name: PromotionSeckillFieldProperty
+    // 商品简介
+    introduction: PromotionSeckillFieldProperty
     // 商品价格
     price: PromotionSeckillFieldProperty
+    // 市场价
+    marketPrice: PromotionSeckillFieldProperty
+    // 商品销量
+    salesCount: PromotionSeckillFieldProperty
+    // 商品库存
+    stock: PromotionSeckillFieldProperty
   }
   // 角标
   badge: {
@@ -18,6 +26,19 @@ export interface PromotionSeckillProperty {
     // 角标图片
     imgUrl: string
   }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
   // 上圆角
   borderRadiusTop: number
   // 下圆角
@@ -25,10 +46,11 @@ export interface PromotionSeckillProperty {
   // 间距
   space: number
   // 秒杀活动编号
-  activityId: number
+  activityIds: number[]
   // 组件样式
   style: ComponentStyle
 }
+
 // 商品字段
 export interface PromotionSeckillFieldProperty {
   // 是否显示
@@ -43,13 +65,23 @@ export const component = {
   name: '秒杀',
   icon: 'mdi:calendar-time',
   property: {
-    activityId: undefined,
-    layoutType: 'oneCol',
+    layoutType: 'oneColBigImg',
     fields: {
       name: { show: true, color: '#000' },
-      price: { show: true, color: '#ff3000' }
+      introduction: { show: true, color: '#999' },
+      price: { show: true, color: '#ff3000' },
+      marketPrice: { show: true, color: '#c4c4c4' },
+      salesCount: { show: true, color: '#c4c4c4' },
+      stock: { show: false, color: '#c4c4c4' }
     },
     badge: { show: false, imgUrl: '' },
+    btnBuy: {
+      type: 'text',
+      text: '立即秒杀',
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
     borderRadiusTop: 8,
     borderRadiusBottom: 8,
     space: 8,

+ 161 - 95
src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue

@@ -1,135 +1,201 @@
 <template>
-  <el-scrollbar ref="containerRef" class="z-1 min-h-30px" wrap-class="w-full">
-    <!-- 商品网格 -->
+  <div :class="`box-content min-h-30px w-full flex flex-row flex-wrap`" ref="containerRef">
     <div
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
       :style="{
-        gridGap: `${property.space}px`,
-        gridTemplateColumns,
-        width: scrollbarWidth
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
       }"
-      class="grid overflow-x-auto"
+      v-for="(spu, index) in spuList"
+      :key="index"
     >
-      <!-- 商品 -->
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" />
+      </div>
+      <!-- 商品封面图 -->
       <div
-        v-for="(spu, index) in spuList"
-        :key="index"
-        :style="{
-          borderTopLeftRadius: `${property.borderRadiusTop}px`,
-          borderTopRightRadius: `${property.borderRadiusTop}px`,
-          borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
-          borderBottomRightRadius: `${property.borderRadiusBottom}px`
-        }"
-        class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
       >
-        <!-- 角标 -->
-        <div
-          v-if="property.badge.show"
-          class="absolute left-0 top-0 z-1 items-center justify-center"
-        >
-          <el-image :src="property.badge.imgUrl" class="h-26px w-38px" fit="cover" />
-        </div>
-        <!-- 商品封面图 -->
-        <el-image :src="spu.picUrl" :style="{ width: imageSize, height: imageSize }" fit="cover" />
+        <el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
+      </div>
+      <div
+        :class="[
+          ' flex flex-col gap-8px p-8px box-border',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <!-- 商品名称 -->
         <div
+          v-if="property.fields.name.show"
           :class="[
-            'flex flex-col gap-8px p-8px box-border',
+            'text-14px ',
             {
-              'w-[calc(100%-64px)]': columns === 2,
-              'w-full': columns === 3
+              truncate: property.layoutType !== 'oneColSmallImg',
+              'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
             }
           ]"
+          :style="{ color: property.fields.name.color }"
         >
-          <!-- 商品名称 -->
-          <div
-            v-if="property.fields.name.show"
-            :style="{ color: property.fields.name.color }"
-            class="truncate text-12px"
+          {{ spu.name }}
+        </div>
+        <!-- 商品简介 -->
+        <div
+          v-if="property.fields.introduction.show"
+          class="truncate text-12px"
+          :style="{ color: property.fields.introduction.color }"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 价格 -->
+          <span
+            v-if="property.fields.price.show"
+            class="text-16px"
+            :style="{ color: property.fields.price.color }"
+          >
+            ¥{{ fenToYuan(spu.price || Infinity) }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            class="ml-4px text-10px line-through"
+            :style="{ color: property.fields.marketPrice.color }"
+            >¥{{ fenToYuan(spu.marketPrice) }}</span
+          >
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
           >
-            {{ spu.name }}
-          </div>
-          <div>
-            <!-- 商品价格 -->
-            <span
-              v-if="property.fields.price.show"
-              :style="{ color: property.fields.price.color }"
-              class="text-12px"
-            >
-              ¥{{ fenToYuan(spu.seckillPrice || spu.price || 0) }}
-            </span>
-          </div>
+            已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.stock || 0 }}
+          </span>
         </div>
       </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+          :src="property.btnBuy.imgUrl"
+        />
+      </div>
     </div>
-  </el-scrollbar>
+  </div>
 </template>
-<script lang="ts" setup>
+<script setup lang="ts">
 import { PromotionSeckillProperty } from './config'
 import * as ProductSpuApi from '@/api/mall/product/spu'
-import { Spu } from '@/api/mall/product/spu'
 import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
-import { SeckillProductVO } from '@/api/mall/promotion/seckill/seckillActivity'
 import { fenToYuan } from '@/utils'
 
-/** 秒杀 */
+/** 秒杀卡片 */
 defineOptions({ name: 'PromotionSeckill' })
 // 定义属性
 const props = defineProps<{ property: PromotionSeckillProperty }>()
 // 商品列表
 const spuList = ref<ProductSpuApi.Spu[]>([])
+const spuIdList = ref<number[]>([])
+const seckillActivityList = ref<SeckillActivityApi.SeckillActivityVO[]>([])
+
 watch(
-  () => props.property.activityId,
+  () => props.property.activityIds,
   async () => {
-    if (!props.property.activityId) return
-    const activity = await SeckillActivityApi.getSeckillActivity(props.property.activityId)
-    if (!activity?.spuId) return
-    spuList.value = [await ProductSpuApi.getSpu(activity.spuId)]
-    spuList.value = [await ProductSpuApi.getSpu(activity.spuId)]
-    // 循环活动信息,赋值秒杀最低价格
-    activity.products.forEach((product: SeckillProductVO) => {
-      spuList.value.forEach((spu: Spu) => {
-        spu.seckillPrice = Math.min(spu.seckillPrice || Infinity, product.seckillPrice) // 设置 SPU 的最低价格
-      })
-    })
+    try {
+      // 新添加的秒杀组件,是没有活动ID的
+      const activityIds = props.property.activityIds
+      // 检查活动ID的有效性
+      if (Array.isArray(activityIds) && activityIds.length > 0) {
+        // 获取秒杀活动详情列表
+        seckillActivityList.value =
+          await SeckillActivityApi.getSeckillActivityListByIds(activityIds)
+
+        // 获取秒杀活动的 SPU 详情列表
+        spuList.value = []
+        spuIdList.value = seckillActivityList.value
+          .map((activity) => activity.spuId)
+          .filter((spuId): spuId is number => typeof spuId === 'number')
+        if (spuIdList.value.length > 0) {
+          spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value)
+        }
+
+        // 更新 SPU 的最低价格
+        seckillActivityList.value.forEach((activity) => {
+          // 匹配spuId
+          const spu = spuList.value.find((spu) => spu.id === activity.spuId)
+          if (spu) {
+            // 赋值活动价格,哪个最便宜就赋值哪个
+            spu.price = Math.min(activity.seckillPrice || Infinity, spu.price || Infinity)
+          }
+        })
+      }
+    } catch (error) {
+      console.error('获取秒杀活动细节或 SPU 细节时出错:', error)
+    }
   },
   {
     immediate: true,
     deep: true
   }
 )
-// 手机宽度
-const phoneWidth = ref(375)
+
+/**
+ * 计算商品的间距
+ * @param index 商品索引
+ */
+const calculateSpace = (index: number) => {
+  // 商品的列数
+  const columns = props.property.layoutType === 'twoCol' ? 2 : 1
+  // 第一列没有左边距
+  const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
+  // 第一行没有上边距
+  const marginTop = index < columns ? '0' : props.property.space + 'px'
+
+  return { marginLeft, marginTop }
+}
+
 // 容器
 const containerRef = ref()
-// 商品的列数
-const columns = ref(2)
-// 滚动条宽度
-const scrollbarWidth = ref('100%')
-// 商品图大小
-const imageSize = ref('0')
-// 商品网络列数
-const gridTemplateColumns = ref('')
-// 计算布局参数
-watch(
-  () => [props.property, phoneWidth, spuList.value.length],
-  () => {
-    // 计算列数
-    columns.value = props.property.layoutType === 'oneCol' ? 1 : 3
-    // 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数
-    const productWidth =
-      (phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value
-    // 商品图布局:2列时,左右布局 3列时,上下布局
-    imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px`
-    // 指定列数
-    gridTemplateColumns.value = `repeat(${columns.value}, auto)`
-    // 不滚动
-    scrollbarWidth.value = '100%'
-  },
-  { immediate: true, deep: true }
-)
-onMounted(() => {
-  // 提取手机宽度
-  phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375
-})
+// 计算商品的宽度
+const calculateWidth = () => {
+  let width = '100%'
+  // 双列时每列的宽度为:(总宽度 - 间距)/ 2
+  if (props.property.layoutType === 'twoCol') {
+    width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
+  }
+  return { width }
+}
 </script>
 
-<style lang="scss" scoped></style>
+<style scoped lang="scss"></style>

+ 68 - 16
src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue

@@ -2,30 +2,31 @@
   <ComponentContainerProperty v-model="formData.style">
     <el-form label-width="80px" :model="formData">
       <el-card header="秒杀活动" class="property-group" shadow="never">
-        <el-form-item label="秒杀活动" prop="activityId">
-          <el-select v-model="formData.activityId">
-            <el-option
-              v-for="activity in activityList"
-              :key="activity.id"
-              :label="activity.name"
-              :value="activity.id"
-            />
-          </el-select>
-        </el-form-item>
+        <SeckillShowcase v-model="formData.activityIds" />
       </el-card>
       <el-card header="商品样式" class="property-group" shadow="never">
         <el-form-item label="布局" prop="type">
           <el-radio-group v-model="formData.layoutType">
-            <el-tooltip class="item" content="单列" placement="bottom">
-              <el-radio-button value="oneCol">
+            <el-tooltip class="item" content="单列大图" placement="bottom">
+              <el-radio-button value="oneColBigImg">
                 <Icon icon="fluent:text-column-one-24-filled" />
               </el-radio-button>
             </el-tooltip>
-            <el-tooltip class="item" content="三列" placement="bottom">
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button value="oneColSmallImg">
+                <Icon icon="fluent:text-column-two-left-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="双列" placement="bottom">
+              <el-radio-button value="twoCol">
+                <Icon icon="fluent:text-column-two-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <!--<el-tooltip class="item" content="三列" placement="bottom">
               <el-radio-button value="threeCol">
                 <Icon icon="fluent:text-column-three-24-filled" />
               </el-radio-button>
-            </el-tooltip>
+            </el-tooltip>-->
           </el-radio-group>
         </el-form-item>
         <el-form-item label="商品名称" prop="fields.name.show">
@@ -34,12 +35,36 @@
             <el-checkbox v-model="formData.fields.name.show" />
           </div>
         </el-form-item>
+        <el-form-item label="商品简介" prop="fields.introduction.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.introduction.color" />
+            <el-checkbox v-model="formData.fields.introduction.show" />
+          </div>
+        </el-form-item>
         <el-form-item label="商品价格" prop="fields.price.show">
           <div class="flex gap-8px">
             <ColorInput v-model="formData.fields.price.color" />
             <el-checkbox v-model="formData.fields.price.show" />
           </div>
         </el-form-item>
+        <el-form-item label="市场价" prop="fields.marketPrice.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.marketPrice.color" />
+            <el-checkbox v-model="formData.fields.marketPrice.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品销量" prop="fields.salesCount.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.salesCount.color" />
+            <el-checkbox v-model="formData.fields.salesCount.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品库存" prop="fields.stock.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.stock.color" />
+            <el-checkbox v-model="formData.fields.stock.show" />
+          </div>
+        </el-form-item>
       </el-card>
       <el-card header="角标" class="property-group" shadow="never">
         <el-form-item label="角标" prop="badge.show">
@@ -47,10 +72,36 @@
         </el-form-item>
         <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
           <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
-            <template #tip> 建议尺寸:36 * 22 </template>
+            <template #tip> 建议尺寸:36 * 22</template>
           </UploadImg>
         </el-form-item>
       </el-card>
+      <el-card header="按钮" class="property-group" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button value="text">文字</el-radio-button>
+            <el-radio-button value="img">图片</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="formData.btnBuy.type === 'text'">
+          <el-form-item label="按钮文字" prop="btnBuy.text">
+            <el-input v-model="formData.btnBuy.text" />
+          </el-form-item>
+          <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
+            <ColorInput v-model="formData.btnBuy.bgBeginColor" />
+          </el-form-item>
+          <el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
+            <ColorInput v-model="formData.btnBuy.bgEndColor" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="图片" prop="btnBuy.imgUrl">
+            <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
+              <template #tip> 建议尺寸:56 * 56</template>
+            </UploadImg>
+          </el-form-item>
+        </template>
+      </el-card>
       <el-card header="商品样式" class="property-group" shadow="never">
         <el-form-item label="上圆角" prop="borderRadiusTop">
           <el-slider
@@ -92,6 +143,7 @@ import { PromotionSeckillProperty } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
 import { CommonStatusEnum } from '@/utils/constants'
+import SeckillShowcase from '@/views/mall/promotion/seckill/components/SeckillShowcase.vue'
 
 // 秒杀属性面板
 defineOptions({ name: 'PromotionSeckillProperty' })
@@ -100,7 +152,7 @@ const props = defineProps<{ modelValue: PromotionSeckillProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
 // 活动列表
-const activityList = ref<SeckillActivityApi.SeckillActivityVO>([])
+const activityList = ref<SeckillActivityApi.SeckillActivityVO[]>([])
 onMounted(async () => {
   const { list } = await SeckillActivityApi.getSeckillActivityPage({
     status: CommonStatusEnum.ENABLE

+ 4 - 1
src/components/DiyEditor/components/mobile/TabBar/property.vue

@@ -79,7 +79,7 @@
 </template>
 
 <script setup lang="ts">
-import { TabBarProperty, THEME_LIST } from './config'
+import { TabBarProperty, component, THEME_LIST } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 // 底部导航栏
 defineOptions({ name: 'TabBarProperty' })
@@ -88,6 +88,9 @@ const props = defineProps<{ modelValue: TabBarProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
 
+// 将数据库的值更新到右侧属性栏
+component.property.items = formData.value.items
+
 // 要的主题
 const handleThemeChange = () => {
   const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme)

+ 3 - 2
src/components/Editor/src/Editor.vue

@@ -7,6 +7,7 @@ import { isNumber } from '@/utils/is'
 import { ElMessage } from 'element-plus'
 import { useLocaleStore } from '@/store/modules/locale'
 import { getAccessToken, getTenantId } from '@/utils/auth'
+import { getUploadUrl } from '@/components/UploadFile/src/useUpload'
 
 defineOptions({ name: 'Editor' })
 
@@ -88,7 +89,7 @@ const editorConfig = computed((): IEditorConfig => {
       scroll: true,
       MENU_CONF: {
         ['uploadImage']: {
-          server: import.meta.env.VITE_UPLOAD_URL,
+          server: getUploadUrl(),
           // 单个文件的最大体积限制,默认为 2M
           maxFileSize: 5 * 1024 * 1024,
           // 最多可上传几个文件,默认为 100
@@ -136,7 +137,7 @@ const editorConfig = computed((): IEditorConfig => {
           }
         },
         ['uploadVideo']: {
-          server: import.meta.env.VITE_UPLOAD_URL,
+          server: getUploadUrl(),
           // 单个文件的最大体积限制,默认为 10M
           maxFileSize: 10 * 1024 * 1024,
           // 最多可上传几个文件,默认为 100

+ 0 - 1
src/components/FormCreate/src/components/useApiSelect.tsx

@@ -185,7 +185,6 @@ export const useApiSelect = (option: ApiSelectProps) => {
             </el-select>
           )
         }
-        debugger
         return (
           <el-select
             class="w-1/1"

+ 1 - 1
src/components/FormCreate/src/config/useDictSelectRule.ts

@@ -48,7 +48,7 @@ export const useDictSelectRule = () => {
         },
         {
           type: 'select',
-          field: 'dictValueType',
+          field: 'valueType',
           title: '字典值类型',
           value: 'str',
           options: [

+ 43 - 0
src/components/FormCreate/src/utils/index.ts

@@ -16,3 +16,46 @@ export const localeProps = (t, prefix, rules) => {
     return rule
   })
 }
+
+/**
+ * 解析表单组件的  field, title 等字段(递归,如果组件包含子组件)
+ * 
+ * @param rule  组件的生成规则 https://www.form-create.com/v3/guide/rule
+ * @param fields 解析后表单组件字段
+ * @param parentTitle  如果是子表单,子表单的标题,默认为空
+ */
+export const parseFormFields = (
+  rule: Record<string, any>,
+  fields: Array<Record<string, any>> = [],
+  parentTitle: string = ''
+) => {
+  const { type, field, $required, title: tempTitle, children } = rule
+  if (field && tempTitle) {
+    let title = tempTitle
+    if (parentTitle) {
+      title = `${parentTitle}.${tempTitle}`
+    }
+    let required = false
+    if ($required) {
+      required = true
+    }
+    fields.push({
+      field,
+      title,
+      type,
+      required
+    })
+    // TODO 子表单 需要处理子表单字段
+    // if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
+    //   // 解析子表单的字段
+    //   rule.props.rule.forEach((item) => {
+    //     parseFields(item, fieldsPermission, title)
+    //   })
+    // }
+  }
+  if (children && Array.isArray(children)) {
+    children.forEach((rule) => {
+      parseFormFields(rule, fields)
+    })
+  }
+}

+ 1 - 0
src/components/RouterSearch/index.vue

@@ -20,6 +20,7 @@
   <div v-else class="custom-hover" @click.stop="showTopSearch = !showTopSearch">
     <Icon icon="ep:search" />
     <el-select
+      @click.stop
       filterable
       :reserve-keyword="false"
       remote

+ 0 - 237
src/components/SimpleProcessDesigner/src/addNode.vue

@@ -1,237 +0,0 @@
-/* stylelint-disable order/properties-order */
-<template>
-  <div class="add-node-btn-box">
-    <div class="add-node-btn">
-      <el-popover placement="right-start" v-model="visible" width="auto">
-        <div class="add-node-popover-body">
-          <a class="add-node-popover-item approver" @click="addType(1)">
-            <div class="item-wrapper">
-              <span class="iconfont"></span>
-            </div>
-            <p>审批人</p>
-          </a>
-          <a class="add-node-popover-item notifier" @click="addType(2)">
-            <div class="item-wrapper">
-              <span class="iconfont"></span>
-            </div>
-            <p>抄送人</p>
-          </a>
-          <a class="add-node-popover-item condition" @click="addType(4)">
-            <div class="item-wrapper">
-              <span class="iconfont"></span>
-            </div>
-            <p>条件分支</p>
-          </a>
-        </div>
-        <template #reference>
-          <button class="btn" type="button">
-            <span class="iconfont"></span>
-          </button>
-        </template>
-      </el-popover>
-    </div>
-  </div>
-</template>
-<script setup>
-import { ref } from 'vue'
-let props = defineProps({
-  childNodeP: {
-    type: Object,
-    default: () => ({})
-  }
-})
-let emits = defineEmits(['update:childNodeP'])
-let visible = ref(false)
-const addType = (type) => {
-  visible.value = false
-  if (type != 4) {
-    var data
-    if (type == 1) {
-      data = {
-        nodeName: '审核人',
-        error: true,
-        type: 1,
-        settype: 1,
-        selectMode: 0,
-        selectRange: 0,
-        directorLevel: 1,
-        examineMode: 1,
-        noHanderAction: 1,
-        examineEndDirectorLevel: 0,
-        childNode: props.childNodeP,
-        nodeUserList: []
-      }
-    } else if (type == 2) {
-      data = {
-        nodeName: '抄送人',
-        type: 2,
-        ccSelfSelectFlag: 1,
-        childNode: props.childNodeP,
-        nodeUserList: []
-      }
-    }
-    emits('update:childNodeP', data)
-  } else {
-    emits('update:childNodeP', {
-      nodeName: '路由',
-      type: 4,
-      childNode: null,
-      conditionNodes: [
-        {
-          nodeName: '条件1',
-          error: true,
-          type: 3,
-          priorityLevel: 1,
-          conditionList: [],
-          nodeUserList: [],
-          childNode: props.childNodeP
-        },
-        {
-          nodeName: '条件2',
-          type: 3,
-          priorityLevel: 2,
-          conditionList: [],
-          nodeUserList: [],
-          childNode: null
-        }
-      ]
-    })
-  }
-}
-</script>
-<style scoped lang="scss">
-.add-node-btn-box {
-  width: 240px;
-  display: inline-flex;
-  -ms-flex-negative: 0;
-  flex-shrink: 0;
-  -webkit-box-flex: 1;
-  -ms-flex-positive: 1;
-  position: relative;
-
-  &:before {
-    content: '';
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    z-index: -1;
-    margin: auto;
-    width: 2px;
-    height: 100%;
-    background-color: #cacaca;
-  }
-
-  .add-node-btn {
-    user-select: none;
-    width: 240px;
-    padding: 20px 0 32px;
-    display: flex;
-    -webkit-box-pack: center;
-    justify-content: center;
-    flex-shrink: 0;
-    -webkit-box-flex: 1;
-    flex-grow: 1;
-
-    .btn {
-      outline: none;
-      box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
-      width: 30px;
-      height: 30px;
-      background: #3296fa;
-      border-radius: 50%;
-      position: relative;
-      border: none;
-      line-height: 30px;
-      -webkit-transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-      transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-
-      .iconfont {
-        color: #fff;
-        font-size: 16px;
-      }
-
-      &:hover {
-        transform: scale(1.3);
-        box-shadow: 0 13px 27px 0 rgba(0, 0, 0, 0.1);
-      }
-
-      &:active {
-        transform: none;
-        background: #1e83e9;
-        box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
-      }
-    }
-  }
-}
-
-.add-node-popover-body {
-  display: flex;
-
-  .add-node-popover-item {
-    margin-right: 10px;
-    cursor: pointer;
-    text-align: center;
-    flex: 1;
-    color: #191f25 !important;
-
-    .item-wrapper {
-      user-select: none;
-      display: inline-block;
-      width: 80px;
-      height: 80px;
-      margin-bottom: 5px;
-      background: #fff;
-      border: 1px solid #e2e2e2;
-      border-radius: 50%;
-      transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-
-      .iconfont {
-        font-size: 35px;
-        line-height: 80px;
-      }
-    }
-
-    &.approver {
-      .item-wrapper {
-        color: #ff943e;
-      }
-    }
-
-    &.notifier {
-      .item-wrapper {
-        color: #3296fa;
-      }
-    }
-
-    &.condition {
-      .item-wrapper {
-        color: #15bc83;
-      }
-    }
-
-    &:hover {
-      .item-wrapper {
-        background: #3296fa;
-        box-shadow: 0 10px 20px 0 rgba(50, 150, 250, 0.4);
-      }
-
-      .iconfont {
-        color: #fff;
-      }
-    }
-
-    &:active {
-      .item-wrapper {
-        box-shadow: none;
-        background: #eaeaea;
-      }
-
-      .iconfont {
-        color: inherit;
-      }
-    }
-  }
-}
-</style>

+ 0 - 297
src/components/SimpleProcessDesigner/src/nodeWrap.vue

@@ -1,297 +0,0 @@
-<!-- eslint-disable vue/no-mutating-props -->
-<!--
- * @Date: 2022-09-21 14:41:53
- * @LastEditors: StavinLi 495727881@qq.com
- * @LastEditTime: 2023-05-24 15:20:24
- * @FilePath: /Workflow-Vue3/src/components/nodeWrap.vue
--->
-<template>
-     <div class="node-wrap" v-if="nodeConfig.type < 3">
-      <div class="node-wrap-box" :class="(nodeConfig.type == 0 ? 'start-node ' : '') +(isTried && nodeConfig.error ? 'active error' : '')">
-          <div class="title" :style="`background: rgb(${bgColors[nodeConfig.type]});`">
-            <span v-if="nodeConfig.type == 0">{{ nodeConfig.nodeName }}</span>
-            <template v-else>
-              <span class="iconfont">{{nodeConfig.type == 1?'':''}}</span>
-              <input
-                v-if="isInput"
-                type="text"
-                class="ant-input editable-title-input"
-                @blur="blurEvent()"
-                @focus="$event.currentTarget.select()"
-                v-focus
-                v-model="nodeConfig.nodeName"
-                :placeholder="defaultText"
-              />
-              <span v-else class="editable-title" @click="clickEvent()">{{ nodeConfig.nodeName }}</span>
-              <i class="anticon anticon-close close" @click="delNode"></i>
-            </template>
-          </div>
-          <div class="content" @click="setPerson">
-            <div class="text">
-                <span class="placeholder" v-if="!showText">请选择{{defaultText}}</span>
-                {{showText}}
-            </div>
-            <i class="anticon anticon-right arrow"></i>
-          </div>
-          <div class="error_tip" v-if="isTried && nodeConfig.error">
-            <i class="anticon anticon-exclamation-circle"></i>
-          </div>
-      </div>
-      <addNode v-model:childNodeP="nodeConfig.childNode" />
-    </div>
-    <div class="branch-wrap" v-if="nodeConfig.type == 4">
-    <div class="branch-box-wrap">
-      <div class="branch-box">
-        <button class="add-branch" @click="addTerm">添加条件</button>
-        <div class="col-box" v-for="(item, index) in nodeConfig.conditionNodes" :key="index">
-          <div class="condition-node">
-            <div class="condition-node-box">
-              <div class="auto-judge" :class="isTried && item.error ? 'error active' : ''">
-                <div class="sort-left" v-if="index != 0" @click="arrTransfer(index, -1)">&lt;</div>
-                <div class="title-wrapper">
-                  <input
-                    v-if="isInputList[index]"
-                    type="text"
-                    class="ant-input editable-title-input"
-                    @blur="blurEvent(index)"
-                    @focus="$event.currentTarget.select()"
-                    v-model="item.nodeName"
-                  />
-                  <span v-else class="editable-title" @click="clickEvent(index)">{{ item.nodeName }}</span>
-                  <span class="priority-title" @click="setPerson(item.priorityLevel)">优先级{{ item.priorityLevel }}</span>
-                  <i class="anticon anticon-close close" @click="delTerm(index)"></i>
-                </div>
-                <div class="sort-right" v-if="index != nodeConfig.conditionNodes.length - 1" @click="arrTransfer(index)">&gt;</div>
-                <div class="content" @click="setPerson(item.priorityLevel)">{{ conditionStr(nodeConfig, index) }}</div>
-                <div class="error_tip" v-if="isTried && item.error">
-                    <i class="anticon anticon-exclamation-circle"></i>
-                </div>
-              </div>
-              <addNode v-model:childNodeP="item.childNode" />
-            </div>
-          </div>
-          <nodeWrap v-if="item.childNode" v-model:nodeConfig="item.childNode" />
-          <template v-if="index == 0">
-            <div class="top-left-cover-line"></div>
-            <div class="bottom-left-cover-line"></div>
-          </template>
-          <template v-if="index == nodeConfig.conditionNodes.length - 1">
-            <div class="top-right-cover-line"></div>
-            <div class="bottom-right-cover-line"></div>
-          </template>
-        </div>
-      </div>
-      <addNode v-model:childNodeP="nodeConfig.childNode" />
-    </div>
-  </div>
-    <nodeWrap v-if="nodeConfig.childNode" v-model:nodeConfig="nodeConfig.childNode" />
-</template>
-<script  setup>
-import addNode from './addNode.vue'
-import { onMounted, ref, watch, getCurrentInstance, computed } from 'vue'
-import {
-  arrToStr,
-  conditionStr,
-  setApproverStr,
-  copyerStr,
-  bgColors,
-  placeholderList
-} from './util'
-import { useWorkFlowStoreWithOut } from '@/store/modules/simpleWorkflow'
-let _uid = getCurrentInstance().uid
-
-let props = defineProps({
-  nodeConfig: {
-    type: Object,
-    default: () => ({})
-  },
-  flowPermission: {
-    type: Object,
-    // eslint-disable-next-line vue/require-valid-default-prop
-    default: () => []
-  }
-})
-
-let defaultText = computed(() => {
-  return placeholderList[props.nodeConfig.type]
-})
-let showText = computed(() => {
-  if (props.nodeConfig.type == 0) return arrToStr(props.flowPermission) || '所有人'
-  if (props.nodeConfig.type == 1) return setApproverStr(props.nodeConfig)
-  return copyerStr(props.nodeConfig)
-})
-
-let isInputList = ref([])
-let isInput = ref(false)
-const resetConditionNodesErr = () => {
-  for (var i = 0; i < props.nodeConfig.conditionNodes.length; i++) {
-    // eslint-disable-next-line vue/no-mutating-props
-    props.nodeConfig.conditionNodes[i].error =
-      conditionStr(props.nodeConfig, i) == '请设置条件' &&
-      i != props.nodeConfig.conditionNodes.length - 1
-  }
-}
-onMounted(() => {
-  if (props.nodeConfig.type == 1) {
-    // eslint-disable-next-line vue/no-mutating-props
-    props.nodeConfig.error = !setApproverStr(props.nodeConfig)
-  } else if (props.nodeConfig.type == 2) {
-    // eslint-disable-next-line vue/no-mutating-props
-    props.nodeConfig.error = !copyerStr(props.nodeConfig)
-  } else if (props.nodeConfig.type == 4) {
-    resetConditionNodesErr()
-  }
-})
-let emits = defineEmits(['update:flowPermission', 'update:nodeConfig'])
-let store = useWorkFlowStoreWithOut()
-let {
-  setPromoter,
-  setApprover,
-  setCopyer,
-  setCondition,
-  setFlowPermission,
-  setApproverConfig,
-  setCopyerConfig,
-  setConditionsConfig
-} = store
-let isTried = computed(() => store.isTried)
-let flowPermission1 = computed(() => store.flowPermission1)
-let approverConfig1 = computed(() => store.approverConfig1)
-let copyerConfig1 = computed(() => store.copyerConfig1)
-let conditionsConfig1 = computed(() => store.conditionsConfig1)
-watch(flowPermission1, (flow) => {
-  if (flow.flag && flow.id === _uid) {
-    emits('update:flowPermission', flow.value)
-  }
-})
-watch(approverConfig1, (approver) => {
-  if (approver.flag && approver.id === _uid) {
-    emits('update:nodeConfig', approver.value)
-  }
-})
-watch(copyerConfig1, (copyer) => {
-  if (copyer.flag && copyer.id === _uid) {
-    emits('update:nodeConfig', copyer.value)
-  }
-})
-watch(conditionsConfig1, (condition) => {
-  if (condition.flag && condition.id === _uid) {
-    emits('update:nodeConfig', condition.value)
-  }
-})
-
-const clickEvent = (index) => {
-  if (index || index === 0) {
-    isInputList.value[index] = true
-  } else {
-    isInput.value = true
-  }
-}
-const blurEvent = (index) => {
-  if (index || index === 0) {
-    isInputList.value[index] = false
-    // eslint-disable-next-line vue/no-mutating-props
-    props.nodeConfig.conditionNodes[index].nodeName =
-      props.nodeConfig.conditionNodes[index].nodeName || '条件'
-  } else {
-    isInput.value = false
-    // eslint-disable-next-line vue/no-mutating-props
-    props.nodeConfig.nodeName = props.nodeConfig.nodeName || defaultText
-  }
-}
-const delNode = () => {
-  emits('update:nodeConfig', props.nodeConfig.childNode)
-}
-const addTerm = () => {
-  let len = props.nodeConfig.conditionNodes.length + 1
-  // eslint-disable-next-line vue/no-mutating-props
-  props.nodeConfig.conditionNodes.push({
-    nodeName: '条件' + len,
-    type: 3,
-    priorityLevel: len,
-    conditionList: [],
-    nodeUserList: [],
-    childNode: null
-  })
-  resetConditionNodesErr()
-  emits('update:nodeConfig', props.nodeConfig)
-}
-const delTerm = (index) => {
-  // eslint-disable-next-line vue/no-mutating-props
-  props.nodeConfig.conditionNodes.splice(index, 1)
-  props.nodeConfig.conditionNodes.map((item, index) => {
-    item.priorityLevel = index + 1
-    item.nodeName = `条件${index + 1}`
-  })
-  resetConditionNodesErr()
-  emits('update:nodeConfig', props.nodeConfig)
-  if (props.nodeConfig.conditionNodes.length == 1) {
-    if (props.nodeConfig.childNode) {
-      if (props.nodeConfig.conditionNodes[0].childNode) {
-        reData(props.nodeConfig.conditionNodes[0].childNode, props.nodeConfig.childNode)
-      } else {
-        // eslint-disable-next-line vue/no-mutating-props
-        props.nodeConfig.conditionNodes[0].childNode = props.nodeConfig.childNode
-      }
-    }
-    emits('update:nodeConfig', props.nodeConfig.conditionNodes[0].childNode)
-  }
-}
-const reData = (data, addData) => {
-  if (!data.childNode) {
-    data.childNode = addData
-  } else {
-    reData(data.childNode, addData)
-  }
-}
-const setPerson = (priorityLevel) => {
-  var { type } = props.nodeConfig
-  if (type == 0) {
-    setPromoter(true)
-    setFlowPermission({
-      value: props.flowPermission,
-      flag: false,
-      id: _uid
-    })
-  } else if (type == 1) {
-    setApprover(true)
-    setApproverConfig({
-      value: {
-        ...JSON.parse(JSON.stringify(props.nodeConfig)),
-        ...{ settype: props.nodeConfig.settype ? props.nodeConfig.settype : 1 }
-      },
-      flag: false,
-      id: _uid
-    })
-  } else if (type == 2) {
-    setCopyer(true)
-    setCopyerConfig({
-      value: JSON.parse(JSON.stringify(props.nodeConfig)),
-      flag: false,
-      id: _uid
-    })
-  } else {
-    setCondition(true)
-    setConditionsConfig({
-      value: JSON.parse(JSON.stringify(props.nodeConfig)),
-      priorityLevel,
-      flag: false,
-      id: _uid
-    })
-  }
-}
-const arrTransfer = (index, type = 1) => {
-  //向左-1,向右1
-  // eslint-disable-next-line vue/no-mutating-props
-  props.nodeConfig.conditionNodes[index] = props.nodeConfig.conditionNodes.splice(
-    index + type,
-    1,
-    props.nodeConfig.conditionNodes[index]
-  )[0]
-  props.nodeConfig.conditionNodes.map((item, index) => {
-    item.priorityLevel = index + 1
-  })
-  resetConditionNodesErr()
-  emits('update:nodeConfig', props.nodeConfig)
-}
-</script>

+ 0 - 165
src/components/SimpleProcessDesigner/src/util.ts

@@ -1,165 +0,0 @@
-/**
- * todo
- */
-export const arrToStr = (arr?: [{ name: string }]) => {
-  if (arr) {
-    return arr
-      .map((item) => {
-        return item.name
-      })
-      .toString()
-  }
-}
-
-export const setApproverStr = (nodeConfig: any) => {
-  if (nodeConfig.settype == 1) {
-    if (nodeConfig.nodeUserList.length == 1) {
-      return nodeConfig.nodeUserList[0].name
-    } else if (nodeConfig.nodeUserList.length > 1) {
-      if (nodeConfig.examineMode == 1) {
-        return arrToStr(nodeConfig.nodeUserList)
-      } else if (nodeConfig.examineMode == 2) {
-        return nodeConfig.nodeUserList.length + '人会签'
-      }
-    }
-  } else if (nodeConfig.settype == 2) {
-    const level =
-      nodeConfig.directorLevel == 1 ? '直接主管' : '第' + nodeConfig.directorLevel + '级主管'
-    if (nodeConfig.examineMode == 1) {
-      return level
-    } else if (nodeConfig.examineMode == 2) {
-      return level + '会签'
-    }
-  } else if (nodeConfig.settype == 4) {
-    if (nodeConfig.selectRange == 1) {
-      return '发起人自选'
-    } else {
-      if (nodeConfig.nodeUserList.length > 0) {
-        if (nodeConfig.selectRange == 2) {
-          return '发起人自选'
-        } else {
-          return '发起人从' + nodeConfig.nodeUserList[0].name + '中自选'
-        }
-      } else {
-        return ''
-      }
-    }
-  } else if (nodeConfig.settype == 5) {
-    return '发起人自己'
-  } else if (nodeConfig.settype == 7) {
-    return '从直接主管到通讯录中级别最高的第' + nodeConfig.examineEndDirectorLevel + '个层级主管'
-  }
-}
-
-export const copyerStr = (nodeConfig: any) => {
-  if (nodeConfig.nodeUserList.length != 0) {
-    return arrToStr(nodeConfig.nodeUserList)
-  } else {
-    if (nodeConfig.ccSelfSelectFlag == 1) {
-      return '发起人自选'
-    }
-  }
-}
-export const conditionStr = (nodeConfig, index) => {
-  const { conditionList, nodeUserList } = nodeConfig.conditionNodes[index]
-  if (conditionList.length == 0) {
-    return index == nodeConfig.conditionNodes.length - 1 &&
-      nodeConfig.conditionNodes[0].conditionList.length != 0
-      ? '其他条件进入此流程'
-      : '请设置条件'
-  } else {
-    let str = ''
-    for (let i = 0; i < conditionList.length; i++) {
-      const {
-        columnId,
-        columnType,
-        showType,
-        showName,
-        optType,
-        zdy1,
-        opt1,
-        zdy2,
-        opt2,
-        fixedDownBoxValue
-      } = conditionList[i]
-      if (columnId == 0) {
-        if (nodeUserList.length != 0) {
-          str += '发起人属于:'
-          str +=
-            nodeUserList
-              .map((item) => {
-                return item.name
-              })
-              .join('或') + ' 并且 '
-        }
-      }
-      if (columnType == 'String' && showType == '3') {
-        if (zdy1) {
-          str += showName + '属于:' + dealStr(zdy1, JSON.parse(fixedDownBoxValue)) + ' 并且 '
-        }
-      }
-      if (columnType == 'Double') {
-        if (optType != 6 && zdy1) {
-          const optTypeStr = ['', '<', '>', '≤', '=', '≥'][optType]
-          str += `${showName} ${optTypeStr} ${zdy1} 并且 `
-        } else if (optType == 6 && zdy1 && zdy2) {
-          str += `${zdy1} ${opt1} ${showName} ${opt2} ${zdy2} 并且 `
-        }
-      }
-    }
-    return str ? str.substring(0, str.length - 4) : '请设置条件'
-  }
-}
-
-export const dealStr = (str: string, obj) => {
-  const arr = []
-  const list = str.split(',')
-  for (const elem in obj) {
-    list.map((item) => {
-      if (item == elem) {
-        arr.push(obj[elem].value)
-      }
-    })
-  }
-  return arr.join('或')
-}
-
-export const removeEle = (arr, elem, key = 'id') => {
-  let includesIndex
-  arr.map((item, index) => {
-    if (item[key] == elem[key]) {
-      includesIndex = index
-    }
-  })
-  arr.splice(includesIndex, 1)
-}
-
-export const bgColors = ['87, 106, 149', '255, 148, 62', '50, 150, 250']
-export const placeholderList = ['发起人', '审核人', '抄送人']
-export const setTypes = [
-  { value: 1, label: '指定成员' },
-  { value: 2, label: '主管' },
-  { value: 4, label: '发起人自选' },
-  { value: 5, label: '发起人自己' },
-  { value: 7, label: '连续多级主管' }
-]
-
-export const selectModes = [
-  { value: 1, label: '选一个人' },
-  { value: 2, label: '选多个人' }
-]
-
-export const selectRanges = [
-  { value: 1, label: '全公司' },
-  { value: 2, label: '指定成员' },
-  { value: 3, label: '指定角色' }
-]
-
-export const optTypes = [
-  { value: '1', label: '小于' },
-  { value: '2', label: '大于' },
-  { value: '3', label: '小于等于' },
-  { value: '4', label: '等于' },
-  { value: '5', label: '大于等于' },
-  { value: '6', label: '介于两个数之间' }
-]

+ 0 - 1292
src/components/SimpleProcessDesigner/theme/workflow.css

@@ -1,1292 +0,0 @@
-
-.clearfix {
-    zoom: 1
-}
-
-.clearfix:after,
-.clearfix:before {
-    content: "";
-    display: table
-}
-
-.clearfix:after {
-    clear: both
-}
-
-@font-face {
-    font-family: anticon;
-    font-display: fallback;
-    src: url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.eot");
-    src: url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.woff") format("woff"), url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.ttf") format("truetype"), url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.svg#iconfont") format("svg")
-}
-
-.anticon {
-    display: inline-block;
-    font-style: normal;
-    vertical-align: baseline;
-    text-align: center;
-    text-transform: none;
-    line-height: 1;
-    text-rendering: optimizeLegibility;
-    -webkit-font-smoothing: antialiased;
-    -moz-osx-font-smoothing: grayscale
-}
-
-.anticon:before {
-    display: block;
-    font-family: anticon!important
-}
-.anticon-close:before {
-  content: "\E633"
-}
-.anticon-right:before {
-    content: "\E61F"
-}
-.anticon-exclamation-circle{
-    color: rgb(242, 86, 67)
-}
-.anticon-exclamation-circle:before {
-    content: "\E62C"
-}
-
-.anticon-left:before {
-    content: "\E620"
-}
-
-.anticon-close-circle:before {
-    content: "\E62E"
-}
-  
-.ant-btn {
-    line-height: 1.5;
-    display: inline-block;
-    font-weight: 400;
-    text-align: center;
-    touch-action: manipulation;
-    cursor: pointer;
-    background-image: none;
-    border: 1px solid transparent;
-    white-space: nowrap;
-    padding: 0 15px;
-    font-size: 14px;
-    border-radius: 4px;
-    height: 32px;
-    user-select: none;
-    transition: all .3s cubic-bezier(.645, .045, .355, 1);
-    position: relative;
-    color: rgba(0, 0, 0, .65);
-    background-color: #fff;
-    border-color: #d9d9d9
-}
-
-.ant-btn>.anticon {
-    line-height: 1
-}
-
-.ant-btn,
-.ant-btn:active,
-.ant-btn:focus {
-    outline: 0
-}
-
-.ant-btn>a:only-child {
-    color: currentColor
-}
-
-.ant-btn>a:only-child:after {
-    content: "";
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    background: transparent
-}
-
-.ant-btn:focus,
-.ant-btn:hover {
-    color: #40a9ff;
-    background-color: #fff;
-    border-color: #40a9ff
-}
-
-.ant-btn:focus>a:only-child,
-.ant-btn:hover>a:only-child {
-    color: currentColor
-}
-
-.ant-btn:focus>a:only-child:after,
-.ant-btn:hover>a:only-child:after {
-    content: "";
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    background: transparent
-}
-
-.ant-btn.active,
-.ant-btn:active {
-    color: #096dd9;
-    background-color: #fff;
-    border-color: #096dd9
-}
-
-.ant-btn.active>a:only-child,
-.ant-btn:active>a:only-child {
-    color: currentColor
-}
-
-.ant-btn.active>a:only-child:after,
-.ant-btn:active>a:only-child:after {
-    content: "";
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    background: transparent
-}
-
-.ant-btn.active,
-.ant-btn:active,
-.ant-btn:focus,
-.ant-btn:hover {
-    background: #fff;
-    text-decoration: none
-}
-
-.ant-btn>i,
-.ant-btn>span {
-    pointer-events: none
-}
-
-.ant-btn:before {
-    position: absolute;
-    top: -1px;
-    left: -1px;
-    bottom: -1px;
-    right: -1px;
-    background: #fff;
-    opacity: .35;
-    content: "";
-    border-radius: inherit;
-    z-index: 1;
-    transition: opacity .2s;
-    pointer-events: none;
-    display: none
-}
-
-.ant-btn .anticon {
-    transition: margin-left .3s cubic-bezier(.645, .045, .355, 1)
-}
-
-.ant-btn:active>span,
-.ant-btn:focus>span {
-    position: relative
-}
-
-.ant-btn>.anticon+span,
-.ant-btn>span+.anticon {
-    margin-left: 8px
-}
-
-.ant-input {
-    font-family: Chinese Quote, -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif;
-    font-variant: tabular-nums;
-    box-sizing: border-box;
-    margin: 0;
-    padding: 0;
-    list-style: none;
-    position: relative;
-    display: inline-block;
-    padding: 4px 11px;
-    width: 100%;
-    height: 32px;
-    font-size: 14px;
-    line-height: 1.5;
-    color: rgba(0, 0, 0, .65);
-    background-color: #fff;
-    background-image: none;
-    border: 1px solid #d9d9d9;
-    border-radius: 4px;
-    transition: all .3s
-}
-
-.ant-input::-moz-placeholder {
-    color: #bfbfbf;
-    opacity: 1
-}
-
-.ant-input:-ms-input-placeholder {
-    color: #bfbfbf
-}
-
-.ant-input::-webkit-input-placeholder {
-    color: #bfbfbf
-}
-
-.ant-input:focus,
-.ant-input:hover {
-    border-color: #40a9ff;
-    border-right-width: 1px!important
-}
-
-.ant-input:focus {
-    outline: 0;
-    box-shadow: 0 0 0 2px rgba(24, 144, 255, .2)
-}
-
-textarea.ant-input {
-    max-width: 100%;
-    height: auto;
-    vertical-align: bottom;
-    transition: all .3s, height 0s;
-    min-height: 32px
-}
-
-a,
-abbr,
-acronym,
-address,
-applet,
-article,
-aside,
-audio,
-b,
-big,
-blockquote,
-body,
-canvas,
-caption,
-center,
-cite,
-code,
-dd,
-del,
-details,
-dfn,
-div,
-dl,
-dt,
-em,
-fieldset,
-figcaption,
-figure,
-footer,
-form,
-h1,
-h2,
-h3,
-h4,
-h5,
-h6,
-header,
-hgroup,
-html,
-i,
-iframe,
-img,
-ins,
-kbd,
-label,
-legend,
-li,
-mark,
-menu,
-nav,
-object,
-ol,
-p,
-pre,
-q,
-s,
-samp,
-section,
-small,
-span,
-strike,
-strong,
-sub,
-summary,
-sup,
-table,
-tbody,
-td,
-tfoot,
-th,
-thead,
-time,
-tr,
-tt,
-u,
-ul,
-var,
-video {
-    margin: 0;
-    padding: 0;
-    border: 0;
-    outline: 0;
-    font-size: 100%;
-    font: inherit;
-    vertical-align: baseline
-}
-
-*,
-:after,
-:before {
-    -webkit-box-sizing: border-box;
-    -moz-box-sizing: border-box;
-    box-sizing: border-box
-}
-
-html {
-    font-family: sans-serif;
-    -ms-text-size-adjust: 100%;
-    -webkit-text-size-adjust: 100%
-}
-
-body,
-html {
-    font-size: 14px
-}
-
-body {
-    font-family: Microsoft Yahei, Lucida Grande, Lucida Sans Unicode, Helvetica, Arial, Verdana, sans-serif;
-    line-height: 1.6;
-    background-color: #fff;
-    position: static!important;
-    -webkit-tap-highlight-color: rgba(0, 0, 0, 0)
-}
-
-ol,
-ul {
-    list-style-type: none
-}
-
-b,
-strong {
-    font-weight: 700
-}
-
-img {
-    border: 0
-}
-
-button,
-input,
-select,
-textarea {
-    font-family: inherit;
-    font-size: 100%;
-    margin: 0
-}
-
-textarea {
-    overflow: auto;
-    vertical-align: top;
-    -webkit-appearance: none
-}
-
-button,
-input {
-    line-height: normal
-}
-
-button,
-select {
-    text-transform: none
-}
-
-button,
-html input[type=button],
-input[type=reset],
-input[type=submit] {
-    -webkit-appearance: button;
-    cursor: pointer
-}
-
-input[type=search] {
-    -webkit-appearance: textfield;
-    -moz-box-sizing: content-box;
-    -webkit-box-sizing: content-box;
-    box-sizing: content-box
-}
-
-input[type=search]::-webkit-search-cancel-button,
-input[type=search]::-webkit-search-decoration {
-    -webkit-appearance: none
-}
-
-button::-moz-focus-inner,
-input::-moz-focus-inner {
-    border: 0;
-    padding: 0
-}
-
-table {
-    width: 100%;
-    border-spacing: 0;
-    border-collapse: collapse
-}
-
-table,
-td,
-th {
-    border: 0
-}
-
-td,
-th {
-    padding: 0;
-    vertical-align: top
-}
-
-th {
-    font-weight: 700;
-    text-align: left
-}
-
-thead th {
-    white-space: nowrap
-}
-
-a {
-    text-decoration: none;
-    cursor: pointer;
-    color: #3296fa
-}
-
-a:active,
-a:hover {
-    outline: 0;
-    color: #3296fa
-}
-
-small {
-    font-size: 80%
-}
-
-body,
-html {
-    font-size: 12px!important;
-    color: #191f25!important;
-    background: #f6f6f6!important
-}
-
-.wrap {
-    display: -webkit-box;
-    display: -ms-flexbox;
-    display: flex;
-    -webkit-box-orient: vertical;
-    -webkit-box-direction: normal;
-    -ms-flex-direction: column;
-    flex-direction: column;
-    height: 100%
-}
-
-@font-face {
-    font-family: IconFont;
-    src: url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.eot");
-    src: url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.woff") format("woff"), url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.ttf") format("truetype"), url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.svg#IconFont") format("svg")
-}
-
-.iconfont {
-    font-family: IconFont!important;
-    font-size: 16px;
-    font-style: normal;
-    -webkit-font-smoothing: antialiased;
-    -webkit-text-stroke-width: .2px;
-    -moz-osx-font-smoothing: grayscale
-}
-
-.fd-nav {
-    position: fixed;
-    top: 0;
-    left: 0;
-    right: 0;
-    z-index: 997;
-    width: 100%;
-    height: 60px;
-    font-size: 14px;
-    color: #fff;
-    background: #3296fa;
-    display: flex;
-    align-items: center
-}
-
-.fd-nav>* {
-    flex: 1;
-    width: 100%
-}
-
-.fd-nav .fd-nav-left {
-    display: -webkit-box;
-    display: flex;
-    align-items: center
-}
-
-.fd-nav .fd-nav-center {
-    flex: none;
-    width: 600px;
-    text-align: center
-}
-
-.fd-nav .fd-nav-right {
-    display: flex;
-    align-items: center;
-    justify-content: flex-end;
-    text-align: right
-}
-
-.fd-nav .fd-nav-back {
-    display: inline-block;
-    width: 60px;
-    height: 60px;
-    font-size: 22px;
-    border-right: 1px solid #1583f2;
-    text-align: center;
-    cursor: pointer
-}
-
-.fd-nav .fd-nav-back:hover {
-    background: #5af
-}
-
-.fd-nav .fd-nav-back:active {
-    background: #1583f2
-}
-
-.fd-nav .fd-nav-back .anticon {
-    line-height: 60px
-}
-
-.fd-nav .fd-nav-title {
-    width: 0;
-    flex: 1;
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    padding: 0 15px
-}
-
-.fd-nav a {
-    color: #fff;
-    margin-left: 12px
-}
-
-.fd-nav .button-publish {
-    min-width: 80px;
-    margin-left: 4px;
-    margin-right: 15px;
-    color: #3296fa;
-    border-color: #fff
-}
-
-.fd-nav .button-publish.ant-btn:focus,
-.fd-nav .button-publish.ant-btn:hover {
-    color: #3296fa;
-    border-color: #fff;
-    box-shadow: 0 10px 20px 0 rgba(0, 0, 0, .3)
-}
-
-.fd-nav .button-publish.ant-btn:active {
-    color: #3296fa;
-    background: #d6eaff;
-    box-shadow: none
-}
-
-.fd-nav .button-preview {
-    min-width: 80px;
-    margin-left: 16px;
-    margin-right: 4px;
-    color: #fff;
-    border-color: #fff;
-    background: transparent
-}
-
-.fd-nav .button-preview.ant-btn:focus,
-.fd-nav .button-preview.ant-btn:hover {
-    color: #fff;
-    border-color: #fff;
-    background: #59acfc
-}
-
-.fd-nav .button-preview.ant-btn:active {
-    color: #fff;
-    border-color: #fff;
-    background: #2186ef
-}
-
-.fd-nav-content {
-    position: fixed;
-    top: 60px;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    z-index: 1;
-    overflow-x: hidden;
-    overflow-y: auto;
-    padding-bottom: 30px
-}
-
-.error-modal-desc {
-    font-size: 13px;
-    color: rgba(25, 31, 37, .56);
-    line-height: 22px;
-    margin-bottom: 14px
-}
-
-.error-modal-list {
-    height: 200px;
-    overflow-y: auto;
-    margin-right: -25px;
-    padding-right: 25px
-}
-
-.error-modal-item {
-    padding: 10px 20px;
-    line-height: 21px;
-    background: #f6f6f6;
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    margin-bottom: 8px;
-    border-radius: 4px
-}
-
-.error-modal-item-label {
-    flex: none;
-    font-size: 15px;
-    color: rgba(25, 31, 37, .56);
-    padding-right: 10px
-}
-
-.error-modal-item-content {
-    text-align: right;
-    flex: 1;
-    font-size: 13px;
-    color: #191f25
-}
-
-#body.blur {
-    -webkit-filter: blur(3px);
-    filter: blur(3px)
-}
-
-.zoom {
-    display: flex;
-    position: fixed;
-    -webkit-box-align: center;
-    -ms-flex-align: center;
-    align-items: center;
-    -webkit-box-pack: justify;
-    -ms-flex-pack: justify;
-    justify-content: space-between;
-    height: 40px;
-    width: 125px;
-    right: 40px;
-    margin-top: 30px;
-    z-index: 10
-}
-
-.zoom .zoom-in,
-.zoom .zoom-out {
-    width: 30px;
-    height: 30px;
-    background: #fff;
-    color: #c1c1cd;
-    cursor: pointer;
-    background-size: 100%;
-    background-repeat: no-repeat
-}
-
-.zoom .zoom-out {
-    background-image: url(https://gw.alicdn.com/tfs/TB1s0qhBHGYBuNjy0FoXXciBFXa-90-90.png)
-}
-
-.zoom .zoom-out.disabled {
-    opacity: .5
-}
-
-.zoom .zoom-in {
-    background-image: url(https://gw.alicdn.com/tfs/TB1UIgJBTtYBeNjy1XdXXXXyVXa-90-90.png)
-}
-
-.zoom .zoom-in.disabled {
-    opacity: .5
-}
-
-.auto-judge:hover .editable-title,
-.node-wrap-box:hover .editable-title {
-    border-bottom: 1px dashed #fff
-}
-
-.auto-judge:hover .editable-title.editing,
-.node-wrap-box:hover .editable-title.editing {
-    text-decoration: none;
-    border: 1px solid #d9d9d9
-}
-
-.auto-judge:hover .editable-title {
-    border-color: #15bc83
-}
-
-.editable-title {
-    line-height: 15px;
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    border-bottom: 1px dashed transparent
-}
-
-.editable-title:before {
-    content: "";
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 40px
-}
-
-.editable-title:hover {
-    border-bottom: 1px dashed #fff
-}
-
-.editable-title-input {
-    flex: none;
-    height: 18px;
-    padding-left: 4px;
-    text-indent: 0;
-    font-size: 12px;
-    line-height: 18px;
-    z-index: 1
-}
-
-.editable-title-input:hover {
-    text-decoration: none
-}
-
-.ant-btn {
-    position: relative
-}
-
-.node-wrap-box {
-    display: -webkit-inline-box;
-    display: -ms-inline-flexbox;
-    display: inline-flex;
-    -webkit-box-orient: vertical;
-    -webkit-box-direction: normal;
-    -ms-flex-direction: column;
-    flex-direction: column;
-    position: relative;
-    width: 220px;
-    min-height: 72px;
-    -ms-flex-negative: 0;
-    flex-shrink: 0;
-    background: #fff;
-    border-radius: 4px;
-    cursor: pointer
-}
-
-.node-wrap-box:after {
-    pointer-events: none;
-    content: "";
-    position: absolute;
-    top: 0;
-    bottom: 0;
-    left: 0;
-    right: 0;
-    z-index: 2;
-    border-radius: 4px;
-    border: 1px solid transparent;
-    transition: all .1s cubic-bezier(.645, .045, .355, 1);
-    box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1)
-}
-
-.node-wrap-box.active:after,
-.node-wrap-box:active:after,
-.node-wrap-box:hover:after {
-    border: 1px solid #3296fa;
-    box-shadow: 0 0 6px 0 rgba(50, 150, 250, .3)
-}
-
-.node-wrap-box.active .close,
-.node-wrap-box:active .close,
-.node-wrap-box:hover .close {
-    display: block
-}
-
-.node-wrap-box.error:after {
-    border: 1px solid #f25643;
-    box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1)
-}
-
-.node-wrap-box .title {
-    position: relative;
-    display: flex;
-    align-items: center;
-    padding-left: 16px;
-    padding-right: 30px;
-    width: 100%;
-    height: 24px;
-    line-height: 24px;
-    font-size: 12px;
-    color: #fff;
-    text-align: left;
-    background: #576a95;
-    border-radius: 4px 4px 0 0
-}
-
-.node-wrap-box .title .iconfont {
-    font-size: 12px;
-    margin-right: 5px
-}
-
-.node-wrap-box .placeholder {
-    color: #bfbfbf
-}
-
-.node-wrap-box .close {
-    display: none;
-    position: absolute;
-    right: 10px;
-    top: 50%;
-    transform: translateY(-50%);
-    width: 20px;
-    height: 20px;
-    font-size: 14px;
-    color: #fff;
-    border-radius: 50%;
-    text-align: center;
-    line-height: 20px
-}
-
-.node-wrap-box .content {
-    position: relative;
-    font-size: 14px;
-    padding: 16px;
-    padding-right: 30px
-}
-
-.node-wrap-box .content .text {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    display: -webkit-box;
-    -webkit-line-clamp: 3;
-    -webkit-box-orient: vertical
-}
-
-.node-wrap-box .content .arrow {
-    position: absolute;
-    right: 10px;
-    top: 50%;
-    transform: translateY(-50%);
-    width: 20px;
-    height: 14px;
-    font-size: 14px;
-    color: #979797
-}
-
-.start-node.node-wrap-box .content .text {
-    display: block;
-    white-space: nowrap
-}
-
-.node-wrap-box:before {
-    content: "";
-    position: absolute;
-    top: -12px;
-    left: 50%;
-    -webkit-transform: translateX(-50%);
-    transform: translateX(-50%);
-    width: 0;
-    height: 4px;
-    border-style: solid;
-    border-width: 8px 6px 4px;
-    border-color: #cacaca transparent transparent;
-    background: #f5f5f7
-}
-
-.node-wrap-box.start-node:before {
-    content: none
-}
-
-.top-left-cover-line {
-    left: -1px
-}
-
-.top-left-cover-line,
-.top-right-cover-line {
-    position: absolute;
-    height: 8px;
-    width: 50%;
-    background-color: #f5f5f7;
-    top: -4px
-}
-
-.top-right-cover-line {
-    right: -1px
-}
-
-.bottom-left-cover-line {
-    left: -1px
-}
-
-.bottom-left-cover-line,
-.bottom-right-cover-line {
-    position: absolute;
-    height: 8px;
-    width: 50%;
-    background-color: #f5f5f7;
-    bottom: -4px
-}
-
-.bottom-right-cover-line {
-    right: -1px
-}
-
-.dingflow-design {
-    width: 100%;
-    background-color: #f5f5f7;
-    overflow: auto;
-    position: absolute;
-    bottom: 0;
-    left: 0;
-    right: 0;
-    top: 0
-}
-
-.dingflow-design .box-scale {
-    transform: scale(1);
-    display: inline-block;
-    position: relative;
-    width: 100%;
-    padding: 54.5px 0;
-    -webkit-box-align: start;
-    -ms-flex-align: start;
-    align-items: flex-start;
-    -webkit-box-pack: center;
-    -ms-flex-pack: center;
-    justify-content: center;
-    -ms-flex-wrap: wrap;
-    flex-wrap: wrap;
-    min-width: -webkit-min-content;
-    min-width: -moz-min-content;
-    min-width: min-content;
-    background-color: #f5f5f7;
-    transform-origin: 50% 0px 0px;
-}
-
-.dingflow-design .node-wrap {
-    flex-direction: column;
-    -webkit-box-pack: start;
-    -ms-flex-pack: start;
-    justify-content: flex-start;
-    -webkit-box-align: center;
-    -ms-flex-align: center;
-    align-items: center;
-    -ms-flex-wrap: wrap;
-    flex-wrap: wrap;
-    -webkit-box-flex: 1;
-    -ms-flex-positive: 1;
-    padding: 0 50px;
-    position: relative
-}
-
-.dingflow-design .branch-wrap,
-.dingflow-design .node-wrap {
-    display: inline-flex;
-    width: 100%
-}
-
-.dingflow-design .branch-box-wrap {
-    display: flex;
-    -webkit-box-orient: vertical;
-    -webkit-box-direction: normal;
-    -ms-flex-direction: column;
-    flex-direction: column;
-    -ms-flex-wrap: wrap;
-    flex-wrap: wrap;
-    -webkit-box-align: center;
-    -ms-flex-align: center;
-    align-items: center;
-    min-height: 270px;
-    width: 100%;
-    -ms-flex-negative: 0;
-    flex-shrink: 0
-}
-
-.dingflow-design .branch-box {
-    display: flex;
-    overflow: visible;
-    min-height: 180px;
-    height: auto;
-    border-bottom: 2px solid #ccc;
-    border-top: 2px solid #ccc;
-    position: relative;
-    margin-top: 15px
-}
-
-.dingflow-design .branch-box .col-box {
-    background: #f5f5f7
-}
-
-.dingflow-design .branch-box .col-box:before {
-    content: "";
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    z-index: 0;
-    margin: auto;
-    width: 2px;
-    height: 100%;
-    background-color: #cacaca
-}
-
-.dingflow-design .add-branch {
-    border: none;
-    outline: none;
-    user-select: none;
-    justify-content: center;
-    font-size: 12px;
-    padding: 0 10px;
-    height: 30px;
-    line-height: 30px;
-    border-radius: 15px;
-    color: #3296fa;
-    background: #fff;
-    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .1);
-    position: absolute;
-    top: -16px;
-    left: 50%;
-    transform: translateX(-50%);
-    transform-origin: center center;
-    cursor: pointer;
-    z-index: 1;
-    display: inline-flex;
-    align-items: center;
-    -webkit-transition: all .3s cubic-bezier(.645, .045, .355, 1);
-    transition: all .3s cubic-bezier(.645, .045, .355, 1)
-}
-
-.dingflow-design .add-branch:hover {
-    transform: translateX(-50%) scale(1.1);
-    box-shadow: 0 8px 16px 0 rgba(0, 0, 0, .1)
-}
-
-.dingflow-design .add-branch:active {
-    transform: translateX(-50%);
-    box-shadow: none
-}
-
-.dingflow-design .col-box {
-    display: inline-flex;
-    -webkit-box-orient: vertical;
-    -webkit-box-direction: normal;
-    flex-direction: column;
-    -webkit-box-align: center;
-    align-items: center;
-    position: relative
-}
-
-.dingflow-design .condition-node {
-    min-height: 220px
-}
-
-.dingflow-design .condition-node,
-.dingflow-design .condition-node-box {
-    display: inline-flex;
-    -webkit-box-orient: vertical;
-    -webkit-box-direction: normal;
-    flex-direction: column;
-    -webkit-box-flex: 1
-}
-
-.dingflow-design .condition-node-box {
-    padding-top: 30px;
-    padding-right: 50px;
-    padding-left: 50px;
-    -webkit-box-pack: center;
-    justify-content: center;
-    -webkit-box-align: center;
-    align-items: center;
-    flex-grow: 1;
-    position: relative
-}
-
-.dingflow-design .condition-node-box:before {
-    content: "";
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    margin: auto;
-    width: 2px;
-    height: 100%;
-    background-color: #cacaca
-}
-
-.dingflow-design .auto-judge {
-    position: relative;
-    width: 220px;
-    min-height: 72px;
-    background: #fff;
-    border-radius: 4px;
-    padding: 14px 19px;
-    cursor: pointer
-}
-
-.dingflow-design .auto-judge:after {
-    pointer-events: none;
-    content: "";
-    position: absolute;
-    top: 0;
-    bottom: 0;
-    left: 0;
-    right: 0;
-    z-index: 2;
-    border-radius: 4px;
-    border: 1px solid transparent;
-    transition: all .1s cubic-bezier(.645, .045, .355, 1);
-    box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1)
-}
-
-.dingflow-design .auto-judge.active:after,
-.dingflow-design .auto-judge:active:after,
-.dingflow-design .auto-judge:hover:after {
-    border: 1px solid #3296fa;
-    box-shadow: 0 0 6px 0 rgba(50, 150, 250, .3)
-}
-
-.dingflow-design .auto-judge.active .close,
-.dingflow-design .auto-judge:active .close,
-.dingflow-design .auto-judge:hover .close {
-    display: block
-}
-
-.dingflow-design .auto-judge.error:after {
-    border: 1px solid #f25643;
-    box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1)
-}
-
-.dingflow-design .auto-judge .title-wrapper {
-    position: relative;
-    font-size: 12px;
-    color: #15bc83;
-    text-align: left;
-    line-height: 16px
-}
-
-.dingflow-design .auto-judge .title-wrapper .editable-title {
-    display: inline-block;
-    max-width: 120px;
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis
-}
-
-.dingflow-design .auto-judge .title-wrapper .priority-title {
-    display: inline-block;
-    float: right;
-    margin-right: 10px;
-    color: rgba(25, 31, 37, .56)
-}
-
-.dingflow-design .auto-judge .placeholder {
-    color: #bfbfbf
-}
-
-.dingflow-design .auto-judge .close {
-    display: none;
-    position: absolute;
-    right: -10px;
-    top: -10px;
-    width: 20px;
-    height: 20px;
-    font-size: 14px;
-    color: rgba(0, 0, 0, .25);
-    border-radius: 50%;
-    text-align: center;
-    line-height: 20px;
-    z-index: 2
-}
-
-.dingflow-design .auto-judge .content {
-    font-size: 14px;
-    color: #191f25;
-    text-align: left;
-    margin-top: 6px;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    display: -webkit-box;
-    -webkit-line-clamp: 3;
-    -webkit-box-orient: vertical
-}
-
-.dingflow-design .auto-judge .sort-left,
-.dingflow-design .auto-judge .sort-right {
-    position: absolute;
-    top: 0;
-    bottom: 0;
-    display: none;
-    z-index: 1
-}
-
-.dingflow-design .auto-judge .sort-left {
-    left: 0;
-    border-right: 1px solid #f6f6f6
-}
-
-.dingflow-design .auto-judge .sort-right {
-    right: 0;
-    border-left: 1px solid #f6f6f6
-}
-
-.dingflow-design .auto-judge:hover .sort-left,
-.dingflow-design .auto-judge:hover .sort-right {
-    display: flex;
-    align-items: center
-}
-
-.dingflow-design .auto-judge .sort-left:hover,
-.dingflow-design .auto-judge .sort-right:hover {
-    background: #efefef
-}
-
-.dingflow-design .end-node {
-    border-radius: 50%;
-    font-size: 14px;
-    color: rgba(25, 31, 37, .4);
-    text-align: left
-}
-
-.dingflow-design .end-node .end-node-circle {
-    width: 10px;
-    height: 10px;
-    margin: auto;
-    border-radius: 50%;
-    background: #dbdcdc
-}
-
-.dingflow-design .end-node .end-node-text {
-    margin-top: 5px;
-    text-align: center
-}
-
-.approval-setting {
-    border-radius: 2px;
-    margin: 20px 0;
-    position: relative;
-    background: #fff
-}
-
-.ant-btn {
-    position: relative
-}
-
-

+ 214 - 0
src/components/SimpleProcessDesignerV2/src/NodeHandler.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="node-handler-wrapper">
+    <div class="node-handler">
+      <el-popover
+        trigger="hover"
+        v-model:visible="popoverShow"
+        placement="right-start"
+        width="auto"
+        v-if="!readonly"
+      >
+        <div class="handler-item-wrapper">
+          <div class="handler-item" @click="addNode(NodeType.USER_TASK_NODE)">
+            <div class="approve handler-item-icon">
+              <span class="iconfont icon-approve icon-size"></span>
+            </div>
+            <div class="handler-item-text">审批人</div>
+          </div>
+          <div class="handler-item" @click="addNode(NodeType.COPY_TASK_NODE)">
+            <div class="handler-item-icon copy">
+              <span class="iconfont icon-size icon-copy"></span>
+            </div>
+            <div class="handler-item-text">抄送</div>
+          </div>
+          <div class="handler-item" @click="addNode(NodeType.CONDITION_BRANCH_NODE)">
+            <div class="handler-item-icon condition">
+              <span class="iconfont icon-size icon-exclusive"></span>
+            </div>
+            <div class="handler-item-text">条件分支</div>
+          </div>
+          <div class="handler-item" @click="addNode(NodeType.PARALLEL_BRANCH_NODE)">
+            <div class="handler-item-icon parallel">
+              <span class="iconfont icon-size icon-parallel"></span>
+            </div>
+            <div class="handler-item-text">并行分支</div>
+          </div>
+          <div class="handler-item" @click="addNode(NodeType.INCLUSIVE_BRANCH_NODE)">
+            <div class="handler-item-icon inclusive">
+              <span class="iconfont icon-size icon-inclusive"></span>
+            </div>
+            <div class="handler-item-text">包容分支</div>
+          </div>
+        </div>
+        <template #reference>
+          <div class="add-icon"><Icon icon="ep:plus" /></div>
+        </template>
+      </el-popover>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  ApproveMethodType,
+  AssignEmptyHandlerType,
+  AssignStartUserHandlerType,
+  NODE_DEFAULT_NAME,
+  NodeType,
+  RejectHandlerType,
+  SimpleFlowNode
+} from './consts'
+import { generateUUID } from '@/utils'
+
+defineOptions({
+  name: 'NodeHandler'
+})
+
+const message = useMessage() // 消息弹窗
+
+const popoverShow = ref(false)
+const props = defineProps({
+  childNode: {
+    type: Object as () => SimpleFlowNode,
+    default: null
+  },
+  currentNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+const emits = defineEmits(['update:childNode'])
+
+const readonly = inject<Boolean>('readonly') // 是否只读
+
+const addNode = (type: number) => {
+  // 校验:条件分支、包容分支后面,不允许直接添加并行分支
+  if (
+    type === NodeType.PARALLEL_BRANCH_NODE &&
+    [NodeType.CONDITION_BRANCH_NODE, NodeType.INCLUSIVE_BRANCH_NODE].includes(
+      props.currentNode?.type
+    )
+  ) {
+    message.error('条件分支、包容分支后面,不允许直接添加并行分支')
+    return
+  }
+
+  popoverShow.value = false
+  if (type === NodeType.USER_TASK_NODE) {
+    const id = 'Activity_' + generateUUID()
+    const data: SimpleFlowNode = {
+      id: id,
+      name: NODE_DEFAULT_NAME.get(NodeType.USER_TASK_NODE) as string,
+      showText: '',
+      type: NodeType.USER_TASK_NODE,
+      approveMethod: ApproveMethodType.SEQUENTIAL_APPROVE,
+      // 超时处理
+      rejectHandler: {
+        type: RejectHandlerType.FINISH_PROCESS
+      },
+      timeoutHandler: {
+        enable: false
+      },
+      assignEmptyHandler: {
+        type: AssignEmptyHandlerType.APPROVE
+      },
+      assignStartUserHandlerType: AssignStartUserHandlerType.START_USER_AUDIT,
+      childNode: props.childNode
+    }
+    emits('update:childNode', data)
+  }
+  if (type === NodeType.COPY_TASK_NODE) {
+    const data: SimpleFlowNode = {
+      id: 'Activity_' + generateUUID(),
+      name: NODE_DEFAULT_NAME.get(NodeType.COPY_TASK_NODE) as string,
+      showText: '',
+      type: NodeType.COPY_TASK_NODE,
+      childNode: props.childNode
+    }
+    emits('update:childNode', data)
+  }
+  if (type === NodeType.CONDITION_BRANCH_NODE) {
+    const data: SimpleFlowNode = {
+      name: '条件分支',
+      type: NodeType.CONDITION_BRANCH_NODE,
+      id: 'GateWay_' + generateUUID(),
+      childNode: props.childNode,
+      conditionNodes: [
+        {
+          id: 'Flow_' + generateUUID(),
+          name: '条件1',
+          showText: '',
+          type: NodeType.CONDITION_NODE,
+          childNode: undefined,
+          conditionType: 1,
+          defaultFlow: false
+        },
+        {
+          id: 'Flow_' + generateUUID(),
+          name: '其它情况',
+          showText: '未满足其它条件时,将进入此分支',
+          type: NodeType.CONDITION_NODE,
+          childNode: undefined,
+          conditionType: undefined,
+          defaultFlow: true
+        }
+      ]
+    }
+    emits('update:childNode', data)
+  }
+  if (type === NodeType.PARALLEL_BRANCH_NODE) {
+    const data: SimpleFlowNode = {
+      name: '并行分支',
+      type: NodeType.PARALLEL_BRANCH_NODE,
+      id: 'GateWay_' + generateUUID(),
+      childNode: props.childNode,
+      conditionNodes: [
+        {
+          id: 'Flow_' + generateUUID(),
+          name: '并行1',
+          showText: '无需配置条件同时执行',
+          type: NodeType.CONDITION_NODE,
+          childNode: undefined
+        },
+        {
+          id: 'Flow_' + generateUUID(),
+          name: '并行2',
+          showText: '无需配置条件同时执行',
+          type: NodeType.CONDITION_NODE,
+          childNode: undefined
+        }
+      ]
+    }
+    emits('update:childNode', data)
+  }
+  if (type === NodeType.INCLUSIVE_BRANCH_NODE) {
+    const data: SimpleFlowNode = {
+      name: '包容分支',
+      type: NodeType.INCLUSIVE_BRANCH_NODE,
+      id: 'GateWay_' + generateUUID(),
+      childNode: props.childNode,
+      conditionNodes: [
+        {
+          id: 'Flow_' + generateUUID(),
+          name: '包容条件1',
+          showText: '',
+          type: NodeType.CONDITION_NODE,
+          childNode: undefined,
+          defaultFlow: false
+        },
+        {
+          id: 'Flow_' + generateUUID(),
+          name: '其它情况',
+          showText: '未满足其它条件时,将进入此分支',
+          type: NodeType.CONDITION_NODE,
+          childNode: undefined,
+          defaultFlow: true
+        }
+      ]
+    }
+    emits('update:childNode', data)
+  }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 118 - 0
src/components/SimpleProcessDesignerV2/src/ProcessNodeTree.vue

@@ -0,0 +1,118 @@
+<template>
+  <!-- 发起人节点 -->
+  <StartUserNode
+    v-if="currentNode && currentNode.type === NodeType.START_USER_NODE"
+    :flow-node="currentNode"
+  />
+  <!-- 审批节点 -->
+  <UserTaskNode
+    v-if="currentNode && currentNode.type === NodeType.USER_TASK_NODE"
+    :flow-node="currentNode"
+    @update:flow-node="handleModelValueUpdate"
+    @find:parent-node="findFromParentNode"
+  />
+  <!-- 抄送节点 -->
+  <CopyTaskNode
+    v-if="currentNode && currentNode.type === NodeType.COPY_TASK_NODE"
+    :flow-node="currentNode"
+    @update:flow-node="handleModelValueUpdate"
+  />
+  <!-- 条件节点 -->
+  <ExclusiveNode
+    v-if="currentNode && currentNode.type === NodeType.CONDITION_BRANCH_NODE"
+    :flow-node="currentNode"
+    @update:model-value="handleModelValueUpdate"
+    @find:parent-node="findFromParentNode"
+  />
+  <!-- 并行节点 -->
+  <ParallelNode
+    v-if="currentNode && currentNode.type === NodeType.PARALLEL_BRANCH_NODE"
+    :flow-node="currentNode"
+    @update:model-value="handleModelValueUpdate"
+    @find:parent-node="findFromParentNode"
+  />
+  <!-- 包容分支节点 -->
+  <InclusiveNode
+    v-if="currentNode && currentNode.type === NodeType.INCLUSIVE_BRANCH_NODE"
+    :flow-node="currentNode"
+    @update:model-value="handleModelValueUpdate"
+    @find:parent-node="findFromParentNode"
+  />
+  <!-- 递归显示孩子节点  -->
+  <ProcessNodeTree
+    v-if="currentNode && currentNode.childNode"
+    v-model:flow-node="currentNode.childNode"
+    :parent-node="currentNode"
+    @find:recursive-find-parent-node="recursiveFindParentNode"
+  />
+
+  <!-- 结束节点 -->
+  <EndEventNode
+    v-if="currentNode && currentNode.type === NodeType.END_EVENT_NODE"
+    :flow-node="currentNode"
+  />
+</template>
+<script setup lang="ts">
+import StartUserNode from './nodes/StartUserNode.vue'
+import EndEventNode from './nodes/EndEventNode.vue'
+import UserTaskNode from './nodes/UserTaskNode.vue'
+import CopyTaskNode from './nodes/CopyTaskNode.vue'
+import ExclusiveNode from './nodes/ExclusiveNode.vue'
+import ParallelNode from './nodes/ParallelNode.vue'
+import InclusiveNode from './nodes/InclusiveNode.vue'
+import { SimpleFlowNode, NodeType } from './consts'
+import { useWatchNode } from './node'
+defineOptions({
+  name: 'ProcessNodeTree'
+})
+const props = defineProps({
+  parentNode: {
+    type: Object as () => SimpleFlowNode,
+    default: () => null
+  },
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    default: () => null
+  }
+})
+const emits = defineEmits<{
+  'update:flowNode': [node: SimpleFlowNode | undefined]
+  'find:recursiveFindParentNode': [
+    nodeList: SimpleFlowNode[],
+    curentNode: SimpleFlowNode,
+    nodeType: number
+  ]
+}>()
+
+const currentNode = useWatchNode(props)
+
+// 用于删除节点
+const handleModelValueUpdate = (updateValue) => {
+  emits('update:flowNode', updateValue)
+}
+
+const findFromParentNode = (nodeList: SimpleFlowNode[], nodeType: number) => {
+  emits('find:recursiveFindParentNode', nodeList, props.parentNode, nodeType)
+}
+
+// 递归从父节点中查询匹配的节点
+const recursiveFindParentNode = (
+  nodeList: SimpleFlowNode[],
+  findNode: SimpleFlowNode,
+  nodeType: number
+) => {
+  if (!findNode) {
+    return
+  }
+  if (findNode.type === NodeType.START_USER_NODE) {
+    nodeList.push(findNode)
+    return
+  }
+
+  if (findNode.type === nodeType) {
+    nodeList.push(findNode)
+  }
+  emits('find:recursiveFindParentNode', nodeList, props.parentNode, nodeType)
+}
+</script>
+<style lang="scss" scoped></style>

+ 179 - 0
src/components/SimpleProcessDesignerV2/src/SimpleProcessDesigner.vue

@@ -0,0 +1,179 @@
+<template>
+  <div v-loading="loading" class="overflow-auto">
+    <SimpleProcessModel
+      v-if="processNodeTree"
+      :flow-node="processNodeTree"
+      :readonly="false"
+      @save="saveSimpleFlowModel"
+    />
+    <Dialog v-model="errorDialogVisible" title="保存失败" width="400" :fullscreen="false">
+      <div class="mb-2">以下节点内容不完善,请修改后保存</div>
+      <div
+        class="mb-3 b-rounded-1 bg-gray-100 p-2 line-height-normal"
+        v-for="(item, index) in errorNodes"
+        :key="index"
+      >
+        {{ item.name }} : {{ NODE_DEFAULT_TEXT.get(item.type) }}
+      </div>
+      <template #footer>
+        <el-button type="primary" @click="errorDialogVisible = false">知道了</el-button>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import SimpleProcessModel from './SimpleProcessModel.vue'
+import { updateBpmSimpleModel, getBpmSimpleModel } from '@/api/bpm/simple'
+import { SimpleFlowNode, NodeType, NodeId, NODE_DEFAULT_TEXT } from './consts'
+import { getModel } from '@/api/bpm/model'
+import { getForm, FormVO } from '@/api/bpm/form'
+import { handleTree } from '@/utils/tree'
+import * as RoleApi from '@/api/system/role'
+import * as DeptApi from '@/api/system/dept'
+import * as PostApi from '@/api/system/post'
+import * as UserApi from '@/api/system/user'
+import * as UserGroupApi from '@/api/bpm/userGroup'
+
+defineOptions({
+  name: 'SimpleProcessDesigner'
+})
+const emits = defineEmits(['success']) // 保存成功事件
+
+const props = defineProps({
+  modelId: {
+    type: String,
+    required: true
+  }
+})
+
+const loading = ref(false)
+const formFields = ref<string[]>([])
+const formType = ref(20)
+const roleOptions = ref<RoleApi.RoleVO[]>([]) // 角色列表
+const postOptions = ref<PostApi.PostVO[]>([]) // 岗位列表
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const deptOptions = ref<DeptApi.DeptVO[]>([]) // 部门列表
+const deptTreeOptions = ref()
+const userGroupOptions = ref<UserGroupApi.UserGroupVO[]>([]) // 用户组列表
+provide('formFields', formFields)
+provide('formType', formType)
+provide('roleList', roleOptions)
+provide('postList', postOptions)
+provide('userList', userOptions)
+provide('deptList', deptOptions)
+provide('userGroupList', userGroupOptions)
+provide('deptTree', deptTreeOptions)
+
+const message = useMessage() // 国际化
+const processNodeTree = ref<SimpleFlowNode | undefined>()
+const errorDialogVisible = ref(false)
+let errorNodes: SimpleFlowNode[] = []
+const saveSimpleFlowModel = async (simpleModelNode: SimpleFlowNode) => {
+  if (!simpleModelNode) {
+    message.error('模型数据为空')
+    return
+  }
+  try {
+    loading.value = true
+    const data = {
+      id: props.modelId,
+      simpleModel: simpleModelNode
+    }
+    const result = await updateBpmSimpleModel(data)
+    if (result) {
+      message.success('修改成功')
+      emits('success')
+    } else {
+      message.alert('修改失败')
+    }
+  } finally {
+    loading.value = false
+  }
+}
+// 校验节点设置。 暂时以 showText 为空 未节点错误配置
+const validateNode = (node: SimpleFlowNode | undefined, errorNodes: SimpleFlowNode[]) => {
+  if (node) {
+    const { type, showText, conditionNodes } = node
+    if (type == NodeType.END_EVENT_NODE) {
+      return
+    }
+    if (type == NodeType.START_USER_NODE) {
+      // 发起人节点暂时不用校验,直接校验孩子节点
+      validateNode(node.childNode, errorNodes)
+    }
+
+    if (
+      type === NodeType.USER_TASK_NODE ||
+      type === NodeType.COPY_TASK_NODE ||
+      type === NodeType.CONDITION_NODE
+    ) {
+      if (!showText) {
+        errorNodes.push(node)
+      }
+      validateNode(node.childNode, errorNodes)
+    }
+
+    if (
+      type == NodeType.CONDITION_BRANCH_NODE ||
+      type == NodeType.PARALLEL_BRANCH_NODE ||
+      type == NodeType.INCLUSIVE_BRANCH_NODE
+    ) {
+      // 分支节点
+      // 1. 先校验各个分支
+      conditionNodes?.forEach((item) => {
+        validateNode(item, errorNodes)
+      })
+      // 2. 校验孩子节点
+      validateNode(node.childNode, errorNodes)
+    }
+  }
+}
+
+onMounted(async () => {
+  try {
+    loading.value = true
+    // 获取表单字段
+    const bpmnModel = await getModel(props.modelId)
+    if (bpmnModel) {
+      formType.value = bpmnModel.formType
+      if (formType.value === 10) {
+        const bpmnForm = (await getForm(bpmnModel.formId)) as unknown as FormVO
+        formFields.value = bpmnForm?.fields
+      }
+    }
+    // 获得角色列表
+    roleOptions.value = await RoleApi.getSimpleRoleList()
+    // 获得岗位列表
+    postOptions.value = await PostApi.getSimplePostList()
+    // 获得用户列表
+    userOptions.value = await UserApi.getSimpleUserList()
+    // 获得部门列表
+    deptOptions.value = await DeptApi.getSimpleDeptList()
+
+    deptTreeOptions.value = handleTree(deptOptions.value as DeptApi.DeptVO[], 'id')
+    // 获取用户组列表
+    userGroupOptions.value = await UserGroupApi.getUserGroupSimpleList()
+
+    //获取 SIMPLE 设计器模型
+    const result = await getBpmSimpleModel(props.modelId)
+    if (result) {
+      processNodeTree.value = result
+    } else {
+      // 初始值
+      processNodeTree.value = {
+        name: '发起人',
+        type: NodeType.START_USER_NODE,
+        id: NodeId.START_USER_NODE_ID,
+        childNode: {
+          id: NodeId.END_EVENT_NODE_ID,
+          name: '结束',
+          type: NodeType.END_EVENT_NODE
+        }
+      }
+    }
+  } finally {
+    loading.value = false
+  }
+})
+</script>

+ 140 - 0
src/components/SimpleProcessDesignerV2/src/SimpleProcessModel.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="simple-process-model-container position-relative">
+    <div class="position-absolute top-0px right-0px bg-#fff">
+      <el-row type="flex" justify="end">
+        <el-button-group key="scale-control" size="default">
+          <el-button size="default" :icon="ScaleToOriginal" @click="processReZoom()" />
+          <el-button size="default" :plain="true" :icon="ZoomOut" @click="zoomOut()" />
+          <el-button size="default" class="w-80px"> {{ scaleValue }}% </el-button>
+          <el-button size="default" :plain="true" :icon="ZoomIn" @click="zoomIn()" />
+        </el-button-group>
+        <el-button
+          v-if="!readonly"
+          size="default"
+          class="ml-4px"
+          type="primary"
+          :icon="Select"
+          @click="saveSimpleFlowModel"
+          >保存模型</el-button
+        >
+      </el-row>
+    </div>
+    <div class="simple-process-model" :style="`transform: scale(${scaleValue / 100});`">
+      <ProcessNodeTree v-if="processNodeTree" v-model:flow-node="processNodeTree" />
+    </div>
+  </div>
+  <Dialog v-model="errorDialogVisible" title="保存失败" width="400" :fullscreen="false">
+    <div class="mb-2">以下节点内容不完善,请修改后保存</div>
+    <div
+      class="mb-3 b-rounded-1 bg-gray-100 p-2 line-height-normal"
+      v-for="(item, index) in errorNodes"
+      :key="index"
+    >
+      {{ item.name }} : {{ NODE_DEFAULT_TEXT.get(item.type) }}
+    </div>
+    <template #footer>
+      <el-button type="primary" @click="errorDialogVisible = false">知道了</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import ProcessNodeTree from './ProcessNodeTree.vue'
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from './consts'
+import { useWatchNode } from './node'
+import { Select, ZoomOut, ZoomIn, ScaleToOriginal } from '@element-plus/icons-vue'
+defineOptions({
+  name: 'SimpleProcessModel'
+})
+
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  },
+  readonly: {
+    type: Boolean,
+    required: false,
+    default: true
+  }
+})
+const emits = defineEmits<{
+  'save': [node: SimpleFlowNode | undefined]
+}>()
+
+const processNodeTree = useWatchNode(props)
+
+provide('readonly', props.readonly)
+let scaleValue = ref(100)
+const MAX_SCALE_VALUE = 200
+const MIN_SCALE_VALUE = 50
+// 放大
+const zoomIn = () => {
+  if (scaleValue.value == MAX_SCALE_VALUE) {
+    return
+  }
+  scaleValue.value += 10
+}
+// 缩小
+const zoomOut = () => {
+  if (scaleValue.value == MIN_SCALE_VALUE) {
+    return
+  }
+  scaleValue.value -= 10
+}
+const processReZoom = () => {
+  scaleValue.value = 100
+}
+
+const errorDialogVisible = ref(false)
+let errorNodes: SimpleFlowNode[] = []
+const saveSimpleFlowModel = async () => {
+  errorNodes = []
+  validateNode(processNodeTree.value, errorNodes)
+  if (errorNodes.length > 0) {
+    errorDialogVisible.value = true
+    return
+  }
+  emits('save', processNodeTree.value)
+}
+// 校验节点设置。 暂时以 showText 为空 未节点错误配置
+const validateNode = (node: SimpleFlowNode | undefined, errorNodes: SimpleFlowNode[]) => {
+  if (node) {
+    const { type, showText, conditionNodes } = node
+    if (type == NodeType.END_EVENT_NODE) {
+      return
+    }
+    if (type == NodeType.START_USER_NODE) {
+      // 发起人节点暂时不用校验,直接校验孩子节点
+      validateNode(node.childNode, errorNodes)
+    }
+
+    if (
+      type === NodeType.USER_TASK_NODE ||
+      type === NodeType.COPY_TASK_NODE ||
+      type === NodeType.CONDITION_NODE
+    ) {
+      if (!showText) {
+        errorNodes.push(node)
+      }
+      validateNode(node.childNode, errorNodes)
+    }
+
+    if (
+      type == NodeType.CONDITION_BRANCH_NODE ||
+      type == NodeType.PARALLEL_BRANCH_NODE ||
+      type == NodeType.INCLUSIVE_BRANCH_NODE
+    ) {
+      // 分支节点
+      // 1. 先校验各个分支
+      conditionNodes?.forEach((item) => {
+        validateNode(item, errorNodes)
+      })
+      // 2. 校验孩子节点
+      validateNode(node.childNode, errorNodes)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 48 - 0
src/components/SimpleProcessDesignerV2/src/SimpleProcessViewer.vue

@@ -0,0 +1,48 @@
+<template>
+  <SimpleProcessModel :flow-node="simpleModel" :readonly="true" />
+</template>
+
+<script setup lang="ts">
+import { useWatchNode } from './node'
+import { SimpleFlowNode } from './consts'
+
+defineOptions({
+  name: 'SimpleProcessViewer'
+})
+
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  },
+  // 流程任务
+  tasks: {
+    type: Array,
+    default: () => [] as any[]
+  },
+  // 流程实例
+  processInstance: {
+    type: Object,
+    default: () => undefined
+  }
+})
+const approveTasks = ref<any[]>(props.tasks)
+const currentProcessInstance = ref(props.processInstance)
+const simpleModel = useWatchNode(props)
+watch(
+  () => props.tasks,
+  (newValue) => {
+    approveTasks.value = newValue
+  }
+)
+watch(
+  () => props.processInstance,
+  (newValue) => {
+    currentProcessInstance.value = newValue
+  }
+)
+
+provide('tasks', approveTasks)
+provide('processInstance', currentProcessInstance)
+</script>
+p

+ 570 - 0
src/components/SimpleProcessDesignerV2/src/consts.ts

@@ -0,0 +1,570 @@
+// @ts-ignore
+import { DictDataVO } from '@/api/system/dict/types'
+import { TaskStatusEnum } from '@/api/bpm/task'
+/**
+ * 节点类型
+ */
+export enum NodeType {
+  /**
+   * 结束节点
+   */
+  END_EVENT_NODE = 1,
+  /**
+   * 发起人节点
+   */
+  START_USER_NODE = 10,
+  /**
+   * 审批人节点
+   */
+  USER_TASK_NODE = 11,
+
+  /**
+   * 抄送人节点
+   */
+  COPY_TASK_NODE = 12,
+
+  /**
+   * 条件节点
+   */
+  CONDITION_NODE = 50,
+  /**
+   * 条件分支节点 (对应排他网关)
+   */
+  CONDITION_BRANCH_NODE = 51,
+  /**
+   * 并行分支节点 (对应并行网关)
+   */
+  PARALLEL_BRANCH_NODE = 52,
+
+  /**
+   * 包容分支节点 (对应包容网关)
+   */
+  INCLUSIVE_BRANCH_NODE = 53
+}
+
+export enum NodeId {
+  /**
+   * 发起人节点 Id
+   */
+  START_USER_NODE_ID = 'StartUserNode',
+
+  /**
+   * 发起人节点 Id
+   */
+  END_EVENT_NODE_ID = 'EndEvent'
+}
+
+/**
+ *  节点结构定义
+ */
+export interface SimpleFlowNode {
+  id: string
+  type: NodeType
+  name: string
+  showText?: string
+  // 孩子节点
+  childNode?: SimpleFlowNode
+  // 条件节点
+  conditionNodes?: SimpleFlowNode[]
+  // 审批类型
+  approveType?: ApproveType
+  // 候选人策略
+  candidateStrategy?: number
+  // 候选人参数
+  candidateParam?: string
+  // 多人审批方式
+  approveMethod?: ApproveMethodType
+  //通过比例
+  approveRatio?: number
+  // 审批按钮设置
+  buttonsSetting?: any[]
+  // 表单权限
+  fieldsPermission?: Array<Record<string, any>>
+  // 审批任务超时处理
+  timeoutHandler?: TimeoutHandler
+  // 审批任务拒绝处理
+  rejectHandler?: RejectHandler
+  // 审批人为空的处理
+  assignEmptyHandler?: AssignEmptyHandler
+  // 审批节点的审批人与发起人相同时,对应的处理类型
+  assignStartUserHandlerType?: number
+  // 条件类型
+  conditionType?: ConditionType
+  // 条件表达式
+  conditionExpression?: string
+  // 条件组
+  conditionGroups?: ConditionGroup
+  // 是否默认的条件
+  defaultFlow?: boolean
+  // 活动的状态,用于前端节点状态展示
+  activityStatus?: TaskStatusEnum
+}
+// 候选人策略枚举 ( 用于审批节点。抄送节点 )
+export enum CandidateStrategy {
+  /**
+   * 指定角色
+   */
+  ROLE = 10,
+  /**
+   * 部门成员
+   */
+  DEPT_MEMBER = 20,
+  /**
+   * 部门的负责人
+   */
+  DEPT_LEADER = 21,
+  /**
+   * 连续多级部门的负责人
+   */
+  MULTI_LEVEL_DEPT_LEADER = 23,
+  /**
+   * 指定岗位
+   */
+  POST = 22,
+  /**
+   * 指定用户
+   */
+  USER = 30,
+  /**
+   * 发起人自选
+   */
+  START_USER_SELECT = 35,
+  /**
+   * 发起人自己
+   */
+  START_USER = 36,
+  /**
+   * 发起人部门负责人
+   */
+  START_USER_DEPT_LEADER = 37,
+  /**
+   * 发起人连续多级部门的负责人
+   */
+  START_USER_MULTI_LEVEL_DEPT_LEADER = 38,
+  /**
+   * 指定用户组
+   */
+  USER_GROUP = 40,
+  /**
+   * 表单内用户字段
+   */
+  FORM_USER = 50,
+  /**
+   * 表单内部门负责人
+   */
+  FORM_DEPT_LEADER = 51,
+  /**
+   * 流程表达式
+   */
+  EXPRESSION = 60
+}
+
+// 多人审批方式类型枚举 ( 用于审批节点 )
+export enum ApproveMethodType {
+  /**
+   * 随机挑选一人审批
+   */
+  RANDOM_SELECT_ONE_APPROVE = 1,
+
+  /**
+   * 多人会签(按通过比例)
+   */
+  APPROVE_BY_RATIO = 2,
+
+  /**
+   * 多人或签(通过只需一人,拒绝只需一人)
+   */
+  ANY_APPROVE = 3,
+  /**
+   * 多人依次审批
+   */
+  SEQUENTIAL_APPROVE = 4
+}
+
+/**
+ * 审批拒绝结构定义
+ */
+export type RejectHandler = {
+  // 审批拒绝类型
+  type: RejectHandlerType
+  // 退回节点 Id
+  returnNodeId?: string
+}
+
+/**
+ * 审批超时结构定义
+ */
+export type TimeoutHandler = {
+  // 是否开启超时处理
+  enable: boolean
+  // 超时执行的动作
+  type?: number
+  // 超时时间设置
+  timeDuration?: string
+  // 执行动作是自动提醒, 最大提醒次数
+  maxRemindCount?: number
+}
+
+/**
+ * 审批人为空的结构定义
+ */
+export type AssignEmptyHandler = {
+  // 审批人为空的处理类型
+  type: AssignEmptyHandlerType
+  // 指定用户的编号数组
+  userIds?: number[]
+}
+
+// 审批拒绝类型枚举
+export enum RejectHandlerType {
+  /**
+   * 结束流程
+   */
+  FINISH_PROCESS = 1,
+  /**
+   * 驳回到指定节点
+   */
+  RETURN_USER_TASK = 2
+}
+// 用户任务超时处理类型枚举
+export enum TimeoutHandlerType {
+  /**
+   * 自动提醒
+   */
+  REMINDER = 1,
+  /**
+   * 自动同意
+   */
+  APPROVE = 2,
+  /**
+   * 自动拒绝
+   */
+  REJECT = 3
+}
+// 用户任务的审批人为空时,处理类型枚举
+export enum AssignEmptyHandlerType {
+  /**
+   * 自动通过
+   */
+  APPROVE = 1,
+  /**
+   * 自动拒绝
+   */
+  REJECT = 2,
+  /**
+   * 指定人员审批
+   */
+  ASSIGN_USER,
+  /**
+   * 转交给流程管理员
+   */
+  ASSIGN_ADMIN = 4
+}
+// 用户任务的审批人与发起人相同时,处理类型枚举
+export enum AssignStartUserHandlerType {
+  /**
+   * 由发起人对自己审批
+   */
+  START_USER_AUDIT = 1,
+  /**
+   * 自动跳过【参考飞书】:1)如果当前节点还有其他审批人,则交由其他审批人进行审批;2)如果当前节点没有其他审批人,则该节点自动通过
+   */
+  SKIP = 2,
+  /**
+   * 转交给部门负责人审批
+   */
+  ASSIGN_DEPT_LEADER = 3
+}
+
+// 用户任务的审批类型。 【参考飞书】
+export enum ApproveType {
+  /**
+   * 人工审批
+   */
+  USER = 1,
+  /**
+   * 自动通过
+   */
+  AUTO_APPROVE = 2,
+  /**
+   * 自动拒绝
+   */
+  AUTO_REJECT = 3
+}
+
+// 时间单位枚举
+export enum TimeUnitType {
+  /**
+   * 分钟
+   */
+  MINUTE = 1,
+  /**
+   * 小时
+   */
+  HOUR = 2,
+  /**
+   * 天
+   */
+  DAY = 3
+}
+
+// 条件配置类型 ( 用于条件节点配置 )
+export enum ConditionType {
+  /**
+   * 条件表达式
+   */
+  EXPRESSION = 1,
+
+  /**
+   * 条件规则
+   */
+  RULE = 2
+}
+/**
+ * 表单权限的枚举
+ */
+export enum FieldPermissionType {
+  /**
+   * 只读
+   */
+  READ = '1',
+  /**
+   * 编辑
+   */
+  WRITE = '2',
+  /**
+   * 隐藏
+   */
+  NONE = '3'
+}
+/**
+ * 操作按钮权限结构定义
+ */
+export type ButtonSetting = {
+  id: OperationButtonType
+  displayName: string
+  enable: boolean
+}
+
+// 操作按钮类型枚举 (用于审批节点)
+export enum OperationButtonType {
+  /**
+   * 通过
+   */
+  APPROVE = 1,
+  /**
+   * 拒绝
+   */
+  REJECT = 2,
+  /**
+   * 转办
+   */
+  TRANSFER = 3,
+  /**
+   * 委派
+   */
+  DELEGATE = 4,
+  /**
+   * 加签
+   */
+  ADD_SIGN = 5,
+  /**
+   * 退回
+   */
+  RETURN = 6,
+  /**
+   * 抄送
+   */
+  COPY = 7
+}
+
+/**
+ * 条件规则结构定义
+ */
+export type ConditionRule = {
+  type: number
+  opName: string
+  opCode: string
+  leftSide: string
+  rightSide: string
+}
+
+/**
+ * 条件组结构定义
+ */
+export type ConditionGroup = {
+  // 条件组的逻辑关系是否为且
+  and: boolean
+  // 条件数组
+  conditions: Condition[]
+}
+
+/**
+ * 条件结构定义
+ */
+export type Condition = {
+  // 条件规则的逻辑关系是否为且
+  and: boolean
+  rules: ConditionRule[]
+}
+
+export const NODE_DEFAULT_TEXT = new Map<number, string>()
+NODE_DEFAULT_TEXT.set(NodeType.USER_TASK_NODE, '请配置审批人')
+NODE_DEFAULT_TEXT.set(NodeType.COPY_TASK_NODE, '请配置抄送人')
+NODE_DEFAULT_TEXT.set(NodeType.CONDITION_NODE, '请设置条件')
+NODE_DEFAULT_TEXT.set(NodeType.START_USER_NODE, '请设置发起人')
+
+export const NODE_DEFAULT_NAME = new Map<number, string>()
+NODE_DEFAULT_NAME.set(NodeType.USER_TASK_NODE, '审批人')
+NODE_DEFAULT_NAME.set(NodeType.COPY_TASK_NODE, '抄送人')
+NODE_DEFAULT_NAME.set(NodeType.CONDITION_NODE, '条件')
+NODE_DEFAULT_NAME.set(NodeType.START_USER_NODE, '发起人')
+
+// 候选人策略。暂时不从字典中取。 后续可能调整。控制显示顺序
+export const CANDIDATE_STRATEGY: DictDataVO[] = [
+  { label: '指定成员', value: CandidateStrategy.USER },
+  { label: '指定角色', value: CandidateStrategy.ROLE },
+  { label: '部门成员', value: CandidateStrategy.DEPT_MEMBER },
+  { label: '部门负责人', value: CandidateStrategy.DEPT_LEADER },
+  { label: '连续多级部门负责人', value: CandidateStrategy.MULTI_LEVEL_DEPT_LEADER },
+  { label: '发起人自选', value: CandidateStrategy.START_USER_SELECT },
+  { label: '发起人本人', value: CandidateStrategy.START_USER },
+  { label: '发起人部门负责人', value: CandidateStrategy.START_USER_DEPT_LEADER },
+  { label: '发起人连续部门负责人', value: CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER },
+  { label: '用户组', value: CandidateStrategy.USER_GROUP },
+  { label: '表单内用户字段', value: CandidateStrategy.FORM_USER },
+  { label: '表单内部门负责人', value: CandidateStrategy.FORM_DEPT_LEADER },
+  { label: '流程表达式', value: CandidateStrategy.EXPRESSION }
+]
+// 审批节点 的审批类型
+export const APPROVE_TYPE: DictDataVO[] = [
+  { label: '人工审批', value: ApproveType.USER },
+  { label: '自动通过', value: ApproveType.AUTO_APPROVE },
+  { label: '自动拒绝', value: ApproveType.AUTO_REJECT }
+]
+
+export const APPROVE_METHODS: DictDataVO[] = [
+  { label: '按顺序依次审批', value: ApproveMethodType.SEQUENTIAL_APPROVE },
+  { label: '会签(可同时审批,至少 % 人必须审批通过)', value: ApproveMethodType.APPROVE_BY_RATIO },
+  { label: '或签(可同时审批,有一人通过即可)', value: ApproveMethodType.ANY_APPROVE },
+  { label: '随机挑选一人审批', value: ApproveMethodType.RANDOM_SELECT_ONE_APPROVE }
+]
+
+export const CONDITION_CONFIG_TYPES: DictDataVO[] = [
+  { label: '条件表达式', value: ConditionType.EXPRESSION },
+  { label: '条件规则', value: ConditionType.RULE }
+]
+
+// 时间单位类型
+export const TIME_UNIT_TYPES: DictDataVO[] = [
+  { label: '分钟', value: TimeUnitType.MINUTE },
+  { label: '小时', value: TimeUnitType.HOUR },
+  { label: '天', value: TimeUnitType.DAY }
+]
+// 超时处理执行动作类型
+export const TIMEOUT_HANDLER_TYPES: DictDataVO[] = [
+  { label: '自动提醒', value: 1 },
+  { label: '自动同意', value: 2 },
+  { label: '自动拒绝', value: 3 }
+]
+export const REJECT_HANDLER_TYPES: DictDataVO[] = [
+  { label: '终止流程', value: RejectHandlerType.FINISH_PROCESS },
+  { label: '驳回到指定节点', value: RejectHandlerType.RETURN_USER_TASK }
+  // { label: '结束任务', value: RejectHandlerType.FINISH_TASK }
+]
+export const ASSIGN_EMPTY_HANDLER_TYPES: DictDataVO[] = [
+  { label: '自动通过', value: 1 },
+  { label: '自动拒绝', value: 2 },
+  { label: '指定成员审批', value: 3 },
+  { label: '转交给流程管理员', value: 4 }
+]
+export const ASSIGN_START_USER_HANDLER_TYPES: DictDataVO[] = [
+  { label: '由发起人对自己审批', value: 1 },
+  { label: '自动跳过', value: 2 },
+  { label: '转交给部门负责人审批', value: 3 }
+]
+
+// 比较运算符
+export const COMPARISON_OPERATORS: DictDataVO = [
+  {
+    value: '==',
+    label: '等于'
+  },
+  {
+    value: '!=',
+    label: '不等于'
+  },
+  {
+    value: '>',
+    label: '大于'
+  },
+  {
+    value: '>=',
+    label: '大于等于'
+  },
+  {
+    value: '<',
+    label: '小于'
+  },
+  {
+    value: '<=',
+    label: '小于等于'
+  }
+]
+// 审批操作按钮名称
+export const OPERATION_BUTTON_NAME = new Map<number, string>()
+OPERATION_BUTTON_NAME.set(OperationButtonType.APPROVE, '通过')
+OPERATION_BUTTON_NAME.set(OperationButtonType.REJECT, '拒绝')
+OPERATION_BUTTON_NAME.set(OperationButtonType.TRANSFER, '转办')
+OPERATION_BUTTON_NAME.set(OperationButtonType.DELEGATE, '委派')
+OPERATION_BUTTON_NAME.set(OperationButtonType.ADD_SIGN, '加签')
+OPERATION_BUTTON_NAME.set(OperationButtonType.RETURN, '退回')
+OPERATION_BUTTON_NAME.set(OperationButtonType.COPY, '抄送')
+
+// 默认的按钮权限设置
+export const DEFAULT_BUTTON_SETTING: ButtonSetting[] = [
+  { id: OperationButtonType.APPROVE, displayName: '通过', enable: true },
+  { id: OperationButtonType.REJECT, displayName: '拒绝', enable: true },
+  { id: OperationButtonType.TRANSFER, displayName: '转办', enable: true },
+  { id: OperationButtonType.DELEGATE, displayName: '委派', enable: true },
+  { id: OperationButtonType.ADD_SIGN, displayName: '加签', enable: true },
+  { id: OperationButtonType.RETURN, displayName: '退回', enable: true }
+]
+
+// 发起人的按钮权限。暂时定死,不可以编辑
+export const START_USER_BUTTON_SETTING: ButtonSetting[] = [
+  { id: OperationButtonType.APPROVE, displayName: '提交', enable: true },
+  { id: OperationButtonType.REJECT, displayName: '拒绝', enable: false },
+  { id: OperationButtonType.TRANSFER, displayName: '转办', enable: false },
+  { id: OperationButtonType.DELEGATE, displayName: '委派', enable: false },
+  { id: OperationButtonType.ADD_SIGN, displayName: '加签', enable: false },
+  { id: OperationButtonType.RETURN, displayName: '退回', enable: false }
+]
+
+export const MULTI_LEVEL_DEPT: DictDataVO = [
+  { label: '第 1 级部门', value: 1 },
+  { label: '第 2 级部门', value: 2 },
+  { label: '第 3 级部门', value: 3 },
+  { label: '第 4 级部门', value: 4 },
+  { label: '第 5 级部门', value: 5 },
+  { label: '第 6 级部门', value: 6 },
+  { label: '第 7 级部门', value: 7 },
+  { label: '第 8 级部门', value: 8 },
+  { label: '第 9 级部门', value: 9 },
+  { label: '第 10 级部门', value: 10 },
+  { label: '第 11 级部门', value: 11 },
+  { label: '第 12 级部门', value: 12 },
+  { label: '第 13 级部门', value: 13 },
+  { label: '第 14 级部门', value: 14 },
+  { label: '第 15 级部门', value: 15 }
+]
+
+/**
+ * 流程实例的变量枚举
+ */
+export enum ProcessVariableEnum {
+  /**
+   * 发起用户 ID
+   */
+  START_USER_ID = 'PROCESS_START_USER_ID'
+}

+ 5 - 0
src/components/SimpleProcessDesignerV2/src/index.ts

@@ -0,0 +1,5 @@
+import SimpleProcessDesigner from './SimpleProcessDesigner.vue'
+import SimpleProcessViewer from './SimpleProcessViewer.vue'
+import '../theme/simple-process-designer.scss'
+
+export { SimpleProcessDesigner, SimpleProcessViewer}

+ 495 - 0
src/components/SimpleProcessDesignerV2/src/node.ts

@@ -0,0 +1,495 @@
+import { cloneDeep } from 'lodash-es'
+import { TaskStatusEnum } from '@/api/bpm/task'
+import * as RoleApi from '@/api/system/role'
+import * as DeptApi from '@/api/system/dept'
+import * as PostApi from '@/api/system/post'
+import * as UserApi from '@/api/system/user'
+import * as UserGroupApi from '@/api/bpm/userGroup'
+import {
+  SimpleFlowNode,
+  CandidateStrategy,
+  NodeType,
+  ApproveMethodType,
+  RejectHandlerType,
+  NODE_DEFAULT_NAME,
+  AssignStartUserHandlerType,
+  AssignEmptyHandlerType,
+  FieldPermissionType,
+  ProcessVariableEnum
+} from './consts'
+import { parseFormFields } from '@/components/FormCreate/src/utils/index'
+export function useWatchNode(props: { flowNode: SimpleFlowNode }): Ref<SimpleFlowNode> {
+  const node = ref<SimpleFlowNode>(props.flowNode)
+  watch(
+    () => props.flowNode,
+    (newValue) => {
+      node.value = newValue
+    }
+  )
+  return node
+}
+
+// 解析 formCreate 所有表单字段, 并返回
+const parseFormCreateFields = (formFields?: string[]) => {
+  const result: Array<Record<string, any>> = []
+  if (formFields) {
+    formFields.forEach((fieldStr: string) => {
+      parseFormFields(JSON.parse(fieldStr), result)
+    })
+  }
+  // 固定添加发起人 ID 字段
+  result.unshift({
+    field: ProcessVariableEnum.START_USER_ID,
+    title: '发起人',
+    type: 'UserSelect',
+    required: true
+  })
+  return result
+}
+
+/**
+ * @description 表单数据权限配置,用于发起人节点 、审批节点、抄送节点
+ */
+export function useFormFieldsPermission(defaultPermission: FieldPermissionType) {
+  // 字段权限配置. 需要有 field, title,  permissioin 属性
+  const fieldsPermissionConfig = ref<Array<Record<string, any>>>([])
+
+  const formType = inject<Ref<number>>('formType') // 表单类型
+
+  const formFields = inject<Ref<string[]>>('formFields') // 流程表单字段
+
+  const getNodeConfigFormFields = (nodeFormFields?: Array<Record<string, string>>) => {
+    nodeFormFields = toRaw(nodeFormFields)
+    fieldsPermissionConfig.value =
+      cloneDeep(nodeFormFields) || getDefaultFieldsPermission(unref(formFields))
+  }
+  // 默认的表单权限: 获取表单的所有字段,设置字段默认权限为只读
+  const getDefaultFieldsPermission = (formFields?: string[]) => {
+    let defaultFieldsPermission: Array<Record<string, any>> = []
+    if (formFields) {
+      defaultFieldsPermission = parseFormCreateFields(formFields).map((item) => {
+        return {
+          field: item.field,
+          title: item.title,
+          permission: defaultPermission
+        }
+      })
+    }
+    return defaultFieldsPermission
+  }
+
+  // 获取表单的所有字段,作为下拉框选项
+  const formFieldOptions = parseFormCreateFields(unref(formFields))
+
+  return {
+    formType,
+    fieldsPermissionConfig,
+    formFieldOptions,
+    getNodeConfigFormFields
+  }
+}
+/**
+ * @description 获取表单的字段
+ */
+export function useFormFields() {
+  const formFields = inject<Ref<string[]>>('formFields') // 流程表单字段
+  return parseFormCreateFields(unref(formFields))
+}
+
+export type UserTaskFormType = {
+  //candidateParamArray: any[]
+  candidateStrategy: CandidateStrategy
+  approveMethod: ApproveMethodType
+  roleIds?: number[] // 角色
+  deptIds?: number[] // 部门
+  deptLevel?: number // 部门层级
+  userIds?: number[] // 用户
+  userGroups?: number[] // 用户组
+  postIds?: number[] // 岗位
+  expression?: string // 流程表达式
+  formUser?: string // 表单内用户字段
+  formDept?: string // 表单内部门字段
+  approveRatio?: number
+  rejectHandlerType?: RejectHandlerType
+  returnNodeId?: string
+  timeoutHandlerEnable?: boolean
+  timeoutHandlerType?: number
+  assignEmptyHandlerType?: AssignEmptyHandlerType
+  assignEmptyHandlerUserIds?: number[]
+  assignStartUserHandlerType?: AssignStartUserHandlerType
+  timeDuration?: number
+  maxRemindCount?: number
+  buttonsSetting: any[]
+}
+
+export type CopyTaskFormType = {
+  // candidateParamArray: any[]
+  candidateStrategy: CandidateStrategy
+  roleIds?: number[] // 角色
+  deptIds?: number[] // 部门
+  deptLevel?: number // 部门层级
+  userIds?: number[] // 用户
+  userGroups?: number[] // 用户组
+  postIds?: number[] // 岗位
+  formUser?: string // 表单内用户字段
+  formDept?: string // 表单内部门字段
+  expression?: string // 流程表达式
+}
+
+/**
+ * @description 节点表单数据。 用于审批节点、抄送节点
+ */
+export function useNodeForm(nodeType: NodeType) {
+  const roleOptions = inject<Ref<RoleApi.RoleVO[]>>('roleList') // 角色列表
+  const postOptions = inject<Ref<PostApi.PostVO[]>>('postList') // 岗位列表
+  const userOptions = inject<Ref<UserApi.UserVO[]>>('userList') // 用户列表
+  const deptOptions = inject<Ref<DeptApi.DeptVO[]>>('deptList') // 部门列表
+  const userGroupOptions = inject<Ref<UserGroupApi.UserGroupVO[]>>('userGroupList') // 用户组列表
+  const deptTreeOptions = inject('deptTree') // 部门树
+  const formFields = inject<Ref<string[]>>('formFields') // 流程表单字段
+  const configForm = ref<UserTaskFormType | CopyTaskFormType>()
+  if (nodeType === NodeType.USER_TASK_NODE) {
+    configForm.value = {
+      candidateStrategy: CandidateStrategy.USER,
+      approveMethod: ApproveMethodType.SEQUENTIAL_APPROVE,
+      approveRatio: 100,
+      rejectHandlerType: RejectHandlerType.FINISH_PROCESS,
+      assignStartUserHandlerType: AssignStartUserHandlerType.START_USER_AUDIT,
+      returnNodeId: '',
+      timeoutHandlerEnable: false,
+      timeoutHandlerType: 1,
+      timeDuration: 6, // 默认 6小时
+      maxRemindCount: 1, // 默认 提醒 1次
+      buttonsSetting: []
+    }
+  } else {
+    configForm.value = {
+      candidateStrategy: CandidateStrategy.USER
+    }
+  }
+
+  const getShowText = (): string => {
+    let showText = ''
+    // 指定成员
+    if (configForm.value?.candidateStrategy === CandidateStrategy.USER) {
+      if (configForm.value?.userIds!.length > 0) {
+        const candidateNames: string[] = []
+        userOptions?.value.forEach((item) => {
+          if (configForm.value?.userIds!.includes(item.id)) {
+            candidateNames.push(item.nickname)
+          }
+        })
+        showText = `指定成员:${candidateNames.join(',')}`
+      }
+    }
+    // 指定角色
+    if (configForm.value?.candidateStrategy === CandidateStrategy.ROLE) {
+      if (configForm.value.roleIds!.length > 0) {
+        const candidateNames: string[] = []
+        roleOptions?.value.forEach((item) => {
+          if (configForm.value?.roleIds!.includes(item.id)) {
+            candidateNames.push(item.name)
+          }
+        })
+        showText = `指定角色:${candidateNames.join(',')}`
+      }
+    }
+    // 指定部门
+    if (
+      configForm.value?.candidateStrategy === CandidateStrategy.DEPT_MEMBER ||
+      configForm.value?.candidateStrategy === CandidateStrategy.DEPT_LEADER ||
+      configForm.value?.candidateStrategy === CandidateStrategy.MULTI_LEVEL_DEPT_LEADER
+    ) {
+      if (configForm.value?.deptIds!.length > 0) {
+        const candidateNames: string[] = []
+        deptOptions?.value.forEach((item) => {
+          if (configForm.value?.deptIds!.includes(item.id!)) {
+            candidateNames.push(item.name)
+          }
+        })
+        if (configForm.value.candidateStrategy === CandidateStrategy.DEPT_MEMBER) {
+          showText = `部门成员:${candidateNames.join(',')}`
+        } else if (configForm.value.candidateStrategy === CandidateStrategy.DEPT_LEADER) {
+          showText = `部门的负责人:${candidateNames.join(',')}`
+        } else {
+          showText = `多级部门的负责人:${candidateNames.join(',')}`
+        }
+      }
+    }
+
+    // 指定岗位
+    if (configForm.value?.candidateStrategy === CandidateStrategy.POST) {
+      if (configForm.value.postIds!.length > 0) {
+        const candidateNames: string[] = []
+        postOptions?.value.forEach((item) => {
+          if (configForm.value?.postIds!.includes(item.id!)) {
+            candidateNames.push(item.name)
+          }
+        })
+        showText = `指定岗位: ${candidateNames.join(',')}`
+      }
+    }
+    // 指定用户组
+    if (configForm.value?.candidateStrategy === CandidateStrategy.USER_GROUP) {
+      if (configForm.value?.userGroups!.length > 0) {
+        const candidateNames: string[] = []
+        userGroupOptions?.value.forEach((item) => {
+          if (configForm.value?.userGroups!.includes(item.id)) {
+            candidateNames.push(item.name)
+          }
+        })
+        showText = `指定用户组: ${candidateNames.join(',')}`
+      }
+    }
+
+    // 表单内用户字段
+    if (configForm.value?.candidateStrategy === CandidateStrategy.FORM_USER) {
+      const formFieldOptions = parseFormCreateFields(unref(formFields))
+      const item = formFieldOptions.find((item) => item.field === configForm.value?.formUser)
+      showText = `表单用户:${item?.title}`
+    }
+
+    // 表单内部门负责人
+    if (configForm.value?.candidateStrategy === CandidateStrategy.FORM_DEPT_LEADER) {
+      showText = `表单内部门负责人`
+    }
+
+    // 发起人自选
+    if (configForm.value?.candidateStrategy === CandidateStrategy.START_USER_SELECT) {
+      showText = `发起人自选`
+    }
+    // 发起人自己
+    if (configForm.value?.candidateStrategy === CandidateStrategy.START_USER) {
+      showText = `发起人自己`
+    }
+    // 发起人的部门负责人
+    if (configForm.value?.candidateStrategy === CandidateStrategy.START_USER_DEPT_LEADER) {
+      showText = `发起人的部门负责人`
+    }
+    // 发起人的部门负责人
+    if (
+      configForm.value?.candidateStrategy === CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER
+    ) {
+      showText = `发起人连续部门负责人`
+    }
+    // 流程表达式
+    if (configForm.value?.candidateStrategy === CandidateStrategy.EXPRESSION) {
+      showText = `流程表达式:${configForm.value.expression}`
+    }
+    return showText
+  }
+
+  /**
+   *  处理候选人参数的赋值
+   */
+  const handleCandidateParam = () => {
+    let candidateParam: undefined | string = undefined
+    if (!configForm.value) {
+      return candidateParam
+    }
+    switch (configForm.value.candidateStrategy) {
+      case CandidateStrategy.USER:
+        candidateParam = configForm.value.userIds!.join(',')
+        break
+      case CandidateStrategy.ROLE:
+        candidateParam = configForm.value.roleIds!.join(',')
+        break
+      case CandidateStrategy.POST:
+        candidateParam = configForm.value.postIds!.join(',')
+        break
+      case CandidateStrategy.USER_GROUP:
+        candidateParam = configForm.value.userGroups!.join(',')
+        break
+      case CandidateStrategy.FORM_USER:
+        candidateParam = configForm.value.formUser!
+        break
+      case CandidateStrategy.EXPRESSION:
+        candidateParam = configForm.value.expression!
+        break
+      case CandidateStrategy.DEPT_MEMBER:
+      case CandidateStrategy.DEPT_LEADER:
+        candidateParam = configForm.value.deptIds!.join(',')
+        break
+      // 发起人部门负责人
+      case CandidateStrategy.START_USER_DEPT_LEADER:
+      case CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER:
+        candidateParam = configForm.value.deptLevel + ''
+        break
+      // 指定连续多级部门的负责人
+      case CandidateStrategy.MULTI_LEVEL_DEPT_LEADER: {
+        // 候选人参数格式: | 分隔 。左边为部门(多个部门用 , 分隔)。 右边为部门层级
+        const deptIds = configForm.value.deptIds!.join(',')
+        candidateParam = deptIds.concat('|' + configForm.value.deptLevel + '')
+        break
+      }
+      // 表单内部门的负责人
+      case CandidateStrategy.FORM_DEPT_LEADER: {
+        // 候选人参数格式: | 分隔 。左边为表单内部门字段。 右边为部门层级
+        const deptFieldOnForm = configForm.value.formDept!
+        candidateParam = deptFieldOnForm.concat('|' + configForm.value.deptLevel + '')
+        break
+      }
+      default:
+        break
+    }
+    return candidateParam
+  }
+  /**
+   *  解析候选人参数
+   */
+  const parseCandidateParam = (
+    candidateStrategy: CandidateStrategy,
+    candidateParam: string | undefined
+  ) => {
+    if (!configForm.value || !candidateParam) {
+      return
+    }
+    switch (candidateStrategy) {
+      case CandidateStrategy.USER: {
+        configForm.value.userIds = candidateParam.split(',').map((item) => +item)
+        break
+      }
+      case CandidateStrategy.ROLE:
+        configForm.value.roleIds = candidateParam.split(',').map((item) => +item)
+        break
+      case CandidateStrategy.POST:
+        configForm.value.postIds = candidateParam.split(',').map((item) => +item)
+        break
+      case CandidateStrategy.USER_GROUP:
+        configForm.value.userGroups = candidateParam.split(',').map((item) => +item)
+        break
+      case CandidateStrategy.FORM_USER:
+        configForm.value.formUser = candidateParam
+        break
+      case CandidateStrategy.EXPRESSION:
+        configForm.value.expression = candidateParam
+        break
+      case CandidateStrategy.DEPT_MEMBER:
+      case CandidateStrategy.DEPT_LEADER:
+        configForm.value.deptIds = candidateParam.split(',').map((item) => +item)
+        break
+      // 发起人部门负责人
+      case CandidateStrategy.START_USER_DEPT_LEADER:
+      case CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER:
+        configForm.value.deptLevel = +candidateParam
+        break
+      // 指定连续多级部门的负责人
+      case CandidateStrategy.MULTI_LEVEL_DEPT_LEADER: {
+        // 候选人参数格式: | 分隔 。左边为部门(多个部门用 , 分隔)。 右边为部门层级
+        const paramArray = candidateParam.split('|')
+        configForm.value.deptIds = paramArray[0].split(',').map((item) => +item)
+        configForm.value.deptLevel = +paramArray[1]
+        break
+      }
+      // 表单内的部门负责人
+      case CandidateStrategy.FORM_DEPT_LEADER: {
+        // 候选人参数格式: | 分隔 。左边为表单内的部门字段。 右边为部门层级
+        const paramArray = candidateParam.split('|')
+        configForm.value.formDept = paramArray[0]
+        configForm.value.deptLevel = +paramArray[1]
+        break
+      }
+      default:
+        break
+    }
+  }
+  return {
+    configForm,
+    roleOptions,
+    postOptions,
+    userOptions,
+    userGroupOptions,
+    deptTreeOptions,
+    handleCandidateParam,
+    parseCandidateParam,
+    getShowText
+  }
+}
+
+/**
+ * @description 抽屉配置
+ */
+export function useDrawer() {
+  // 抽屉配置是否可见
+  const settingVisible = ref(false)
+  // 关闭配置抽屉
+  const closeDrawer = () => {
+    settingVisible.value = false
+  }
+  // 打开配置抽屉
+  const openDrawer = () => {
+    settingVisible.value = true
+  }
+  return {
+    settingVisible,
+    closeDrawer,
+    openDrawer
+  }
+}
+
+/**
+ * @description 节点名称配置
+ */
+export function useNodeName(nodeType: NodeType) {
+  // 节点名称
+  const nodeName = ref<string>()
+  // 节点名称输入框
+  const showInput = ref(false)
+  // 点击节点名称编辑图标
+  const clickIcon = () => {
+    showInput.value = true
+  }
+  // 节点名称输入框失去焦点
+  const blurEvent = () => {
+    showInput.value = false
+    nodeName.value = nodeName.value || (NODE_DEFAULT_NAME.get(nodeType) as string)
+  }
+  return {
+    nodeName,
+    showInput,
+    clickIcon,
+    blurEvent
+  }
+}
+
+export function useNodeName2(node: Ref<SimpleFlowNode>, nodeType: NodeType) {
+  // 显示节点名称输入框
+  const showInput = ref(false)
+  // 节点名称输入框失去焦点
+  const blurEvent = () => {
+    showInput.value = false
+    node.value.name = node.value.name || (NODE_DEFAULT_NAME.get(nodeType) as string)
+  }
+  // 点击节点标题进行输入
+  const clickTitle = () => {
+    showInput.value = true
+  }
+  return {
+    showInput,
+    clickTitle,
+    blurEvent
+  }
+}
+
+/**
+ * @description 根据节点任务状态,获取节点任务状态样式
+ */
+export function useTaskStatusClass(taskStatus: TaskStatusEnum | undefined): string {
+  if (!taskStatus) {
+    return ''
+  }
+  if (taskStatus === TaskStatusEnum.APPROVE) {
+    return 'status-pass'
+  }
+  if (taskStatus === TaskStatusEnum.RUNNING) {
+    return 'status-running'
+  }
+  if (taskStatus === TaskStatusEnum.REJECT) {
+    return 'status-reject'
+  }
+  if (taskStatus === TaskStatusEnum.CANCEL) {
+    return 'status-cancel'
+  }
+
+  return ''
+}

+ 419 - 0
src/components/SimpleProcessDesignerV2/src/nodes-config/ConditionNodeConfig.vue

@@ -0,0 +1,419 @@
+<template>
+  <el-drawer
+    :append-to-body="true"
+    v-model="settingVisible"
+    :show-close="false"
+    :size="588"
+    :before-close="handleClose"
+  >
+    <template #header>
+      <div class="config-header">
+        <input
+          v-if="showInput"
+          type="text"
+          class="config-editable-input"
+          @blur="blurEvent()"
+          v-mountedFocus
+          v-model="currentNode.name"
+          :placeholder="currentNode.name"
+        />
+        <div v-else class="node-name"
+          >{{ currentNode.name }}
+          <Icon class="ml-1" icon="ep:edit-pen" :size="16" @click="clickIcon()"
+        /></div>
+
+        <div class="divide-line"></div>
+      </div>
+    </template>
+    <div>
+      <div class="mb-3 font-size-16px" v-if="currentNode.defaultFlow">未满足其它条件时,将进入此分支(该分支不可编辑和删除)</div>
+      <div v-else>
+        <el-form
+          ref="formRef"
+          :model="currentNode"
+          :rules="formRules"
+          label-position="top"
+        >
+          <el-form-item label="配置方式" prop="conditionType">
+            <el-radio-group
+              v-model="currentNode.conditionType"
+              @change="changeConditionType"
+            >
+              <el-radio
+                v-for="(dict, index) in conditionConfigTypes"
+                :key="index"
+                :value="dict.value"
+                :label="dict.value"
+              >
+                {{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+
+          <el-form-item
+            v-if="currentNode.conditionType === 1"
+            label="条件表达式"
+            prop="conditionExpression"
+          >
+            <el-input
+              type="textarea"
+              v-model="currentNode.conditionExpression"
+              clearable
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item v-if="currentNode.conditionType === 2" label="条件规则">
+            <div class="condition-group-tool">
+              <div class="flex items-center">
+                <div class="mr-4">条件组关系</div>
+                <el-switch
+                  v-model="conditionGroups.and"
+                  inline-prompt
+                  active-text="且"
+                  inactive-text="或"
+                />
+              </div>
+            </div>
+            <el-space direction="vertical" :spacer="conditionGroups.and ? '且' : '或'">
+              <el-card
+                class="condition-group"
+                style="width: 530px"
+                v-for="(condition, cIdx) in conditionGroups.conditions"
+                :key="cIdx"
+              >
+                <div class="condition-group-delete" v-if="conditionGroups.conditions.length > 1">
+                  <Icon
+                    color="#0089ff"
+                    icon="ep:circle-close-filled"
+                    :size="18"
+                    @click="deleteConditionGroup(cIdx)"
+                  />
+                </div>
+                <template #header>
+                  <div class="flex items-center justify-between">
+                    <div>条件组</div>
+                    <div class="flex">
+                      <div class="mr-4">规则关系</div>
+                      <el-switch
+                        v-model="condition.and"
+                        inline-prompt
+                        active-text="且"
+                        inactive-text="或"
+                      />
+                    </div>
+                  </div>
+                </template>
+
+                <div class="flex pt-2" v-for="(rule, rIdx) in condition.rules" :key="rIdx">
+                  <div class="mr-2">
+                    <el-select style="width: 160px" v-model="rule.leftSide">
+                      <el-option
+                        v-for="(item, index) in fieldsInfo"
+                        :key="index"
+                        :label="item.title"
+                        :value="item.field"
+                      />
+                    </el-select>
+                  </div>
+                  <div class="mr-2">
+                    <el-select v-model="rule.opCode" style="width: 100px">
+                      <el-option
+                        v-for="item in COMPARISON_OPERATORS"
+                        :key="item.value"
+                        :label="item.label"
+                        :value="item.value"
+                      />
+                    </el-select>
+                  </div>
+                  <div class="mr-2">
+                    <el-input v-model="rule.rightSide" style="width: 160px" />
+                  </div>
+                  <div class="mr-1 flex items-center" v-if="condition.rules.length > 1">
+                    <Icon
+                      icon="ep:delete"
+                      :size="18"
+                      @click="deleteConditionRule(condition, rIdx)"
+                    />
+                  </div>
+                  <div class="flex items-center">
+                    <Icon icon="ep:plus" :size="18" @click="addConditionRule(condition, rIdx)" />
+                  </div>
+                </div>
+              </el-card>
+            </el-space>
+            <div title="添加条件组" class="mt-4 cursor-pointer">
+              <Icon color="#0089ff" icon="ep:plus" :size="24" @click="addConditionGroup" />
+            </div>
+          </el-form-item>
+        </el-form>
+      </div>
+    </div>
+    <template #footer>
+      <el-divider />
+      <div>
+        <el-button type="primary" @click="saveConfig">确 定</el-button>
+        <el-button @click="closeDrawer">取 消</el-button>
+      </div>
+    </template>
+  </el-drawer>
+</template>
+<script setup lang="ts">
+import {
+  SimpleFlowNode,
+  CONDITION_CONFIG_TYPES,
+  ConditionType,
+  COMPARISON_OPERATORS,
+  ConditionGroup,
+  Condition,
+  ConditionRule
+} from '../consts'
+import { getDefaultConditionNodeName } from '../utils'
+import { useFormFields } from '../node'
+const message = useMessage() // 消息弹窗
+defineOptions({
+  name: 'ConditionNodeConfig'
+})
+const formType = inject<Ref<number>>('formType') // 表单类型
+const conditionConfigTypes = computed(() => {
+  return CONDITION_CONFIG_TYPES.filter((item) => {
+    // 业务表单暂时去掉条件规则选项
+    if (formType?.value !== 10) {
+      return item.value === ConditionType.RULE
+    } else {
+      return true
+    }
+  })
+})
+
+const props = defineProps({
+  conditionNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  },
+  nodeIndex: {
+    type: Number,
+    required: true
+  }
+})
+const settingVisible = ref(false)
+const open = () => {
+  if (currentNode.value.conditionType === ConditionType.RULE) {
+    if (currentNode.value.conditionGroups) {
+      conditionGroups.value = currentNode.value.conditionGroups
+    }
+  }
+  settingVisible.value = true
+}
+
+watch(
+  () => props.conditionNode,
+  (newValue) => {
+    currentNode.value = newValue
+  }
+)
+// 显示名称输入框
+const showInput = ref(false)
+
+const clickIcon = () => {
+  showInput.value = true
+}
+// 输入框失去焦点
+const blurEvent = () => {
+  showInput.value = false
+  currentNode.value.name =
+    currentNode.value.name ||
+    getDefaultConditionNodeName(props.nodeIndex, currentNode.value?.defaultFlow)
+}
+
+const currentNode = ref<SimpleFlowNode>(props.conditionNode)
+
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+// 关闭
+const closeDrawer = () => {
+  settingVisible.value = false
+}
+
+const handleClose = async (done: (cancel?: boolean) => void) => {
+  const isSuccess = await saveConfig()
+  if (!isSuccess) {
+    done(true) // 传入 true 阻止关闭
+  } else {
+    done()
+  }
+}
+// 表单校验规则
+const formRules = reactive({
+  conditionType: [{ required: true, message: '配置方式不能为空', trigger: 'blur' }],
+  conditionExpression: [{ required: true, message: '条件表达式不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+// 保存配置
+const saveConfig = async () => {
+  if (!currentNode.value.defaultFlow) {
+    // 校验表单
+    if (!formRef) return false
+    const valid = await formRef.value.validate()
+    if (!valid) return false
+    const showText = getShowText()
+    if (!showText) {
+      return false
+    }
+    currentNode.value.showText = showText
+    if (currentNode.value.conditionType === ConditionType.EXPRESSION) {
+      currentNode.value.conditionGroups = undefined
+    }
+    if (currentNode.value.conditionType === ConditionType.RULE) {
+      currentNode.value.conditionExpression = undefined
+      currentNode.value.conditionGroups = conditionGroups.value
+    }
+  }
+  settingVisible.value = false
+  return true
+}
+const getShowText = (): string => {
+  let showText = ''
+  if (currentNode.value.conditionType === ConditionType.EXPRESSION) {
+    if (currentNode.value.conditionExpression) {
+      showText = `表达式:${currentNode.value.conditionExpression}`
+    }
+  }
+  if (currentNode.value.conditionType === ConditionType.RULE) {
+    // 条件组是否为与关系
+    const groupAnd = conditionGroups.value.and
+    let warningMesg: undefined | string = undefined
+    const conditionGroup = conditionGroups.value.conditions.map((item) => {
+      return (
+        '(' +
+        item.rules
+          .map((rule) => {
+            if (rule.leftSide && rule.rightSide) {
+              return (
+                getFieldTitle(rule.leftSide) + ' ' + getOpName(rule.opCode) + ' ' + rule.rightSide
+              )
+            } else {
+              // 有一条规则不完善。提示错误
+              warningMesg = '请完善条件规则'
+              return ''
+            }
+          })
+          .join(item.and ? ' 且 ' : ' 或 ') +
+        ' ) '
+      )
+    })
+    if (warningMesg) {
+      message.warning(warningMesg)
+      showText = ''
+    } else {
+      showText = conditionGroup.join(groupAnd ? ' 且 ' : ' 或 ')
+    }
+  }
+  return showText
+}
+
+// 改变条件配置方式
+const changeConditionType = () => {}
+
+const conditionGroups = ref<ConditionGroup>({
+  and: true,
+  conditions: [
+    {
+      and: true,
+      rules: [
+        {
+          type: 1,
+          opName: '等于',
+          opCode: '==',
+          leftSide: '',
+          rightSide: ''
+        }
+      ]
+    }
+  ]
+})
+// 添加条件组
+const addConditionGroup = () => {
+  const condition = {
+    and: true,
+    rules: [
+      {
+        type: 1,
+        opName: '等于',
+        opCode: '==',
+        leftSide: '',
+        rightSide: ''
+      }
+    ]
+  }
+  conditionGroups.value.conditions.push(condition)
+}
+// 删除条件组
+const deleteConditionGroup = (idx: number) => {
+  conditionGroups.value.conditions.splice(idx, 1)
+}
+
+// 添加条件规则
+const addConditionRule = (condition: Condition, idx: number) => {
+  const rule: ConditionRule = {
+    type: 1,
+    opName: '等于',
+    opCode: '==',
+    leftSide: '',
+    rightSide: ''
+  }
+  condition.rules.splice(idx + 1, 0, rule)
+}
+
+const deleteConditionRule = (condition: Condition, idx: number) => {
+  condition.rules.splice(idx, 1)
+}
+
+const fieldsInfo = useFormFields()
+
+const getFieldTitle = (field: string) => {
+  const item = fieldsInfo.find((item) => item.field === field)
+  return item?.title
+}
+
+const getOpName = (opCode: string): string => {
+  const opName = COMPARISON_OPERATORS.find((item) => item.value === opCode)
+  return opName?.label
+}
+</script>
+
+<style lang="scss" scoped>
+.condition-group-tool {
+  display: flex;
+  justify-content: space-between;
+  width: 500px;
+  margin-bottom: 20px;
+}
+
+.condition-group {
+  position: relative;
+
+  &:hover {
+    border-color: #0089ff;
+
+    .condition-group-delete {
+      opacity: 1;
+    }
+  }
+
+  .condition-group-delete {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: flex;
+    cursor: pointer;
+    opacity: 0;
+  }
+}
+
+::v-deep(.el-card__header) {
+  padding: 8px var(--el-card-padding);
+  border-bottom: 1px solid var(--el-card-border-color);
+  box-sizing: border-box;
+}
+</style>

+ 374 - 0
src/components/SimpleProcessDesignerV2/src/nodes-config/CopyTaskNodeConfig.vue

@@ -0,0 +1,374 @@
+<template>
+  <el-drawer
+    :append-to-body="true"
+    v-model="settingVisible"
+    :show-close="false"
+    :size="550"
+    :before-close="saveConfig"
+  >
+    <template #header>
+      <div class="config-header">
+        <input
+          v-if="showInput"
+          type="text"
+          class="config-editable-input"
+          @blur="blurEvent()"
+          v-mountedFocus
+          v-model="nodeName"
+          :placeholder="nodeName"
+        />
+        <div v-else class="node-name">
+          {{ nodeName }} <Icon class="ml-1" icon="ep:edit-pen" :size="16" @click="clickIcon()" />
+        </div>
+        <div class="divide-line"></div>
+      </div>
+    </template>
+    <el-tabs type="border-card" v-model="activeTabName">
+      <el-tab-pane label="抄送人" name="user">
+        <div>
+          <el-form ref="formRef" :model="configForm" label-position="top" :rules="formRules">
+            <el-form-item label="抄送人设置" prop="candidateStrategy">
+              <el-radio-group
+                v-model="configForm.candidateStrategy"
+                @change="changeCandidateStrategy"
+              >
+                <el-radio
+                  v-for="(dict, index) in copyUserStrategies"
+                  :key="index"
+                  :value="dict.value"
+                  :label="dict.value"
+                >
+                  {{ dict.label }}
+                </el-radio>
+              </el-radio-group>
+            </el-form-item>
+
+            <el-form-item
+              v-if="configForm.candidateStrategy == CandidateStrategy.ROLE"
+              label="指定角色"
+              prop="roleIds"
+            >
+              <el-select v-model="configForm.roleIds" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in roleOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="
+                configForm.candidateStrategy == CandidateStrategy.DEPT_MEMBER ||
+                configForm.candidateStrategy == CandidateStrategy.DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.MULTI_LEVEL_DEPT_LEADER
+              "
+              label="指定部门"
+              prop="deptIds"
+              span="24"
+            >
+              <el-tree-select
+                ref="treeRef"
+                v-model="configForm.deptIds"
+                :data="deptTreeOptions"
+                :props="defaultProps"
+                empty-text="加载中,请稍后"
+                multiple
+                node-key="id"
+                style="width: 100%"
+                show-checkbox
+              />
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy == CandidateStrategy.POST"
+              label="指定岗位"
+              prop="postIds"
+              span="24"
+            >
+              <el-select v-model="configForm.postIds" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in postOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id!"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy == CandidateStrategy.USER"
+              label="指定用户"
+              prop="userIds"
+              span="24"
+            >
+              <el-select v-model="configForm.userIds" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in userOptions"
+                  :key="item.id"
+                  :label="item.nickname"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.USER_GROUP"
+              label="指定用户组"
+              prop="userGroups"
+            >
+              <el-select v-model="configForm.userGroups" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in userGroupOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.FORM_USER"
+              label="表单内用户字段"
+              prop="formUser"
+            >
+              <el-select v-model="configForm.formUser" clearable style="width: 100%">
+                <el-option
+                  v-for="(item, idx) in userFieldOnFormOptions"
+                  :key="idx"
+                  :label="item.title"
+                  :value="item.field"
+                  :disabled ="!item.required"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.FORM_DEPT_LEADER"
+              label="表单内部门字段"
+              prop="formDept"
+            >
+              <el-select v-model="configForm.formDept" clearable style="width: 100%">
+                <el-option
+                  v-for="(item, idx) in deptFieldOnFormOptions"
+                  :key="idx"
+                  :label="item.title"
+                  :value="item.field"
+                  :disabled ="!item.required"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="
+                configForm.candidateStrategy == CandidateStrategy.MULTI_LEVEL_DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.START_USER_DEPT_LEADER ||
+                configForm.candidateStrategy ==
+                  CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.FORM_DEPT_LEADER
+              "
+              :label="deptLevelLabel!"
+              prop="deptLevel"
+              span="24"
+            >
+              <el-select v-model="configForm.deptLevel" clearable>
+                <el-option
+                  v-for="(item, index) in MULTI_LEVEL_DEPT"
+                  :key="index"
+                  :label="item.label"
+                  :value="item.value"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.EXPRESSION"
+              label="流程表达式"
+              prop="expression"
+            >
+              <el-input
+                type="textarea"
+                v-model="configForm.expression"
+                clearable
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-tab-pane>
+      <el-tab-pane label="表单字段权限" name="fields" v-if="formType === 10">
+        <div class="field-setting-pane">
+          <div class="field-setting-desc">字段权限</div>
+          <div class="field-permit-title">
+            <div class="setting-title-label first-title"> 字段名称 </div>
+            <div class="other-titles">
+              <span class="setting-title-label">只读</span>
+              <span class="setting-title-label">可编辑</span>
+              <span class="setting-title-label">隐藏</span>
+            </div>
+          </div>
+          <div
+            class="field-setting-item"
+            v-for="(item, index) in fieldsPermissionConfig"
+            :key="index"
+          >
+            <div class="field-setting-item-label"> {{ item.title }} </div>
+            <el-radio-group class="field-setting-item-group" v-model="item.permission">
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.READ"
+                  size="large"
+                  :label="FieldPermissionType.WRITE"
+                  ><span></span
+                ></el-radio>
+              </div>
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.WRITE"
+                  size="large"
+                  :label="FieldPermissionType.WRITE"
+                  disabled
+                  ><span></span
+                ></el-radio>
+              </div>
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.NONE"
+                  size="large"
+                  :label="FieldPermissionType.NONE"
+                  ><span></span
+                ></el-radio>
+              </div>
+            </el-radio-group>
+          </div>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+    <template #footer>
+      <el-divider />
+      <div>
+        <el-button type="primary" @click="saveConfig">确 定</el-button>
+        <el-button @click="closeDrawer">取 消</el-button>
+      </div>
+    </template>
+  </el-drawer>
+</template>
+<script setup lang="ts">
+import {
+  SimpleFlowNode,
+  CandidateStrategy,
+  NodeType,
+  CANDIDATE_STRATEGY,
+  FieldPermissionType,
+  MULTI_LEVEL_DEPT
+} from '../consts'
+import {
+  useWatchNode,
+  useDrawer,
+  useNodeName,
+  useFormFieldsPermission,
+  useNodeForm,
+  CopyTaskFormType
+} from '../node'
+import { defaultProps } from '@/utils/tree'
+defineOptions({
+  name: 'CopyTaskNodeConfig'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+const deptLevelLabel = computed(() => {
+  let label = '部门负责人来源'
+  if (configForm.value.candidateStrategy == CandidateStrategy.MULTI_LEVEL_DEPT_LEADER) {
+    label = label + '(指定部门向上)'
+  } else {
+    label = label + '(发起人部门向上)'
+  }
+  return label
+})
+// 抽屉配置
+const { settingVisible, closeDrawer, openDrawer } = useDrawer()
+// 当前节点
+const currentNode = useWatchNode(props)
+// 节点名称
+const { nodeName, showInput, clickIcon, blurEvent } = useNodeName(NodeType.COPY_TASK_NODE)
+// 激活的 Tab 标签页
+const activeTabName = ref('user')
+// 表单字段权限配置
+const { formType, fieldsPermissionConfig, formFieldOptions, getNodeConfigFormFields } =
+  useFormFieldsPermission(FieldPermissionType.READ)
+// 表单内用户字段选项, 必须是必填和用户选择器
+const userFieldOnFormOptions = computed(() => {
+  return formFieldOptions.filter((item) => item.type === 'UserSelect')
+})
+// 表单内部门字段选项, 必须是必填和部门选择器
+const deptFieldOnFormOptions = computed(() => {
+  return formFieldOptions.filter((item) => item.type === 'DeptSelect')
+})
+// 抄送人表单配置
+const formRef = ref() // 表单 Ref
+// 表单校验规则
+const formRules = reactive({
+  candidateStrategy: [{ required: true, message: '抄送人设置不能为空', trigger: 'change' }],
+  userIds: [{ required: true, message: '用户不能为空', trigger: 'change' }],
+  roleIds: [{ required: true, message: '角色不能为空', trigger: 'change' }],
+  deptIds: [{ required: true, message: '部门不能为空', trigger: 'change' }],
+  userGroups: [{ required: true, message: '用户组不能为空', trigger: 'change' }],
+  postIds: [{ required: true, message: '岗位不能为空', trigger: 'change' }],
+  formUser: [{ required: true, message: '表单内用户字段不能为空', trigger: 'change' }],
+  formDept: [{ required: true, message: '表单内部门字段不能为空', trigger: 'change' }],
+  expression: [{ required: true, message: '流程表达式不能为空', trigger: 'blur' }]
+})
+
+const {
+  configForm: tempConfigForm,
+  roleOptions,
+  postOptions,
+  userOptions,
+  userGroupOptions,
+  deptTreeOptions,
+  getShowText,
+  handleCandidateParam,
+  parseCandidateParam
+} = useNodeForm(NodeType.COPY_TASK_NODE)
+const configForm = tempConfigForm as Ref<CopyTaskFormType>
+// 抄送人策略, 去掉发起人自选 和 发起人自己
+const copyUserStrategies = computed(() => {
+  return CANDIDATE_STRATEGY.filter((item) => item.value !== CandidateStrategy.START_USER)
+})
+// 改变抄送人设置策略
+const changeCandidateStrategy = () => {
+  configForm.value.userIds = []
+  configForm.value.deptIds = []
+  configForm.value.roleIds = []
+  configForm.value.postIds = []
+  configForm.value.userGroups = []
+  configForm.value.deptLevel = 1
+  configForm.value.formUser = ''
+}
+// 保存配置
+const saveConfig = async () => {
+  activeTabName.value = 'user'
+  if (!formRef) return false
+  const valid = await formRef.value.validate()
+  if (!valid) return false
+  const showText = getShowText()
+  if (!showText) return false
+  currentNode.value.name = nodeName.value!
+  currentNode.value.candidateParam = handleCandidateParam()
+  currentNode.value.candidateStrategy = configForm.value.candidateStrategy
+  currentNode.value.showText = showText
+  currentNode.value.fieldsPermission = fieldsPermissionConfig.value
+  settingVisible.value = false
+  return true
+}
+// 显示抄送节点配置, 由父组件传过来
+const showCopyTaskNodeConfig = (node: SimpleFlowNode) => {
+  nodeName.value = node.name
+  // 抄送人设置
+  configForm.value.candidateStrategy = node.candidateStrategy!
+  parseCandidateParam(node.candidateStrategy!, node?.candidateParam)
+  // 表单字段权限
+  getNodeConfigFormFields(node.fieldsPermission)
+}
+
+defineExpose({ openDrawer, showCopyTaskNodeConfig }) // 暴露方法给父组件
+</script>
+
+<style lang="scss" scoped></style>

+ 135 - 0
src/components/SimpleProcessDesignerV2/src/nodes-config/StartUserNodeConfig.vue

@@ -0,0 +1,135 @@
+<template>
+  <el-drawer
+    :append-to-body="true"
+    v-model="settingVisible"
+    :show-close="false"
+    :size="550"
+    :before-close="saveConfig"
+  >
+    <template #header>
+      <div class="config-header">
+        <input
+          v-if="showInput"
+          type="text"
+          class="config-editable-input"
+          @blur="blurEvent()"
+          v-mountedFocus
+          v-model="nodeName"
+          :placeholder="nodeName"
+        />
+        <div v-else class="node-name">
+          {{ nodeName }} <Icon class="ml-1" icon="ep:edit-pen" :size="16" @click="clickIcon()" />
+        </div>
+        <div class="divide-line"></div>
+      </div>
+    </template>
+    <el-tabs type="border-card" v-model="activeTabName">
+      <el-tab-pane label="权限" name="user">
+        <div> 待实现 </div>
+      </el-tab-pane>
+      <el-tab-pane label="表单字段权限" name="fields" v-if="formType === 10">
+        <div class="field-setting-pane">
+          <div class="field-setting-desc">字段权限</div>
+          <div class="field-permit-title">
+            <div class="setting-title-label first-title"> 字段名称 </div>
+            <div class="other-titles">
+              <span class="setting-title-label">只读</span>
+              <span class="setting-title-label">可编辑</span>
+              <span class="setting-title-label">隐藏</span>
+            </div>
+          </div>
+          <div
+            class="field-setting-item"
+            v-for="(item, index) in fieldsPermissionConfig"
+            :key="index"
+          >
+            <div class="field-setting-item-label"> {{ item.title }} </div>
+            <el-radio-group class="field-setting-item-group" v-model="item.permission">
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.READ"
+                  size="large"
+                  :label="FieldPermissionType.READ"
+                  ><span></span
+                ></el-radio>
+              </div>
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.WRITE"
+                  size="large"
+                  :label="FieldPermissionType.WRITE"
+                  ><span></span
+                ></el-radio>
+              </div>
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.NONE"
+                  size="large"
+                  :label="FieldPermissionType.NONE"
+                  ><span></span
+                ></el-radio>
+              </div>
+            </el-radio-group>
+          </div>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+    <template #footer>
+      <el-divider />
+      <div>
+        <el-button type="primary" @click="saveConfig">确 定</el-button>
+        <el-button @click="closeDrawer">取 消</el-button>
+      </div>
+    </template>
+  </el-drawer>
+</template>
+<script setup lang="ts">
+import { SimpleFlowNode, NodeType, FieldPermissionType, START_USER_BUTTON_SETTING } from '../consts'
+import { useWatchNode, useDrawer, useNodeName, useFormFieldsPermission } from '../node'
+
+defineOptions({
+  name: 'StartUserNodeConfig'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+// 抽屉配置
+const { settingVisible, closeDrawer, openDrawer } = useDrawer()
+// 当前节点
+const currentNode = useWatchNode(props)
+// 节点名称
+const { nodeName, showInput, clickIcon, blurEvent } = useNodeName(NodeType.COPY_TASK_NODE)
+// 激活的 Tab 标签页
+const activeTabName = ref('user')
+// 表单字段权限配置
+const { formType, fieldsPermissionConfig, getNodeConfigFormFields } = useFormFieldsPermission(
+  FieldPermissionType.WRITE
+)
+
+// 保存配置
+const saveConfig = async () => {
+  activeTabName.value = 'user'
+  currentNode.value.name = nodeName.value!
+  // TODO 暂时写死。后续可以显示谁有权限可以发起
+  currentNode.value.showText = '已设置'
+  // 设置表单权限
+  currentNode.value.fieldsPermission = fieldsPermissionConfig.value
+  // 设置发起人的按钮权限
+  currentNode.value.buttonsSetting = START_USER_BUTTON_SETTING
+  settingVisible.value = false
+  return true
+}
+// 显示发起人节点配置, 由父组件传过来
+const showStartUserNodeConfig = (node: SimpleFlowNode) => {
+  nodeName.value = node.name
+  // 表单字段权限
+  getNodeConfigFormFields(node.fieldsPermission)
+}
+
+defineExpose({ openDrawer, showStartUserNodeConfig }) // 暴露方法给父组件
+</script>
+
+<style lang="scss" scoped></style>

+ 905 - 0
src/components/SimpleProcessDesignerV2/src/nodes-config/UserTaskNodeConfig.vue

@@ -0,0 +1,905 @@
+<template>
+  <el-drawer
+    :append-to-body="true"
+    v-model="settingVisible"
+    :show-close="false"
+    :size="550"
+    :before-close="saveConfig"
+    class="justify-start"
+  >
+    <template #header>
+      <div class="config-header">
+        <input
+          v-if="showInput"
+          type="text"
+          class="config-editable-input"
+          @blur="blurEvent()"
+          v-mountedFocus
+          v-model="nodeName"
+          :placeholder="nodeName"
+        />
+        <div v-else class="node-name">
+          {{ nodeName }} <Icon class="ml-1" icon="ep:edit-pen" :size="16" @click="clickIcon()" />
+        </div>
+        <div class="divide-line"></div>
+      </div>
+    </template>
+    <div class="flex flex-items-center mb-3">
+      <span class="font-size-16px mr-3">审批类型 :</span>
+      <el-radio-group v-model="approveType">
+        <el-radio
+          v-for="(item, index) in APPROVE_TYPE"
+          :key="index"
+          :value="item.value"
+          :label="item.value"
+        >
+          {{ item.label }}
+        </el-radio>
+      </el-radio-group>
+    </div>
+    <el-tabs type="border-card" v-model="activeTabName" v-if="approveType === ApproveType.USER">
+      <el-tab-pane label="审批人" name="user">
+        <div>
+          <el-form ref="formRef" :model="configForm" label-position="top" :rules="formRules">
+            <el-form-item label="审批人设置" prop="candidateStrategy">
+              <el-radio-group
+                v-model="configForm.candidateStrategy"
+                @change="changeCandidateStrategy"
+              >
+                <el-radio
+                  v-for="(dict, index) in CANDIDATE_STRATEGY"
+                  :key="index"
+                  :value="dict.value"
+                  :label="dict.value"
+                >
+                  {{ dict.label }}
+                </el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy == CandidateStrategy.ROLE"
+              label="指定角色"
+              prop="roleIds"
+            >
+              <el-select v-model="configForm.roleIds" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in roleOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="
+                configForm.candidateStrategy == CandidateStrategy.DEPT_MEMBER ||
+                configForm.candidateStrategy == CandidateStrategy.DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.MULTI_LEVEL_DEPT_LEADER
+              "
+              label="指定部门"
+              prop="deptIds"
+              span="24"
+            >
+              <el-tree-select
+                ref="treeRef"
+                v-model="configForm.deptIds"
+                :data="deptTreeOptions"
+                :props="defaultProps"
+                empty-text="加载中,请稍后"
+                multiple
+                node-key="id"
+                :check-strictly="true"
+                style="width: 100%"
+                show-checkbox
+              />
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy == CandidateStrategy.POST"
+              label="指定岗位"
+              prop="postIds"
+              span="24"
+            >
+              <el-select v-model="configForm.postIds" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in postOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id!"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy == CandidateStrategy.USER"
+              label="指定用户"
+              prop="userIds"
+              span="24"
+            >
+              <el-select v-model="configForm.userIds" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in userOptions"
+                  :key="item.id"
+                  :label="item.nickname"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.USER_GROUP"
+              label="指定用户组"
+              prop="userGroups"
+            >
+              <el-select v-model="configForm.userGroups" clearable multiple style="width: 100%">
+                <el-option
+                  v-for="item in userGroupOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.FORM_USER"
+              label="表单内用户字段"
+              prop="formUser"
+            >
+              <el-select v-model="configForm.formUser" clearable style="width: 100%">
+                <el-option
+                  v-for="(item, idx) in userFieldOnFormOptions"
+                  :key="idx"
+                  :label="item.title"
+                  :value="item.field"
+                  :disabled ="!item.required"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.FORM_DEPT_LEADER"
+              label="表单内部门字段"
+              prop="formDept"
+            >
+              <el-select v-model="configForm.formDept" clearable style="width: 100%">
+                <el-option
+                  v-for="(item, idx) in deptFieldOnFormOptions"
+                  :key="idx"
+                  :label="item.title"
+                  :value="item.field"
+                  :disabled ="!item.required"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item
+              v-if="
+                configForm.candidateStrategy == CandidateStrategy.MULTI_LEVEL_DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.START_USER_DEPT_LEADER ||
+                configForm.candidateStrategy ==
+                  CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.FORM_DEPT_LEADER
+              "
+              :label="deptLevelLabel!"
+              prop="deptLevel"
+              span="24"
+            >
+              <el-select v-model="configForm.deptLevel" clearable>
+                <el-option
+                  v-for="(item, index) in MULTI_LEVEL_DEPT"
+                  :key="index"
+                  :label="item.label"
+                  :value="item.value"
+                />
+              </el-select>
+            </el-form-item>
+            <!-- TODO @jason:后续要支持选择已经存好的表达式 -->
+            <el-form-item
+              v-if="configForm.candidateStrategy === CandidateStrategy.EXPRESSION"
+              label="流程表达式"
+              prop="expression"
+            >
+              <el-input
+                type="textarea"
+                v-model="configForm.expression"
+                clearable
+                style="width: 100%"
+              />
+            </el-form-item>
+            <el-form-item label="多人审批方式" prop="approveMethod">
+              <el-radio-group v-model="configForm.approveMethod" @change="approveMethodChanged">
+                <div class="flex-col">
+                  <div
+                    v-for="(item, index) in APPROVE_METHODS"
+                    :key="index"
+                    class="flex items-center"
+                  >
+                    <el-radio :value="item.value" :label="item.value">
+                      {{ item.label }}
+                    </el-radio>
+                    <el-form-item prop="approveRatio">
+                      <el-input-number
+                        v-model="configForm.approveRatio"
+                        :min="10"
+                        :max="100"
+                        :step="10"
+                        size="small"
+                        v-if="
+                          item.value === ApproveMethodType.APPROVE_BY_RATIO &&
+                          configForm.approveMethod === ApproveMethodType.APPROVE_BY_RATIO
+                        "
+                      />
+                    </el-form-item>
+                  </div>
+                </div>
+              </el-radio-group>
+            </el-form-item>
+
+            <el-divider content-position="left">审批人拒绝时</el-divider>
+            <el-form-item prop="rejectHandlerType">
+              <el-radio-group v-model="configForm.rejectHandlerType">
+                <div class="flex-col">
+                  <div v-for="(item, index) in REJECT_HANDLER_TYPES" :key="index">
+                    <el-radio :key="item.value" :value="item.value" :label="item.label" />
+                  </div>
+                </div>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.rejectHandlerType == RejectHandlerType.RETURN_USER_TASK"
+              label="驳回节点"
+              prop="returnNodeId"
+            >
+              <el-select v-model="configForm.returnNodeId" clearable style="width: 100%">
+                <el-option
+                  v-for="item in returnTaskList"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+
+            <el-divider content-position="left">审批人超时未处理时</el-divider>
+            <el-form-item label="启用开关" prop="timeoutHandlerEnable">
+              <el-switch
+                v-model="configForm.timeoutHandlerEnable"
+                active-text="开启"
+                inactive-text="关闭"
+                @change="timeoutHandlerChange"
+              />
+            </el-form-item>
+            <el-form-item
+              label="执行动作"
+              prop="timeoutHandlerType"
+              v-if="configForm.timeoutHandlerEnable"
+            >
+              <el-radio-group
+                v-model="configForm.timeoutHandlerType"
+                @change="timeoutHandlerTypeChanged"
+              >
+                <el-radio-button
+                  v-for="item in TIMEOUT_HANDLER_TYPES"
+                  :key="item.value"
+                  :value="item.value"
+                  :label="item.label"
+                />
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="超时时间设置" v-if="configForm.timeoutHandlerEnable">
+              <span class="mr-2">当超过</span>
+              <el-form-item prop="timeDuration">
+                <el-input-number
+                  class="mr-2"
+                  :style="{ width: '100px' }"
+                  v-model="configForm.timeDuration"
+                  :min="1"
+                  controls-position="right"
+                />
+              </el-form-item>
+              <el-select
+                v-model="timeUnit"
+                class="mr-2"
+                :style="{ width: '100px' }"
+                @change="timeUnitChange"
+              >
+                <el-option
+                  v-for="item in TIME_UNIT_TYPES"
+                  :key="item.value"
+                  :label="item.label"
+                  :value="item.value"
+                />
+              </el-select>
+              未处理
+            </el-form-item>
+            <el-form-item
+              label="最大提醒次数"
+              prop="maxRemindCount"
+              v-if="configForm.timeoutHandlerEnable && configForm.timeoutHandlerType === 1"
+            >
+              <el-input-number v-model="configForm.maxRemindCount" :min="1" :max="10" />
+            </el-form-item>
+
+            <el-divider content-position="left">审批人为空时</el-divider>
+            <el-form-item prop="assignEmptyHandlerType">
+              <el-radio-group v-model="configForm.assignEmptyHandlerType">
+                <div class="flex-col">
+                  <div v-for="(item, index) in ASSIGN_EMPTY_HANDLER_TYPES" :key="index">
+                    <el-radio :key="item.value" :value="item.value" :label="item.label" />
+                  </div>
+                </div>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item
+              v-if="configForm.assignEmptyHandlerType == AssignEmptyHandlerType.ASSIGN_USER"
+              label="指定用户"
+              prop="assignEmptyHandlerUserIds"
+              span="24"
+            >
+              <el-select
+                v-model="configForm.assignEmptyHandlerUserIds"
+                clearable
+                multiple
+                style="width: 100%"
+              >
+                <el-option
+                  v-for="item in userOptions"
+                  :key="item.id"
+                  :label="item.nickname"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+
+            <el-divider content-position="left">审批人与提交人为同一人时</el-divider>
+            <el-form-item prop="assignStartUserHandlerType">
+              <el-radio-group v-model="configForm.assignStartUserHandlerType">
+                <div class="flex-col">
+                  <div v-for="(item, index) in ASSIGN_START_USER_HANDLER_TYPES" :key="index">
+                    <el-radio :key="item.value" :value="item.value" :label="item.label" />
+                  </div>
+                </div>
+              </el-radio-group>
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-tab-pane>
+      <el-tab-pane label="操作按钮设置" name="buttons">
+        <div class="button-setting-pane">
+          <div class="button-setting-desc">操作按钮</div>
+          <div class="button-setting-title">
+            <div class="button-title-label">操作按钮</div>
+            <div class="pl-4 button-title-label">显示名称</div>
+            <div class="button-title-label">启用</div>
+          </div>
+          <div class="button-setting-item" v-for="(item, index) in buttonsSetting" :key="index">
+            <div class="button-setting-item-label"> {{ OPERATION_BUTTON_NAME.get(item.id) }} </div>
+            <div class="button-setting-item-label">
+              <input
+                type="text"
+                class="editable-title-input"
+                @blur="btnDisplayNameBlurEvent(index)"
+                v-mountedFocus
+                v-model="item.displayName"
+                :placeholder="item.displayName"
+                v-if="btnDisplayNameEdit[index]"
+              />
+              <el-button v-else text @click="changeBtnDisplayName(index)"
+                >{{ item.displayName }} &nbsp;<Icon icon="ep:edit"
+              /></el-button>
+            </div>
+            <div class="button-setting-item-label">
+              <el-switch v-model="item.enable" />
+            </div>
+          </div>
+        </div>
+      </el-tab-pane>
+      <el-tab-pane label="表单字段权限" name="fields" v-if="formType === 10">
+        <div class="field-setting-pane">
+          <div class="field-setting-desc">字段权限</div>
+          <div class="field-permit-title">
+            <div class="setting-title-label first-title"> 字段名称 </div>
+            <div class="other-titles">
+              <span class="setting-title-label">只读</span>
+              <span class="setting-title-label">可编辑</span>
+              <span class="setting-title-label">隐藏</span>
+            </div>
+          </div>
+          <div
+            class="field-setting-item"
+            v-for="(item, index) in fieldsPermissionConfig"
+            :key="index"
+          >
+            <div class="field-setting-item-label"> {{ item.title }} </div>
+            <el-radio-group class="field-setting-item-group" v-model="item.permission">
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.READ"
+                  size="large"
+                  :label="FieldPermissionType.READ"
+                  ><span></span
+                ></el-radio>
+              </div>
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.WRITE"
+                  size="large"
+                  :label="FieldPermissionType.WRITE"
+                  ><span></span
+                ></el-radio>
+              </div>
+              <div class="item-radio-wrap">
+                <el-radio
+                  :value="FieldPermissionType.NONE"
+                  size="large"
+                  :label="FieldPermissionType.NONE"
+                  ><span></span
+                ></el-radio>
+              </div>
+            </el-radio-group>
+          </div>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+    <template #footer>
+      <el-divider />
+      <div>
+        <el-button type="primary" @click="saveConfig">确 定</el-button>
+        <el-button @click="closeDrawer">取 消</el-button>
+      </div>
+    </template>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import {
+  SimpleFlowNode,
+  APPROVE_TYPE,
+  ApproveType,
+  APPROVE_METHODS,
+  CandidateStrategy,
+  NodeType,
+  ApproveMethodType,
+  TimeUnitType,
+  RejectHandlerType,
+  TIMEOUT_HANDLER_TYPES,
+  TIME_UNIT_TYPES,
+  REJECT_HANDLER_TYPES,
+  DEFAULT_BUTTON_SETTING,
+  OPERATION_BUTTON_NAME,
+  ButtonSetting,
+  MULTI_LEVEL_DEPT,
+  CANDIDATE_STRATEGY,
+  ASSIGN_START_USER_HANDLER_TYPES,
+  TimeoutHandlerType,
+  ASSIGN_EMPTY_HANDLER_TYPES,
+  AssignEmptyHandlerType,
+  FieldPermissionType
+} from '../consts'
+
+import {
+  useWatchNode,
+  useNodeName,
+  useFormFieldsPermission,
+  useNodeForm,
+  UserTaskFormType,
+  useDrawer
+} from '../node'
+import { defaultProps } from '@/utils/tree'
+import { cloneDeep } from 'lodash-es'
+import { convertTimeUnit, getApproveTypeText } from '../utils'
+defineOptions({
+  name: 'UserTaskNodeConfig'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+const emits = defineEmits<{
+  'find:returnTaskNodes': [nodeList: SimpleFlowNode[]]
+}>()
+const deptLevelLabel = computed(() => {
+  let label = '部门负责人来源'
+  if (configForm.value.candidateStrategy == CandidateStrategy.MULTI_LEVEL_DEPT_LEADER) {
+    label = label + '(指定部门向上)'
+  } else if (configForm.value.candidateStrategy == CandidateStrategy.FORM_DEPT_LEADER) {
+    label = label + '(表单内部门向上)'
+  } else {
+    label = label + '(发起人部门向上)'
+  }
+  return label
+})
+// 监控节点的变化
+const currentNode = useWatchNode(props)
+// 抽屉配置
+const { settingVisible, closeDrawer, openDrawer } = useDrawer()
+// 节点名称配置
+const { nodeName, showInput, clickIcon, blurEvent } = useNodeName(NodeType.USER_TASK_NODE)
+// 激活的 Tab 标签页
+const activeTabName = ref('user')
+// 表单字段权限设置
+const { formType, fieldsPermissionConfig, formFieldOptions, getNodeConfigFormFields } =
+  useFormFieldsPermission(FieldPermissionType.READ)
+// 表单内用户字段选项, 必须是必填和用户选择器
+const userFieldOnFormOptions = computed(() => {
+  return formFieldOptions.filter((item) => item.type === 'UserSelect')
+})
+// 表单内部门字段选项, 必须是必填和部门选择器
+const deptFieldOnFormOptions = computed(() => {
+  return formFieldOptions.filter((item) => item.type === 'DeptSelect')
+})
+// 操作按钮设置
+const { buttonsSetting, btnDisplayNameEdit, changeBtnDisplayName, btnDisplayNameBlurEvent } =
+  useButtonsSetting()
+const approveType = ref(ApproveType.USER)
+// 审批人表单设置
+const formRef = ref() // 表单 Ref
+// 表单校验规则
+const formRules = reactive({
+  candidateStrategy: [{ required: true, message: '审批人设置不能为空', trigger: 'change' }],
+  userIds: [{ required: true, message: '用户不能为空', trigger: 'change' }],
+  roleIds: [{ required: true, message: '角色不能为空', trigger: 'change' }],
+  deptIds: [{ required: true, message: '部门不能为空', trigger: 'change' }],
+  userGroups: [{ required: true, message: '用户组不能为空', trigger: 'change' }],
+  formUser: [{ required: true, message: '表单内用户字段不能为空', trigger: 'change' }],
+  formDept: [{ required: true, message: '表单内部门字段不能为空', trigger: 'change' }],
+  postIds: [{ required: true, message: '岗位不能为空', trigger: 'change' }],
+  expression: [{ required: true, message: '流程表达式不能为空', trigger: 'blur' }],
+  approveMethod: [{ required: true, message: '多人审批方式不能为空', trigger: 'change' }],
+  approveRatio: [{ required: true, message: '通过比例不能为空', trigger: 'blur' }],
+  returnNodeId: [{ required: true, message: '驳回节点不能为空', trigger: 'change' }],
+  timeoutHandlerEnable: [{ required: true }],
+  timeoutHandlerType: [{ required: true }],
+  timeDuration: [{ required: true, message: '超时时间不能为空', trigger: 'blur' }],
+  maxRemindCount: [{ required: true, message: '提醒次数不能为空', trigger: 'blur' }],
+  assignEmptyHandlerType: [{ required: true }],
+  assignEmptyHandlerUserIds: [{ required: true, message: '用户不能为空', trigger: 'change' }],
+  assignStartUserHandlerType: [{ required: true }]
+})
+
+const {
+  configForm: tempConfigForm,
+  roleOptions,
+  postOptions,
+  userOptions,
+  userGroupOptions,
+  deptTreeOptions,
+  handleCandidateParam,
+  parseCandidateParam,
+  getShowText
+} = useNodeForm(NodeType.USER_TASK_NODE)
+const configForm = tempConfigForm as Ref<UserTaskFormType>
+
+// 改变审批人设置策略
+const changeCandidateStrategy = () => {
+  configForm.value.userIds = []
+  configForm.value.deptIds = []
+  configForm.value.roleIds = []
+  configForm.value.postIds = []
+  configForm.value.userGroups = []
+  configForm.value.deptLevel = 1
+  configForm.value.formUser = ''
+  configForm.value.formDept = ''
+  configForm.value.approveMethod = ApproveMethodType.SEQUENTIAL_APPROVE
+}
+
+// 审批方式改变
+const approveMethodChanged = () => {
+  configForm.value.rejectHandlerType = RejectHandlerType.FINISH_PROCESS
+  if (configForm.value.approveMethod === ApproveMethodType.APPROVE_BY_RATIO) {
+    configForm.value.approveRatio = 100
+  }
+  formRef.value.clearValidate('approveRatio')
+}
+// 审批拒绝 可退回的节点
+const returnTaskList = ref<SimpleFlowNode[]>([])
+// 审批人超时未处理设置
+const {
+  timeoutHandlerChange,
+  cTimeoutType,
+  timeoutHandlerTypeChanged,
+  timeUnit,
+  timeUnitChange,
+  isoTimeDuration,
+  cTimeoutMaxRemindCount
+} = useTimeoutHandler()
+
+// 保存配置
+const saveConfig = async () => {
+  activeTabName.value = 'user'
+  // 设置审批节点名称
+  currentNode.value.name = nodeName.value!
+  // 设置审批类型
+  currentNode.value.approveType = approveType.value
+  // 如果不是人工审批。返回
+  if (approveType.value !== ApproveType.USER) {
+    currentNode.value.showText = getApproveTypeText(approveType.value)
+    settingVisible.value = false
+    return true
+  }
+
+  if (!formRef) return false
+  const valid = await formRef.value.validate()
+  if (!valid) return false
+  const showText = getShowText()
+  if (!showText) return false
+
+  currentNode.value.candidateStrategy = configForm.value.candidateStrategy
+  // 处理 candidateParam 参数
+  currentNode.value.candidateParam = handleCandidateParam()
+  // 设置审批方式
+  currentNode.value.approveMethod = configForm.value.approveMethod
+  if (configForm.value.approveMethod === ApproveMethodType.APPROVE_BY_RATIO) {
+    currentNode.value.approveRatio = configForm.value.approveRatio
+  }
+  // 设置拒绝处理
+  currentNode.value.rejectHandler = {
+    type: configForm.value.rejectHandlerType!,
+    returnNodeId: configForm.value.returnNodeId
+  }
+  // 设置超时处理
+  currentNode.value.timeoutHandler = {
+    enable: configForm.value.timeoutHandlerEnable!,
+    type: cTimeoutType.value,
+    timeDuration: isoTimeDuration.value,
+    maxRemindCount: cTimeoutMaxRemindCount.value
+  }
+  // 设置审批人为空时
+  currentNode.value.assignEmptyHandler = {
+    type: configForm.value.assignEmptyHandlerType!,
+    userIds:
+      configForm.value.assignEmptyHandlerType === AssignEmptyHandlerType.ASSIGN_USER
+        ? configForm.value.assignEmptyHandlerUserIds
+        : undefined
+  }
+  // 设置审批人与发起人相同时
+  currentNode.value.assignStartUserHandlerType = configForm.value.assignStartUserHandlerType
+  // 设置表单权限
+  currentNode.value.fieldsPermission = fieldsPermissionConfig.value
+  // 设置按钮权限
+  currentNode.value.buttonsSetting = buttonsSetting.value
+
+  currentNode.value.showText = showText
+  settingVisible.value = false
+  return true
+}
+
+// 显示审批节点配置, 由父组件传过来
+const showUserTaskNodeConfig = (node: SimpleFlowNode) => {
+  nodeName.value = node.name
+  // 1 审批类型
+  approveType.value = node.approveType ? node.approveType : ApproveType.USER
+  // 如果审批类型不是人工审批返回
+  if (approveType.value !== ApproveType.USER) {
+    return
+  }
+
+  //2.1 审批人设置
+  configForm.value.candidateStrategy = node.candidateStrategy!
+  // 解析候选人参数
+  parseCandidateParam(node.candidateStrategy!, node?.candidateParam)
+  // 2.2 设置审批方式
+  configForm.value.approveMethod = node.approveMethod!
+  if (node.approveMethod == ApproveMethodType.APPROVE_BY_RATIO) {
+    configForm.value.approveRatio = node.approveRatio!
+  }
+  // 2.3 设置审批拒绝处理
+  configForm.value.rejectHandlerType = node.rejectHandler!.type
+  configForm.value.returnNodeId = node.rejectHandler?.returnNodeId
+  const matchNodeList = []
+  emits('find:returnTaskNodes', matchNodeList)
+  returnTaskList.value = matchNodeList
+  // 2.4 设置审批超时处理
+  configForm.value.timeoutHandlerEnable = node.timeoutHandler!.enable
+  if (node.timeoutHandler?.enable && node.timeoutHandler?.timeDuration) {
+    const strTimeDuration = node.timeoutHandler.timeDuration
+    let parseTime = strTimeDuration.slice(2, strTimeDuration.length - 1)
+    let parseTimeUnit = strTimeDuration.slice(strTimeDuration.length - 1)
+    configForm.value.timeDuration = parseInt(parseTime)
+    timeUnit.value = convertTimeUnit(parseTimeUnit)
+  }
+  configForm.value.timeoutHandlerType = node.timeoutHandler?.type
+  configForm.value.maxRemindCount = node.timeoutHandler?.maxRemindCount
+  // 2.5 设置审批人为空时
+  configForm.value.assignEmptyHandlerType = node.assignEmptyHandler?.type
+  configForm.value.assignEmptyHandlerUserIds = node.assignEmptyHandler?.userIds
+  // 2.6 设置用户任务的审批人与发起人相同时
+  configForm.value.assignStartUserHandlerType = node.assignStartUserHandlerType
+  // 3. 操作按钮设置
+  buttonsSetting.value = cloneDeep(node.buttonsSetting) || DEFAULT_BUTTON_SETTING
+  // 4. 表单字段权限配置
+  getNodeConfigFormFields(node.fieldsPermission)
+}
+
+defineExpose({ openDrawer, showUserTaskNodeConfig }) // 暴露方法给父组件
+
+/**
+ * @description 操作按钮设置
+ */
+function useButtonsSetting() {
+  const buttonsSetting = ref<ButtonSetting[]>()
+  // 操作按钮显示名称可编辑
+  const btnDisplayNameEdit = ref<boolean[]>([])
+  const changeBtnDisplayName = (index: number) => {
+    btnDisplayNameEdit.value[index] = true
+  }
+  const btnDisplayNameBlurEvent = (index: number) => {
+    btnDisplayNameEdit.value[index] = false
+    const buttonItem = buttonsSetting.value![index]
+    buttonItem.displayName = buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!
+  }
+  return {
+    buttonsSetting,
+    btnDisplayNameEdit,
+    changeBtnDisplayName,
+    btnDisplayNameBlurEvent
+  }
+}
+
+/**
+ * @description 审批人超时未处理配置
+ */
+function useTimeoutHandler() {
+  // 时间单位
+  const timeUnit = ref(TimeUnitType.HOUR)
+
+  // 超时开关改变
+  const timeoutHandlerChange = () => {
+    if (configForm.value.timeoutHandlerEnable) {
+      timeUnit.value = 2
+      configForm.value.timeDuration = 6
+      configForm.value.timeoutHandlerType = 1
+      configForm.value.maxRemindCount = 1
+    }
+  }
+  // 超时执行的动作
+  const cTimeoutType = computed(() => {
+    if (!configForm.value.timeoutHandlerEnable) {
+      return undefined
+    }
+    return configForm.value.timeoutHandlerType
+  })
+
+  // 超时处理动作改变
+  const timeoutHandlerTypeChanged = () => {
+    if (configForm.value.timeoutHandlerType === TimeoutHandlerType.REMINDER) {
+      configForm.value.maxRemindCount = 1 // 超时提醒次数,默认为1
+    }
+  }
+
+  // 时间单位改变
+  const timeUnitChange = () => {
+    // 分钟,默认是 60 分钟
+    if (timeUnit.value === TimeUnitType.MINUTE) {
+      configForm.value.timeDuration = 60
+    }
+    // 小时,默认是 6 个小时
+    if (timeUnit.value === TimeUnitType.HOUR) {
+      configForm.value.timeDuration = 6
+    }
+    // 天, 默认 1天
+    if (timeUnit.value === TimeUnitType.DAY) {
+      configForm.value.timeDuration = 1
+    }
+  }
+  // 超时时间的 ISO 表示
+  const isoTimeDuration = computed(() => {
+    if (!configForm.value.timeoutHandlerEnable) {
+      return undefined
+    }
+    let strTimeDuration = 'PT'
+    if (timeUnit.value === TimeUnitType.MINUTE) {
+      strTimeDuration += configForm.value.timeDuration + 'M'
+    }
+    if (timeUnit.value === TimeUnitType.HOUR) {
+      strTimeDuration += configForm.value.timeDuration + 'H'
+    }
+    if (timeUnit.value === TimeUnitType.DAY) {
+      strTimeDuration += configForm.value.timeDuration + 'D'
+    }
+    return strTimeDuration
+  })
+
+  // 超时最大提醒次数
+  const cTimeoutMaxRemindCount = computed(() => {
+    if (!configForm.value.timeoutHandlerEnable) {
+      return undefined
+    }
+    if (configForm.value.timeoutHandlerType !== TimeoutHandlerType.REMINDER) {
+      return undefined
+    }
+    return configForm.value.maxRemindCount
+  })
+
+  return {
+    timeoutHandlerChange,
+    cTimeoutType,
+    timeoutHandlerTypeChanged,
+    timeUnit,
+    timeUnitChange,
+    isoTimeDuration,
+    cTimeoutMaxRemindCount
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.button-setting-pane {
+  display: flex;
+  flex-direction: column;
+  font-size: 14px;
+
+  .button-setting-desc {
+    padding-right: 8px;
+    margin-bottom: 16px;
+    font-size: 16px;
+    font-weight: 700;
+  }
+
+  .button-setting-title {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    height: 45px;
+    padding-left: 12px;
+    background-color: #f8fafc0a;
+    border: 1px solid #1f38581a;
+
+    & > :first-child {
+      width: 100px !important;
+      text-align: left !important;
+    }
+
+    & > :last-child {
+      text-align: center !important;
+    }
+
+    .button-title-label {
+      width: 150px;
+      font-size: 13px;
+      font-weight: 700;
+      color: #000;
+      text-align: left;
+    }
+  }
+
+  .button-setting-item {
+    align-items: center;
+    display: flex;
+    justify-content: space-between;
+    height: 38px;
+    padding-left: 12px;
+    border: 1px solid #1f38581a;
+    border-top: 0;
+
+    & > :first-child {
+      width: 100px !important;
+    }
+
+    & > :last-child {
+      text-align: center !important;
+    }
+
+    .button-setting-item-label {
+      width: 150px;
+      overflow: hidden;
+      text-align: left;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .editable-title-input {
+      height: 24px;
+      max-width: 130px;
+      margin-left: 4px;
+      line-height: 24px;
+      border: 1px solid #d9d9d9;
+      border-radius: 4px;
+      transition: all 0.3s;
+
+      &:focus {
+        border-color: #40a9ff;
+        outline: 0;
+        box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
+      }
+    }
+  }
+}
+</style>

+ 97 - 0
src/components/SimpleProcessDesignerV2/src/nodes/CopyTaskNode.vue

@@ -0,0 +1,97 @@
+<template>
+  <div class="node-wrapper">
+    <div class="node-container">
+      <div
+        class="node-box"
+        :class="[
+          { 'node-config-error': !currentNode.showText },
+          `${useTaskStatusClass(currentNode?.activityStatus)}`
+        ]"
+      >
+        <div class="node-title-container">
+          <div class="node-title-icon copy-task"><span class="iconfont icon-copy"></span></div>
+          <input
+            v-if="!readonly && showInput"
+            type="text"
+            class="editable-title-input"
+            @blur="blurEvent()"
+            v-mountedFocus
+            v-model="currentNode.name"
+            :placeholder="currentNode.name"
+          />
+          <div v-else class="node-title" @click="clickTitle">
+            {{ currentNode.name }}
+          </div>
+        </div>
+        <div class="node-content" @click="openNodeConfig">
+          <div class="node-text" :title="currentNode.showText" v-if="currentNode.showText">
+            {{ currentNode.showText }}
+          </div>
+          <div class="node-text" v-else>
+            {{ NODE_DEFAULT_TEXT.get(NodeType.COPY_TASK_NODE) }}
+          </div>
+          <Icon v-if="!readonly" icon="ep:arrow-right-bold" />
+        </div>
+        <div v-if="!readonly" class="node-toolbar">
+          <div class="toolbar-icon"
+            ><Icon color="#0089ff" icon="ep:circle-close-filled" :size="18" @click="deleteNode"
+          /></div>
+        </div>
+      </div>
+
+      <!-- 传递子节点给添加节点组件。会在子节点前面添加节点 -->
+      <NodeHandler
+        v-if="currentNode"
+        v-model:child-node="currentNode.childNode"
+        :current-node="currentNode"
+      />
+    </div>
+    <CopyTaskNodeConfig
+      v-if="!readonly && currentNode"
+      ref="nodeSetting"
+      :flow-node="currentNode"
+    />
+  </div>
+</template>
+<script setup lang="ts">
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from '../consts'
+import NodeHandler from '../NodeHandler.vue'
+import { useNodeName2, useWatchNode, useTaskStatusClass } from '../node'
+import CopyTaskNodeConfig from '../nodes-config/CopyTaskNodeConfig.vue'
+defineOptions({
+  name: 'CopyTaskNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+// 定义事件,更新父组件。
+const emits = defineEmits<{
+  'update:flowNode': [node: SimpleFlowNode | undefined]
+}>()
+// 是否只读
+const readonly = inject<Boolean>('readonly')
+// 监控节点的变化
+const currentNode = useWatchNode(props)
+// 节点名称编辑
+const { showInput, blurEvent, clickTitle } = useNodeName2(currentNode, NodeType.COPY_TASK_NODE)
+
+const nodeSetting = ref()
+// 打开节点配置
+const openNodeConfig = () => {
+  if (readonly) {
+    return
+  }
+  nodeSetting.value.showCopyTaskNodeConfig(currentNode.value)
+  nodeSetting.value.openDrawer()
+}
+
+// 删除节点。更新当前节点为孩子节点
+const deleteNode = () => {
+  emits('update:flowNode', currentNode.value.childNode)
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 102 - 0
src/components/SimpleProcessDesignerV2/src/nodes/EndEventNode.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="end-node-wrapper">
+    <div class="end-node-box cursor-pointer" :class="`${useTaskStatusClass(currentNode?.activityStatus)}`" @click="nodeClick">
+      <span class="node-fixed-name" title="结束">结束</span>
+    </div>
+  </div>
+  <el-dialog title="审批信息" v-model="dialogVisible" width="1000px" append-to-body>
+      <el-row>
+        <el-table
+          :data="processInstanceInfos"
+          size="small"
+          border
+          header-cell-class-name="table-header-gray"
+        >
+          <el-table-column
+            label="序号"
+            header-align="center"
+            align="center"
+            type="index"
+            width="50"
+          />
+          <el-table-column
+            label="发起人"
+            prop="assigneeUser.nickname"
+            min-width="100"
+            align="center"
+          />
+          <el-table-column label="部门" min-width="100" align="center">
+            <template #default="scope">
+              {{ scope.row.assigneeUser?.deptName || scope.row.ownerUser?.deptName }}
+            </template>
+          </el-table-column>
+          <el-table-column
+            :formatter="dateFormatter"
+            align="center"
+            label="开始时间"
+            prop="createTime"
+            min-width="140"
+          />
+          <el-table-column
+            :formatter="dateFormatter"
+            align="center"
+            label="结束时间"
+            prop="endTime"
+            min-width="140"
+          />
+          <el-table-column align="center" label="审批状态" prop="status" min-width="90">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" />
+            </template>
+          </el-table-column>
+         
+          <el-table-column align="center" label="耗时" prop="durationInMillis" width="100">
+            <template #default="scope">
+              {{ formatPast2(scope.row.durationInMillis) }}
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-row>
+    </el-dialog>
+</template>
+<script setup lang="ts">
+import { SimpleFlowNode } from '../consts'
+import { useWatchNode, useTaskStatusClass } from '../node'
+import { dateFormatter, formatPast2 } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+defineOptions({
+  name: 'EndEventNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    default: () => null
+  }
+})
+// 监控节点变化
+const currentNode = useWatchNode(props)
+// 是否只读
+const readonly = inject<Boolean>('readonly')
+const processInstance = inject<Ref<any>>('processInstance')
+// 审批信息的弹窗显示,用于只读模式
+const dialogVisible = ref(false) // 弹窗可见性
+const processInstanceInfos = ref<any[]>([]) // 流程的审批信息
+
+const nodeClick = () => {
+  if (readonly) { 
+    if(processInstance && processInstance.value){
+      processInstanceInfos.value = [
+      {
+        assigneeUser: processInstance.value.startUser,
+        createTime: processInstance.value.startTime,
+        endTime: processInstance.value.endTime,
+        status: processInstance.value.status,
+        durationInMillis: processInstance.value.durationInMillis
+      }
+    ]
+      dialogVisible.value = true
+    }
+  }
+}
+</script>
+<style lang="scss" scoped></style>

+ 229 - 0
src/components/SimpleProcessDesignerV2/src/nodes/ExclusiveNode.vue

@@ -0,0 +1,229 @@
+<template>
+  <div class="branch-node-wrapper">
+    <div class="branch-node-container">
+      <div
+        v-if="readonly"
+        class="branch-node-readonly"
+        :class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
+      >
+        <span class="iconfont icon-exclusive icon-size condition"></span>
+      </div>
+      <el-button v-else class="branch-node-add" color="#67c23a" @click="addCondition" plain
+        >添加条件</el-button
+      >
+
+      <div
+        class="branch-node-item"
+        v-for="(item, index) in currentNode.conditionNodes"
+        :key="index"
+      >
+        <template v-if="index == 0">
+          <div class="branch-line-first-top"> </div>
+          <div class="branch-line-first-bottom"></div>
+        </template>
+        <template v-if="index + 1 == currentNode.conditionNodes?.length">
+          <div class="branch-line-last-top"></div>
+          <div class="branch-line-last-bottom"></div>
+        </template>
+        <div class="node-wrapper">
+          <div class="node-container">
+            <div
+              class="node-box"
+              :class="[
+                { 'node-config-error': !item.showText },
+                `${useTaskStatusClass(item.activityStatus)}`
+              ]"
+            >
+              <div class="branch-node-title-container">
+                <div v-if="!readonly && showInputs[index]">
+                  <input
+                    type="text"
+                    class="input-max-width editable-title-input"
+                    @blur="blurEvent(index)"
+                    v-mountedFocus
+                    v-model="item.name"
+                  />
+                </div>
+                <div v-else class="branch-title" @click="clickEvent(index)"> {{ item.name }} </div>
+                <div class="branch-priority"> 优先级{{ index + 1 }} </div>
+              </div>
+              <div class="branch-node-content" @click="conditionNodeConfig(item.id)">
+                <div class="branch-node-text" :title="item.showText" v-if="item.showText">
+                  {{ item.showText }}
+                </div>
+                <div class="branch-node-text" v-else>
+                  {{ NODE_DEFAULT_TEXT.get(NodeType.CONDITION_NODE) }}
+                </div>
+              </div>
+              <div
+                class="node-toolbar"
+                v-if="!readonly && index + 1 !== currentNode.conditionNodes?.length"
+              >
+                <div class="toolbar-icon">
+                  <Icon
+                    color="#0089ff"
+                    icon="ep:circle-close-filled"
+                    :size="18"
+                    @click="deleteCondition(index)"
+                  />
+                </div>
+              </div>
+              <div
+                class="branch-node-move move-node-left"
+                v-if="index != 0 && index + 1 !== currentNode.conditionNodes?.length"
+                @click="moveNode(index, -1)"
+              >
+                <Icon icon="ep:arrow-left" />
+              </div>
+
+              <div
+                class="branch-node-move move-node-right"
+                v-if="currentNode.conditionNodes && index < currentNode.conditionNodes.length - 2"
+                @click="moveNode(index, 1)"
+              >
+                <Icon icon="ep:arrow-right" />
+              </div>
+            </div>
+            <NodeHandler v-model:child-node="item.childNode" :current-node="item" />
+          </div>
+        </div>
+        <ConditionNodeConfig :node-index="index" :condition-node="item" :ref="item.id" />
+        <!-- 递归显示子节点  -->
+        <ProcessNodeTree
+          v-if="item && item.childNode"
+          :parent-node="item"
+          v-model:flow-node="item.childNode"
+          @find:recursive-find-parent-node="recursiveFindParentNode"
+        />
+      </div>
+    </div>
+    <NodeHandler
+      v-if="currentNode"
+      v-model:child-node="currentNode.childNode"
+      :current-node="currentNode"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import NodeHandler from '../NodeHandler.vue'
+import ProcessNodeTree from '../ProcessNodeTree.vue'
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from '../consts'
+import { getDefaultConditionNodeName } from '../utils'
+import { useTaskStatusClass } from '../node'
+import { generateUUID } from '@/utils'
+import ConditionNodeConfig from '../nodes-config/ConditionNodeConfig.vue'
+const { proxy } = getCurrentInstance() as any
+defineOptions({
+  name: 'ExclusiveNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+// 定义事件,更新父组件
+const emits = defineEmits<{
+  'update:modelValue': [node: SimpleFlowNode | undefined]
+  'find:parentNode': [nodeList: SimpleFlowNode[], nodeType: number]
+  'find:recursiveFindParentNode': [
+    nodeList: SimpleFlowNode[],
+    curentNode: SimpleFlowNode,
+    nodeType: number
+  ]
+}>()
+// 是否只读
+const readonly = inject<Boolean>('readonly')
+const currentNode = ref<SimpleFlowNode>(props.flowNode)
+watch(
+  () => props.flowNode,
+  (newValue) => {
+    currentNode.value = newValue
+  }
+)
+
+const showInputs = ref<boolean[]>([])
+// 失去焦点
+const blurEvent = (index: number) => {
+  showInputs.value[index] = false
+  const conditionNode = currentNode.value.conditionNodes?.at(index) as SimpleFlowNode
+  conditionNode.name =
+    conditionNode.name || getDefaultConditionNodeName(index, conditionNode.defaultFlow)
+}
+
+// 点击条件名称
+const clickEvent = (index: number) => {
+  showInputs.value[index] = true
+}
+
+const conditionNodeConfig = (nodeId: string) => {
+  if (readonly) {
+    return
+  }
+  const conditionNode = proxy.$refs[nodeId][0]
+  conditionNode.open()
+}
+
+// 新增条件
+const addCondition = () => {
+  const conditionNodes = currentNode.value.conditionNodes
+  if (conditionNodes) {
+    const len = conditionNodes.length
+    let lastIndex = len - 1
+    const conditionData: SimpleFlowNode = {
+      id: 'Flow_' + generateUUID(),
+      name: '条件' + len,
+      showText: '',
+      type: NodeType.CONDITION_NODE,
+      childNode: undefined,
+      conditionNodes: [],
+      conditionType: 1,
+      defaultFlow: false
+    }
+    conditionNodes.splice(lastIndex, 0, conditionData)
+  }
+}
+
+// 删除条件
+const deleteCondition = (index: number) => {
+  const conditionNodes = currentNode.value.conditionNodes
+  if (conditionNodes) {
+    conditionNodes.splice(index, 1)
+    if (conditionNodes.length == 1) {
+      const childNode = currentNode.value.childNode
+      // 更新此节点为后续孩子节点
+      emits('update:modelValue', childNode)
+    }
+  }
+}
+
+// 移动节点
+const moveNode = (index: number, to: number) => {
+  // -1 :向左  1: 向右
+  if (currentNode.value.conditionNodes) {
+    currentNode.value.conditionNodes[index] = currentNode.value.conditionNodes.splice(
+      index + to,
+      1,
+      currentNode.value.conditionNodes[index]
+    )[0]
+  }
+}
+// 递归从父节点中查询匹配的节点
+const recursiveFindParentNode = (
+  nodeList: SimpleFlowNode[],
+  node: SimpleFlowNode,
+  nodeType: number
+) => {
+  if (!node || node.type === NodeType.START_USER_NODE) {
+    return
+  }
+  if (node.type === nodeType) {
+    nodeList.push(node)
+  }
+  // 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点条件分支节点(NodeType.EXCLUSIVE_NODE) 继续查找
+  emits('find:parentNode', nodeList, nodeType)
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 233 - 0
src/components/SimpleProcessDesignerV2/src/nodes/InclusiveNode.vue

@@ -0,0 +1,233 @@
+<template>
+  <div class="branch-node-wrapper">
+    <div class="branch-node-container">
+      <div
+        v-if="readonly"
+        class="branch-node-readonly"
+        :class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
+      >
+        <span class="iconfont icon-inclusive icon-size inclusive"></span>
+      </div>
+      <el-button v-else class="branch-node-add" color="#345da2" @click="addCondition" plain
+        >添加条件</el-button
+      >
+      <div
+        class="branch-node-item"
+        v-for="(item, index) in currentNode.conditionNodes"
+        :key="index"
+      >
+        <template v-if="index == 0">
+          <div class="branch-line-first-top"> </div>
+          <div class="branch-line-first-bottom"></div>
+        </template>
+        <template v-if="index + 1 == currentNode.conditionNodes?.length">
+          <div class="branch-line-last-top"></div>
+          <div class="branch-line-last-bottom"></div>
+        </template>
+        <div class="node-wrapper">
+          <div class="node-container">
+            <div
+              class="node-box"
+              :class="[
+                { 'node-config-error': !item.showText },
+                `${useTaskStatusClass(item.activityStatus)}`
+              ]"
+            >
+              <div class="branch-node-title-container">
+                <div v-if="showInputs[index]">
+                  <input
+                    type="text"
+                    class="editable-title-input"
+                    @blur="blurEvent(index)"
+                    v-mountedFocus
+                    v-model="item.name"
+                  />
+                </div>
+                <div v-else class="branch-title" @click="clickEvent(index)"> {{ item.name }} </div>
+              </div>
+              <div class="branch-node-content" @click="conditionNodeConfig(item.id)">
+                <div class="branch-node-text" :title="item.showText" v-if="item.showText">
+                  {{ item.showText }}
+                </div>
+                <div class="branch-node-text" v-else>
+                  {{ NODE_DEFAULT_TEXT.get(NodeType.CONDITION_NODE) }}
+                </div>
+              </div>
+              <div
+                class="node-toolbar"
+                v-if="!readonly && index + 1 !== currentNode.conditionNodes?.length"
+              >
+                <div class="toolbar-icon">
+                  <Icon
+                    color="#0089ff"
+                    icon="ep:circle-close-filled"
+                    :size="18"
+                    @click="deleteCondition(index)"
+                  />
+                </div>
+              </div>
+              <div
+                class="branch-node-move move-node-left"
+                v-if="!readonly && index != 0 && index + 1 !== currentNode.conditionNodes?.length"
+                @click="moveNode(index, -1)"
+              >
+                <Icon icon="ep:arrow-left" />
+              </div>
+
+              <div
+                class="branch-node-move move-node-right"
+                v-if="
+                  !readonly &&
+                  currentNode.conditionNodes &&
+                  index < currentNode.conditionNodes.length - 2
+                "
+                @click="moveNode(index, 1)"
+              >
+                <Icon icon="ep:arrow-right" />
+              </div>
+            </div>
+            <NodeHandler v-model:child-node="item.childNode" :current-node="item" />
+          </div>
+        </div>
+        <ConditionNodeConfig :node-index="index" :condition-node="item" :ref="item.id" />
+        <!-- 递归显示子节点  -->
+        <ProcessNodeTree
+          v-if="item && item.childNode"
+          :parent-node="item"
+          v-model:flow-node="item.childNode"
+          @find:recursive-find-parent-node="recursiveFindParentNode"
+        />
+      </div>
+    </div>
+    <NodeHandler
+      v-if="currentNode"
+      v-model:child-node="currentNode.childNode"
+      :current-node="currentNode"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import NodeHandler from '../NodeHandler.vue'
+import ProcessNodeTree from '../ProcessNodeTree.vue'
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from '../consts'
+import { useTaskStatusClass } from '../node'
+import { getDefaultInclusiveConditionNodeName } from '../utils'
+import { generateUUID } from '@/utils'
+import ConditionNodeConfig from '../nodes-config/ConditionNodeConfig.vue'
+const { proxy } = getCurrentInstance() as any
+defineOptions({
+  name: 'InclusiveNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+// 定义事件,更新父组件
+const emits = defineEmits<{
+  'update:modelValue': [node: SimpleFlowNode | undefined]
+  'find:parentNode': [nodeList: SimpleFlowNode[], nodeType: number]
+  'find:recursiveFindParentNode': [
+    nodeList: SimpleFlowNode[],
+    curentNode: SimpleFlowNode,
+    nodeType: number
+  ]
+}>()
+// 是否只读
+const readonly = inject<Boolean>('readonly')
+
+const currentNode = ref<SimpleFlowNode>(props.flowNode)
+
+watch(
+  () => props.flowNode,
+  (newValue) => {
+    currentNode.value = newValue
+  }
+)
+
+const showInputs = ref<boolean[]>([])
+// 失去焦点
+const blurEvent = (index: number) => {
+  showInputs.value[index] = false
+  const conditionNode = currentNode.value.conditionNodes?.at(index) as SimpleFlowNode
+  conditionNode.name =
+    conditionNode.name || getDefaultInclusiveConditionNodeName(index, conditionNode.defaultFlow)
+}
+
+// 点击条件名称
+const clickEvent = (index: number) => {
+  showInputs.value[index] = true
+}
+
+const conditionNodeConfig = (nodeId: string) => {
+  if (readonly) {
+    return
+  }
+  const conditionNode = proxy.$refs[nodeId][0]
+  conditionNode.open()
+}
+
+// 新增条件
+const addCondition = () => {
+  const conditionNodes = currentNode.value.conditionNodes
+  if (conditionNodes) {
+    const len = conditionNodes.length
+    let lastIndex = len - 1
+    const conditionData: SimpleFlowNode = {
+      id: 'Flow_' + generateUUID(),
+      name: '包容条件' + len,
+      showText: '',
+      type: NodeType.CONDITION_NODE,
+      childNode: undefined,
+      conditionNodes: [],
+      conditionType: 1,
+      defaultFlow: false
+    }
+    conditionNodes.splice(lastIndex, 0, conditionData)
+  }
+}
+
+// 删除条件
+const deleteCondition = (index: number) => {
+  const conditionNodes = currentNode.value.conditionNodes
+  if (conditionNodes) {
+    conditionNodes.splice(index, 1)
+    if (conditionNodes.length == 1) {
+      const childNode = currentNode.value.childNode
+      // 更新此节点为后续孩子节点
+      emits('update:modelValue', childNode)
+    }
+  }
+}
+
+// 移动节点
+const moveNode = (index: number, to: number) => {
+  // -1 :向左  1: 向右
+  if (currentNode.value.conditionNodes) {
+    currentNode.value.conditionNodes[index] = currentNode.value.conditionNodes.splice(
+      index + to,
+      1,
+      currentNode.value.conditionNodes[index]
+    )[0]
+  }
+}
+// 递归从父节点中查询匹配的节点
+const recursiveFindParentNode = (
+  nodeList: SimpleFlowNode[],
+  node: SimpleFlowNode,
+  nodeType: number
+) => {
+  if (!node || node.type === NodeType.START_USER_NODE) {
+    return
+  }
+  if (node.type === nodeType) {
+    nodeList.push(node)
+  }
+  // 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点条件分支节点(NodeType.INCLUSIVE_BRANCH_NODE) 继续查找
+  emits('find:parentNode', nodeList, nodeType)
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 184 - 0
src/components/SimpleProcessDesignerV2/src/nodes/ParallelNode.vue

@@ -0,0 +1,184 @@
+<template>
+  <div class="branch-node-wrapper">
+    <div class="branch-node-container">
+      <div
+        v-if="readonly"
+        class="branch-node-readonly"
+        :class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
+      >
+        <span class="iconfont icon-parallel icon-size parallel"></span>
+      </div>
+      <el-button v-else class="branch-node-add" color="#626aef" @click="addCondition" plain
+        >添加分支</el-button
+      >
+      <div
+        class="branch-node-item"
+        v-for="(item, index) in currentNode.conditionNodes"
+        :key="index"
+      >
+        <template v-if="index == 0">
+          <div class="branch-line-first-top"></div>
+          <div class="branch-line-first-bottom"></div>
+        </template>
+        <template v-if="index + 1 == currentNode.conditionNodes?.length">
+          <div class="branch-line-last-top"></div>
+          <div class="branch-line-last-bottom"></div>
+        </template>
+        <div class="node-wrapper">
+          <div class="node-container">
+            <div class="node-box" :class="`${useTaskStatusClass(item.activityStatus)}`">
+              <div class="branch-node-title-container">
+                <div v-if="showInputs[index]">
+                  <input
+                    type="text"
+                    class="input-max-width editable-title-input"
+                    @blur="blurEvent(index)"
+                    v-mountedFocus
+                    v-model="item.name"
+                  />
+                </div>
+                <div v-else class="branch-title" @click="clickEvent(index)"> {{ item.name }} </div>
+                <div class="branch-priority">无优先级</div>
+              </div>
+              <div class="branch-node-content" @click="conditionNodeConfig(item.id)">
+                <div class="branch-node-text" :title="item.showText" v-if="item.showText">
+                  {{ item.showText }}
+                </div>
+                <div class="branch-node-text" v-else>
+                  {{ NODE_DEFAULT_TEXT.get(NodeType.CONDITION_NODE) }}
+                </div>
+              </div>
+              <div v-if="!readonly" class="node-toolbar">
+                <div class="toolbar-icon">
+                  <Icon
+                    color="#0089ff"
+                    icon="ep:circle-close-filled"
+                    :size="18"
+                    @click="deleteCondition(index)"
+                  />
+                </div>
+              </div>
+            </div>
+            <NodeHandler v-model:child-node="item.childNode" :current-node="item" />
+          </div>
+        </div>
+        <!-- 递归显示子节点  -->
+        <ProcessNodeTree
+          v-if="item && item.childNode"
+          :parent-node="item"
+          v-model:flow-node="item.childNode"
+          @find:recursive-find-parent-node="recursiveFindParentNode"
+        />
+      </div>
+    </div>
+    <NodeHandler
+      v-if="currentNode"
+      v-model:child-node="currentNode.childNode"
+      :current-node="currentNode"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import NodeHandler from '../NodeHandler.vue'
+import ProcessNodeTree from '../ProcessNodeTree.vue'
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from '../consts'
+import { useTaskStatusClass } from '../node'
+import { generateUUID } from '@/utils'
+const { proxy } = getCurrentInstance() as any
+defineOptions({
+  name: 'ParallelNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+// 定义事件,更新父组件
+const emits = defineEmits<{
+  'update:modelValue': [node: SimpleFlowNode | undefined]
+  'find:parentNode': [nodeList: SimpleFlowNode[], nodeType: number]
+  'find:recursiveFindParentNode': [
+    nodeList: SimpleFlowNode[],
+    curentNode: SimpleFlowNode,
+    nodeType: number
+  ]
+}>()
+
+const currentNode = ref<SimpleFlowNode>(props.flowNode)
+// 是否只读
+const readonly = inject<Boolean>('readonly')
+
+watch(
+  () => props.flowNode,
+  (newValue) => {
+    currentNode.value = newValue
+  }
+)
+
+const showInputs = ref<boolean[]>([])
+// 失去焦点
+const blurEvent = (index: number) => {
+  showInputs.value[index] = false
+  const conditionNode = currentNode.value.conditionNodes?.at(index) as SimpleFlowNode
+  conditionNode.name = conditionNode.name || `并行${index + 1}`
+}
+
+// 点击条件名称
+const clickEvent = (index: number) => {
+  showInputs.value[index] = true
+}
+
+const conditionNodeConfig = (nodeId: string) => {
+  const conditionNode = proxy.$refs[nodeId][0]
+  conditionNode.open()
+}
+
+// 新增条件
+const addCondition = () => {
+  const conditionNodes = currentNode.value.conditionNodes
+  if (conditionNodes) {
+    const len = conditionNodes.length
+    let lastIndex = len - 1
+    const conditionData: SimpleFlowNode = {
+      id: 'Flow_' + generateUUID(),
+      name: '并行' + len,
+      showText: '无需配置条件同时执行',
+      type: NodeType.CONDITION_NODE,
+      childNode: undefined,
+      conditionNodes: []
+    }
+    conditionNodes.splice(lastIndex, 0, conditionData)
+  }
+}
+
+// 删除条件
+const deleteCondition = (index: number) => {
+  const conditionNodes = currentNode.value.conditionNodes
+  if (conditionNodes) {
+    conditionNodes.splice(index, 1)
+    if (conditionNodes.length == 1) {
+      const childNode = currentNode.value.childNode
+      // 更新此节点为后续孩子节点
+      emits('update:modelValue', childNode)
+    }
+  }
+}
+
+// 递归从父节点中查询匹配的节点
+const recursiveFindParentNode = (
+  nodeList: SimpleFlowNode[],
+  node: SimpleFlowNode,
+  nodeType: number
+) => {
+  if (!node || node.type === NodeType.START_USER_NODE) {
+    return
+  }
+  if (node.type === nodeType) {
+    nodeList.push(node)
+  }
+  // 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点并行节点(NodeType.PARALLEL_NODE) 继续查找
+  emits('find:parentNode', nodeList, nodeType)
+}
+</script>

+ 154 - 0
src/components/SimpleProcessDesignerV2/src/nodes/StartUserNode.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="node-wrapper">
+    <div class="node-container">
+      <div
+        class="node-box"
+        :class="[
+          { 'node-config-error': !currentNode.showText },
+          `${useTaskStatusClass(currentNode?.activityStatus)}`
+        ]"
+      >
+        <div class="node-title-container">
+          <div class="node-title-icon start-user"
+            ><span class="iconfont icon-start-user"></span
+          ></div>
+          <input
+            v-if="showInput"
+            type="text"
+            class="editable-title-input"
+            @blur="blurEvent()"
+            v-mountedFocus
+            v-model="currentNode.name"
+            :placeholder="currentNode.name"
+          />
+          <div v-else class="node-title" @click="clickTitle">
+            {{ currentNode.name }}
+          </div>
+        </div>
+        <div class="node-content" @click="nodeClick">
+          <div class="node-text" :title="currentNode.showText" v-if="currentNode.showText">
+            {{ currentNode.showText }}
+          </div>
+          <div class="node-text" v-else>
+            {{ NODE_DEFAULT_TEXT.get(NodeType.START_USER_NODE) }}
+          </div>
+          <Icon icon="ep:arrow-right-bold" v-if="!readonly" />
+        </div>
+      </div>
+      <!-- 传递子节点给添加节点组件。会在子节点前面添加节点 -->
+      <NodeHandler
+        v-if="currentNode"
+        v-model:child-node="currentNode.childNode"
+        :current-node="currentNode"
+      />
+    </div>
+  </div>
+  <StartUserNodeConfig v-if="!readonly && currentNode" ref="nodeSetting" :flow-node="currentNode" />
+  <!-- 审批记录 -->
+  <el-dialog
+    :title="dialogTitle || '审批记录'"
+    v-model="dialogVisible"
+    width="1000px"
+    append-to-body
+  >
+    <el-row>
+      <el-table :data="selectTasks" size="small" border header-cell-class-name="table-header-gray">
+        <el-table-column
+          label="序号"
+          header-align="center"
+          align="center"
+          type="index"
+          width="50"
+        />
+        <el-table-column label="审批人" min-width="100" align="center">
+          <template #default="scope">
+            {{ scope.row.assigneeUser?.nickname || scope.row.ownerUser?.nickname }}
+          </template>
+        </el-table-column>
+
+        <el-table-column label="部门" min-width="100" align="center">
+          <template #default="scope">
+            {{ scope.row.assigneeUser?.deptName || scope.row.ownerUser?.deptName }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="开始时间"
+          prop="createTime"
+          min-width="140"
+        />
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="结束时间"
+          prop="endTime"
+          min-width="140"
+        />
+        <el-table-column align="center" label="审批状态" prop="status" min-width="90">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="审批建议" prop="reason" min-width="120" />
+        <el-table-column align="center" label="耗时" prop="durationInMillis" width="100">
+          <template #default="scope">
+            {{ formatPast2(scope.row.durationInMillis) }}
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-row>
+  </el-dialog>
+</template>
+<script setup lang="ts">
+import NodeHandler from '../NodeHandler.vue'
+import { useWatchNode, useNodeName2, useTaskStatusClass } from '../node'
+import { SimpleFlowNode, NODE_DEFAULT_TEXT, NodeType } from '../consts'
+import StartUserNodeConfig from '../nodes-config/StartUserNodeConfig.vue'
+import { dateFormatter, formatPast2 } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+defineOptions({
+  name: 'StartEventNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    default: () => null
+  }
+})
+const readonly = inject<Boolean>('readonly') // 是否只读
+const tasks = inject<Ref<any[]>>('tasks')
+// 定义事件,更新父组件。
+const emits = defineEmits<{
+  'update:modelValue': [node: SimpleFlowNode | undefined]
+}>()
+// 监控节点变化
+const currentNode = useWatchNode(props)
+// 节点名称编辑
+const { showInput, blurEvent, clickTitle } = useNodeName2(currentNode, NodeType.START_USER_NODE)
+
+const nodeSetting = ref()
+//
+const nodeClick = () => {
+  if (readonly) {
+    // 只读模式,弹窗显示任务信息
+    if (tasks && tasks.value) {
+      dialogTitle.value = currentNode.value.name
+      selectTasks.value = tasks.value.filter(
+        (item: any) => item?.taskDefinitionKey === currentNode.value.id
+      )
+      dialogVisible.value = true
+    }
+  } else {
+    // 编辑模式,打开节点配置、把当前节点传递给配置组件
+    nodeSetting.value.showStartUserNodeConfig(currentNode.value)
+    nodeSetting.value.openDrawer()
+  }
+}
+
+// 任务的弹窗显示,用于只读模式
+const dialogVisible = ref(false) // 弹窗可见性
+const dialogTitle = ref<string | undefined>(undefined) // 弹窗标题
+const selectTasks = ref<any[] | undefined>([]) // 选中的任务数组
+</script>
+<style lang="scss" scoped></style>

+ 174 - 0
src/components/SimpleProcessDesignerV2/src/nodes/UserTaskNode.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="node-wrapper">
+    <div class="node-container">
+      <div
+        class="node-box"
+        :class="[
+          { 'node-config-error': !currentNode.showText },
+          `${useTaskStatusClass(currentNode?.activityStatus)}`
+        ]"
+      >
+        <div class="node-title-container">
+          <div class="node-title-icon user-task"><span class="iconfont icon-approve"></span></div>
+          <input
+            v-if="!readonly && showInput"
+            type="text"
+            class="editable-title-input"
+            @blur="blurEvent()"
+            v-mountedFocus
+            v-model="currentNode.name"
+            :placeholder="currentNode.name"
+          />
+          <div v-else class="node-title" @click="clickTitle">
+            {{ currentNode.name }}
+          </div>
+        </div>
+        <div class="node-content" @click="nodeClick">
+          <div class="node-text" :title="currentNode.showText" v-if="currentNode.showText">
+            {{ currentNode.showText }}
+          </div>
+          <div class="node-text" v-else>
+            {{ NODE_DEFAULT_TEXT.get(NodeType.USER_TASK_NODE) }}
+          </div>
+          <Icon icon="ep:arrow-right-bold" v-if="!readonly" />
+        </div>
+        <div v-if="!readonly" class="node-toolbar">
+          <div class="toolbar-icon"
+            ><Icon color="#0089ff" icon="ep:circle-close-filled" :size="18" @click="deleteNode"
+          /></div>
+        </div>
+      </div>
+      <!-- 传递子节点给添加节点组件。会在子节点前面添加节点 -->
+      <NodeHandler
+        v-if="currentNode"
+        v-model:child-node="currentNode.childNode"
+        :current-node="currentNode"
+      />
+    </div>
+  </div>
+  <UserTaskNodeConfig
+    v-if="currentNode"
+    ref="nodeSetting"
+    :flow-node="currentNode"
+    @find:return-task-nodes="findReturnTaskNodes"
+  />
+  <!-- 审批记录 -->
+  <el-dialog
+    :title="dialogTitle || '审批记录'"
+    v-model="dialogVisible"
+    width="1000px"
+    append-to-body
+  >
+    <el-row>
+      <el-table :data="selectTasks" size="small" border header-cell-class-name="table-header-gray">
+        <el-table-column
+          label="序号"
+          header-align="center"
+          align="center"
+          type="index"
+          width="50"
+        />
+        <el-table-column label="审批人" min-width="100" align="center">
+          <template #default="scope">
+            {{ scope.row.assigneeUser?.nickname || scope.row.ownerUser?.nickname }}
+          </template>
+        </el-table-column>
+
+        <el-table-column label="部门" min-width="100" align="center">
+          <template #default="scope">
+            {{ scope.row.assigneeUser?.deptName || scope.row.ownerUser?.deptName }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="开始时间"
+          prop="createTime"
+          min-width="140"
+        />
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="结束时间"
+          prop="endTime"
+          min-width="140"
+        />
+        <el-table-column align="center" label="审批状态" prop="status" min-width="90">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="审批建议" prop="reason" min-width="120" />
+        <el-table-column align="center" label="耗时" prop="durationInMillis" width="100">
+          <template #default="scope">
+            {{ formatPast2(scope.row.durationInMillis) }}
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-row>
+  </el-dialog>
+</template>
+<script setup lang="ts">
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from '../consts'
+import { useWatchNode, useNodeName2, useTaskStatusClass } from '../node'
+import NodeHandler from '../NodeHandler.vue'
+import UserTaskNodeConfig from '../nodes-config/UserTaskNodeConfig.vue'
+import { dateFormatter, formatPast2 } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+defineOptions({
+  name: 'UserTaskNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    required: true
+  }
+})
+const emits = defineEmits<{
+  'update:flowNode': [node: SimpleFlowNode | undefined]
+  'find:parentNode': [nodeList: SimpleFlowNode[], nodeType: NodeType]
+}>()
+
+// 是否只读
+const readonly = inject<Boolean>('readonly')
+const tasks = inject<Ref<any[]>>('tasks')
+// 监控节点变化
+const currentNode = useWatchNode(props)
+// 节点名称编辑
+const { showInput, blurEvent, clickTitle } = useNodeName2(currentNode, NodeType.START_USER_NODE)
+const nodeSetting = ref()
+
+const nodeClick = () => {
+  if (readonly) {
+    if (tasks && tasks.value) {
+      dialogTitle.value = currentNode.value.name
+      // 只读模式,弹窗显示任务信息
+      selectTasks.value = tasks.value.filter(
+        (item: any) => item?.taskDefinitionKey === currentNode.value.id
+      )
+      dialogVisible.value = true
+    }
+  } else {
+    // 编辑模式,打开节点配置、把当前节点传递给配置组件
+    nodeSetting.value.showUserTaskNodeConfig(currentNode.value)
+    nodeSetting.value.openDrawer()
+  }
+}
+
+const deleteNode = () => {
+  emits('update:flowNode', currentNode.value.childNode)
+}
+// 查找可以驳回用户节点
+const findReturnTaskNodes = (
+  matchNodeList: SimpleFlowNode[] // 匹配的节点
+) => {
+  // 从父节点查找
+  emits('find:parentNode', matchNodeList, NodeType.USER_TASK_NODE)
+}
+
+// 任务的弹窗显示,用于只读模式
+const dialogVisible = ref(false) // 弹窗可见性
+const dialogTitle = ref<string | undefined>(undefined) // 弹窗标题
+const selectTasks = ref<any[] | undefined>([]) // 选中的任务数组
+</script>
+<style lang="scss" scoped></style>

+ 41 - 0
src/components/SimpleProcessDesignerV2/src/utils.ts

@@ -0,0 +1,41 @@
+import { TimeUnitType, ApproveType, APPROVE_TYPE } from './consts'
+
+// 获取条件节点默认的名称
+export const getDefaultConditionNodeName = (index: number, defaultFlow: boolean | undefined): string => {
+  if (defaultFlow) {
+    return '其它情况'
+  }
+  return '条件' + (index + 1)
+}
+
+// 获取包容分支条件节点默认的名称
+export const getDefaultInclusiveConditionNodeName = (index: number, defaultFlow: boolean | undefined): string => {
+  if (defaultFlow) {
+    return '其它情况'
+  }
+  return '包容条件' + (index + 1)
+}
+
+export const convertTimeUnit = (strTimeUnit: string) => {
+  if (strTimeUnit === 'M') {
+    return TimeUnitType.MINUTE
+  }
+  if (strTimeUnit === 'H') {
+    return TimeUnitType.HOUR
+  }
+  if (strTimeUnit === 'D') {
+    return TimeUnitType.DAY
+  }
+  return TimeUnitType.HOUR
+}
+
+export const getApproveTypeText = (approveType: ApproveType): string => {
+  let approveTypeText = ''
+  APPROVE_TYPE.forEach((item) => {
+    if (item.value === approveType) {
+      approveTypeText = item.label
+      return
+    }
+  })
+  return approveTypeText
+}

二進制
src/components/SimpleProcessDesignerV2/theme/iconfont.ttf


二進制
src/components/SimpleProcessDesignerV2/theme/iconfont.woff


二進制
src/components/SimpleProcessDesignerV2/theme/iconfont.woff2


+ 750 - 0
src/components/SimpleProcessDesignerV2/theme/simple-process-designer.scss

@@ -0,0 +1,750 @@
+// 配置节点头部
+.config-header {
+  display: flex;
+  flex-direction: column;
+
+  .node-name {
+    display: flex;
+    height: 24px;
+    line-height: 24px;
+    font-size: 16px;
+    cursor: pointer;
+    align-items: center;
+  }
+
+  .divide-line {
+    width: 100%;
+    height: 1px;
+    margin-top: 16px;
+    background: #eee;
+  }
+
+  .config-editable-input {
+    height: 24px;
+    max-width: 510px;
+    font-size: 16px;
+    line-height: 24px;
+    border: 1px solid #d9d9d9;
+    border-radius: 4px;
+    transition: all 0.3s;
+
+    &:focus {
+      border-color: #40a9ff;
+      outline: 0;
+      box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+    }
+  }
+}
+
+// 表单字段权限
+.field-setting-pane {
+  display: flex;
+  flex-direction: column;
+  font-size: 14px;
+
+  .field-setting-desc {
+    padding-right: 8px;
+    margin-bottom: 16px;
+    font-size: 16px;
+    font-weight: 700;
+  }
+
+  .field-permit-title {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    height: 45px;
+    padding-left: 12px;
+    line-height: 45px;
+    background-color: #f8fafc0a;
+    border: 1px solid #1f38581a;
+
+    .first-title {
+      text-align: left !important;
+    }
+
+    .other-titles {
+      display: flex;
+      justify-content: space-between;
+    }
+
+    .setting-title-label {
+      display: inline-block;
+      width: 110px;
+      padding: 5px 0;
+      font-size: 13px;
+      font-weight: 700;
+      color: #000;
+      text-align: center;
+    }
+  }
+
+  .field-setting-item {
+    align-items: center;
+    display: flex;
+    justify-content: space-between;
+    height: 38px;
+    padding-left: 12px;
+    border: 1px solid #1f38581a;
+    border-top: 0;
+
+    .field-setting-item-label {
+      display: inline-block;
+      width: 110px;
+      min-height: 16px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      cursor: text;
+    }
+
+    .field-setting-item-group {
+      display: flex;
+      justify-content: space-between;
+
+      .item-radio-wrap {
+        display: inline-block;
+        width: 110px;
+        text-align: center;
+      }
+    }
+  }
+}
+
+// 节点连线气泡卡片样式
+.handler-item-wrapper {
+  display: flex;
+  cursor: pointer;
+
+  .handler-item {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+  }
+
+  .handler-item-icon {
+    width: 60px;
+    height: 60px;
+    background: #fff;
+    border: 1px solid #e2e2e2;
+    border-radius: 50%;
+    user-select: none;
+    text-align: center;
+
+    &:hover {
+      background: #e2e2e2;
+      box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
+    }
+
+    .icon-size {
+      font-size: 25px;
+      line-height: 60px;
+    }
+  }
+
+  .approve {
+    color: #ff943e;
+  }
+  .copy {
+    color: #3296fa;
+  }
+
+  .condition {
+    color: #67c23a;
+  }
+
+  .parallel {
+    color: #626aef;
+  }
+
+  .inclusive {
+    color: #345da2;
+  }
+
+  .handler-item-text {
+    margin-top: 4px;
+    width: 80px;
+    text-align: center;
+    font-size: 13px;
+  }
+}
+// Simple 流程模型样式
+.simple-process-model-container {
+  height: 100%;
+  padding-top: 32px;
+  background-color: #fafafa;
+  .simple-process-model {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    transform-origin: 50% 0 0;
+    overflow: auto;
+    transform: scale(1);
+    transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+    background: url(@/assets/svgs/bpm/simple-process-bg.svg) 0 0 repeat;
+    // 节点容器 定义节点宽度
+    .node-container {
+      width: 200px;
+    }
+    // 节点
+    .node-box {
+      position: relative;
+      display: flex;
+      min-height: 70px;
+      padding: 5px 10px 8px;
+      cursor: pointer;
+      background-color: #fff;
+      flex-direction: column;
+      border: 2px solid transparent;
+      border-radius: 8px;
+      box-shadow: 0 1px 4px 0 rgb(10 30 65 / 16%);
+      transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
+
+      &.status-pass {
+        background-color: #a9da90;
+        border-color: #67c23a;
+      }
+
+      &.status-pass:hover {
+        border-color: #67c23a;
+      }
+
+      &.status-running {
+        background-color: #e7f0fe;
+        border-color: #5a9cf8;
+      }
+
+      &.status-running:hover {
+        border-color: #5a9cf8;
+      }
+
+      &.status-reject {
+        background-color: #f6e5e5;
+        border-color: #e47470;
+      }
+
+      &.status-reject:hover {
+        border-color: #e47470;
+      }
+
+      &:hover {
+        border-color: #0089ff;
+
+        .node-toolbar {
+          opacity: 1;
+        }
+
+        .branch-node-move {
+          display: flex;
+        }
+      }
+
+      // 普通节点标题
+      .node-title-container {
+        display: flex;
+        padding: 4px;
+        cursor: pointer;
+        border-radius: 4px 4px 0 0;
+        align-items: center;
+
+        .node-title-icon {
+          display: flex;
+          align-items: center;
+
+          &.user-task {
+            color: #ff943e;
+          }
+
+          &.copy-task {
+            color: #3296fa;
+          }
+
+          &.start-user {
+            color: #676565;
+          }
+        }
+
+        .node-title {
+          margin-left: 4px;
+          overflow: hidden;
+          font-size: 14px;
+          font-weight: 600;
+          line-height: 18px;
+          color: #1f1f1f;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+
+          &:hover {
+            border-bottom: 1px dashed #f60;
+          }
+        }
+      }
+
+      // 条件节点标题
+      .branch-node-title-container {
+        display: flex;
+        padding: 4px 0;
+        cursor: pointer;
+        border-radius: 4px 4px 0 0;
+        align-items: center;
+        justify-content: space-between;
+
+        .input-max-width {
+          max-width: 115px !important;
+        }
+
+        .branch-title {
+          overflow: hidden;
+          font-size: 13px;
+          font-weight: 600;
+          color: #f60;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+
+          &:hover {
+            border-bottom: 1px dashed #000;
+          }
+        }
+
+        .branch-priority {
+          min-width: 50px;
+          font-size: 12px;
+        }
+      }
+
+      .node-content {
+        display: flex;
+        min-height: 32px;
+        padding: 4px 8px;
+        margin-top: 4px;
+        line-height: 32px;
+        justify-content: space-between;
+        align-items: center;
+        color: #111f2c;
+        background: rgb(0 0 0 / 3%);
+        border-radius: 4px;
+
+        .node-text {
+          display: -webkit-box;
+          overflow: hidden;
+          font-size: 14px;
+          line-height: 24px;
+          text-overflow: ellipsis;
+          word-break: break-all;
+          -webkit-line-clamp: 2; /* 这将限制文本显示为两行 */
+          -webkit-box-orient: vertical;
+        }
+      }
+
+      //条件节点内容
+      .branch-node-content {
+        display: flex;
+        min-height: 32px;
+        padding: 4px 0;
+        margin-top: 4px;
+        line-height: 32px;
+        align-items: center;
+        color: #111f2c;
+        border-radius: 4px;
+
+        .branch-node-text {
+          overflow: hidden;
+          font-size: 12px;
+          line-height: 24px;
+          text-overflow: ellipsis;
+          word-break: break-all;
+          -webkit-line-clamp: 2; /* 这将限制文本显示为两行 */
+          -webkit-box-orient: vertical;
+        }
+      }
+
+      // 节点操作 :删除
+      .node-toolbar {
+        position: absolute;
+        top: -20px;
+        right: 0;
+        display: flex;
+        opacity: 0;
+
+        .toolbar-icon {
+          text-align: center;
+          vertical-align: middle;
+        }
+      }
+
+      // 条件节点左右移动
+      .branch-node-move {
+        position: absolute;
+        display: none;
+        width: 10px;
+        height: 100%;
+        cursor: pointer;
+        align-items: center;
+        justify-content: center;
+      }
+
+      .move-node-left {
+        top: 0;
+        left: -2px;
+        background: rgb(126 134 142 / 8%);
+        border-bottom-left-radius: 8px;
+        border-top-left-radius: 8px;
+      }
+
+      .move-node-right {
+        top: 0;
+        right: -2px;
+        background: rgb(126 134 142 / 8%);
+        border-top-right-radius: 6px;
+        border-bottom-right-radius: 6px;
+      }
+    }
+
+    .node-config-error {
+      border-color: #ff5219 !important;
+    }
+    // 普通节点包装
+    .node-wrapper {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+    }
+    // 节点连线处理
+    .node-handler-wrapper {
+      position: relative;
+      display: flex;
+      height: 70px;
+      align-items: center;
+      user-select: none;
+      justify-content: center;
+      flex-direction: column;
+
+      &::before {
+        position: absolute;
+        top: 0;
+        z-index: 0;
+        width: 2px;
+        height: 100%;
+        margin: auto;
+        background-color: #dedede;
+        content: '';
+      }
+
+      .node-handler {
+        .add-icon {
+          position: relative;
+          top: -5px;
+          display: flex;
+          width: 25px;
+          height: 25px;
+          color: #fff;
+          cursor: pointer;
+          background-color: #0089ff;
+          border-radius: 50%;
+          align-items: center;
+          justify-content: center;
+
+          &:hover {
+            transform: scale(1.1);
+          }
+        }
+      }
+
+      .node-handler-arrow {
+        position: absolute;
+        bottom: 0;
+        left: 50%;
+        display: flex;
+        transform: translateX(-50%);
+      }
+    }
+
+    // 条件节点包装
+    .branch-node-wrapper {
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+      margin-top: 16px;
+
+      .branch-node-container {
+        position: relative;
+        display: flex;
+
+        &::before {
+          position: absolute;
+          left: 50%;
+          width: 4px;
+          height: 100%;
+          background-color: #fafafa;
+          content: '';
+          transform: translate(-50%);
+        }
+
+        .branch-node-add {
+          position: absolute;
+          top: -18px;
+          left: 50%;
+          z-index: 1;
+          height: 36px;
+          padding: 0 10px;
+          font-size: 12px;
+          line-height: 36px;
+          border: 2px solid #dedede;
+          border-radius: 18px;
+          transform: translateX(-50%);
+          transform-origin: center center;
+        }
+
+        .branch-node-readonly {
+          position: absolute;
+          top: -18px;
+          left: 50%;
+          z-index: 1;
+          display: flex;
+          width: 36px;
+          height: 36px;
+          background-color: #fff;
+          border: 2px solid #dedede;
+          border-radius: 50%;
+          transform: translateX(-50%);
+          align-items: center;
+          justify-content: center;
+          transform-origin: center center;
+
+          &.status-pass {
+            background-color: #e9f4e2;
+            border-color: #6bb63c;
+          }
+
+          &.status-pass:hover {
+            border-color: #6bb63c;
+          }
+
+          .icon-size {
+            font-size: 22px;
+            &.condition {
+              color: #67c23a;
+            }
+            &.parallel {
+              color: #626aef;
+            }
+            &.inclusive {
+              color: #345da2;
+            }
+          }
+        }
+
+        .branch-node-item {
+          position: relative;
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          min-width: 280px;
+          padding: 40px 40px 0;
+          background: transparent;
+          border-top: 2px solid #dedede;
+          border-bottom: 2px solid #dedede;
+
+          &::before {
+            position: absolute;
+            width: 2px;
+            height: 100%;
+            margin: auto;
+            inset: 0;
+            background-color: #dedede;
+            content: '';
+          }
+        }
+        // 覆盖条件节点第一个节点左上角的线
+        .branch-line-first-top {
+          position: absolute;
+          top: -5px;
+          left: -1px;
+          width: 50%;
+          height: 7px;
+          background-color: #fafafa;
+          content: '';
+        }
+        // 覆盖条件节点第一个节点左下角的线
+        .branch-line-first-bottom {
+          position: absolute;
+          bottom: -5px;
+          left: -1px;
+          width: 50%;
+          height: 7px;
+          background-color: #fafafa;
+          content: '';
+        }
+        // 覆盖条件节点最后一个节点右上角的线
+        .branch-line-last-top {
+          position: absolute;
+          top: -5px;
+          right: -1px;
+          width: 50%;
+          height: 7px;
+          background-color: #fafafa;
+          content: '';
+        }
+        // 覆盖条件节点最后一个节点右下角的线
+        .branch-line-last-bottom {
+          position: absolute;
+          right: -1px;
+          bottom: -5px;
+          width: 50%;
+          height: 7px;
+          background-color: #fafafa;
+          content: '';
+        }
+      }
+    }
+
+    .node-fixed-name {
+      display: inline-block;
+      width: auto;
+      padding: 0 4px;
+      overflow: hidden;
+      text-align: center;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    // 开始节点包装
+    .start-node-wrapper {
+      position: relative;
+      margin-top: 16px;
+
+      .start-node-container {
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        align-items: center;
+
+        .start-node-box {
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          width: 90px;
+          height: 36px;
+          padding: 3px 4px;
+          color: #212121;
+          cursor: pointer;
+          background: #fafafa;
+          border-radius: 30px;
+          box-shadow: 0 1px 5px 0 rgb(10 30 65 / 8%);
+          box-sizing: border-box;
+        }
+      }
+    }
+
+    // 结束节点包装
+    .end-node-wrapper {
+      margin-bottom: 16px;
+
+      .end-node-box {
+        display: flex;
+        width: 80px;
+        height: 36px;
+        color: #212121;
+        border: 2px solid #fafafa;
+        border-radius: 30px;
+        box-shadow: 0 1px 5px 0 rgb(10 30 65 / 8%);
+        box-sizing: border-box;
+        justify-content: center;
+        align-items: center;
+
+        &.status-pass {
+          background-color: #a9da90;
+          border-color: #6bb63c;
+        }
+
+        &.status-pass:hover {
+          border-color: #6bb63c;
+        }
+
+        &.status-reject {
+          background-color: #f6e5e5;
+          border-color: #e47470;
+        }
+
+        &.status-reject:hover {
+          border-color: #e47470;
+        }
+
+        &.status-cancel {
+          background-color: #eaeaeb;
+          border-color: #919398;
+        }
+
+        &.status-cancel:hover {
+          border-color: #919398;
+        }
+      }
+    }
+
+    // 可编辑的 title 输入框
+    .editable-title-input {
+      height: 20px;
+      max-width: 145px;
+      margin-left: 4px;
+      font-size: 12px;
+      line-height: 20px;
+      border: 1px solid #d9d9d9;
+      border-radius: 4px;
+      transition: all 0.3s;
+
+      &:focus {
+        border-color: #40a9ff;
+        outline: 0;
+        box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
+      }
+    }
+  }
+}
+
+// iconfont 样式
+@font-face {
+  font-family: 'iconfont'; /* Project id 4495938 */
+  src:
+    url('iconfont.woff2?t=1724339470412') format('woff2'),
+    url('iconfont.woff?t=1724339470412') format('woff'),
+    url('iconfont.ttf?t=1724339470412') format('truetype');
+}
+
+.iconfont {
+  font-family: 'iconfont' !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-start-user:before {
+  content: '\e679';
+}
+
+.icon-inclusive:before {
+  content: '\e602';
+}
+
+.icon-copy:before {
+  content: '\e7eb';
+}
+
+.icon-handle:before {
+  content: '\e61c';
+}
+
+.icon-exclusive:before {
+  content: '\e717';
+}
+
+.icon-approve:before {
+  content: '\e715';
+}
+
+.icon-parallel:before {
+  content: '\e688';
+}

+ 9 - 14
src/components/UploadFile/src/UploadImgs.vue

@@ -25,7 +25,7 @@
       <template #file="{ file }">
         <img :src="file.url" class="upload-image" />
         <div class="upload-handle" @click.stop>
-          <div class="handle-icon" @click="handlePictureCardPreview(file)">
+          <div class="handle-icon" @click="imagePreview(file.url!)">
             <Icon icon="ep:zoom-in" />
             <span>查看</span>
           </div>
@@ -39,16 +39,12 @@
     <div class="el-upload__tip">
       <slot name="tip"></slot>
     </div>
-    <el-image-viewer
-      v-if="imgViewVisible"
-      :url-list="[viewImageUrl]"
-      @close="imgViewVisible = false"
-    />
   </div>
 </template>
 <script lang="ts" setup>
 import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
 import { ElNotification } from 'element-plus'
+import { createImageViewer } from '@/components/ImageViewer'
 
 import { propTypes } from '@/utils/propTypes'
 import { useUpload } from '@/components/UploadFile/src/useUpload'
@@ -56,6 +52,13 @@ import { useUpload } from '@/components/UploadFile/src/useUpload'
 defineOptions({ name: 'UploadImgs' })
 
 const message = useMessage() // 消息弹窗
+// 查看图片
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
 
 type FileTypes =
   | 'image/apng'
@@ -178,14 +181,6 @@ const handleExceed = () => {
     type: 'warning'
   })
 }
-
-// 图片预览
-const viewImageUrl = ref('')
-const imgViewVisible = ref(false)
-const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
-  viewImageUrl.value = uploadFile.url!
-  imgViewVisible.value = true
-}
 </script>
 
 <style lang="scss" scoped>

+ 20 - 11
src/components/UploadFile/src/useUpload.ts

@@ -3,9 +3,16 @@ import CryptoJS from 'crypto-js'
 import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
 import axios from 'axios'
 
+/**
+ * 获得上传 URL
+ */
+export const getUploadUrl = (): string => {
+  return import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/infra/file/upload'
+}
+
 export const useUpload = () => {
   // 后端上传地址
-  const uploadUrl = import.meta.env.VITE_UPLOAD_URL
+  const uploadUrl = getUploadUrl()
   // 是否使用前端直连上传
   const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
   // 重写ElUpload上传方法
@@ -17,16 +24,18 @@ export const useUpload = () => {
       // 1.2 获取文件预签名地址
       const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
       // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
-      return axios.put(presignedInfo.uploadUrl, options.file, {
-        headers: {
-          'Content-Type': options.file.type,
-        }
-      }).then(() => {
-        // 1.4. 记录文件信息到后端(异步)
-        createFile(presignedInfo, fileName, options.file)
-        // 通知成功,数据格式保持与后端上传的返回结果一致
-        return { data: presignedInfo.url }
-      })
+      return axios
+        .put(presignedInfo.uploadUrl, options.file, {
+          headers: {
+            'Content-Type': options.file.type
+          }
+        })
+        .then(() => {
+          // 1.4. 记录文件信息到后端(异步)
+          createFile(presignedInfo, fileName, options.file)
+          // 通知成功,数据格式保持与后端上传的返回结果一致
+          return { data: presignedInfo.url }
+        })
     } else {
       // 模式二:后端上传
       // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子

+ 152 - 0
src/components/UserSelectForm/index.vue

@@ -0,0 +1,152 @@
+<template>
+  <Dialog v-model="dialogVisible" title="人员选择" width="800">
+    <el-row class="gap2" v-loading="formLoading">
+      <el-col :span="6">
+        <ContentWrap class="h-1/1">
+          <el-tree
+            ref="treeRef"
+            :data="deptTree"
+            :expand-on-click-node="false"
+            :props="defaultProps"
+            default-expand-all
+            highlight-current
+            node-key="id"
+            @node-click="handleNodeClick"
+          />
+        </ContentWrap>
+      </el-col>
+      <el-col :span="17">
+        <el-transfer
+          v-model="selectedUserIdList"
+          :titles="['未选', '已选']"
+          filterable
+          filter-placeholder="搜索成员"
+          :data="transferUserList"
+          :props="{ label: 'nickname', key: 'id' }"
+        />
+      </el-col>
+    </el-row>
+    <template #footer>
+      <el-button
+        :disabled="formLoading || !selectedUserIdList?.length"
+        type="primary"
+        @click="submitForm"
+      >
+        确 定
+      </el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { defaultProps, findTreeNode, handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+import * as UserApi from '@/api/system/user'
+
+defineOptions({ name: 'UserSelectForm' })
+const emit = defineEmits<{
+  confirm: [id: any, userList: any[]]
+}>()
+const { t } = useI18n() // 国际
+const message = useMessage() // 消息弹窗
+const deptTree = ref<Tree[]>([]) // 部门树形结构化
+const userList = ref<UserApi.UserVO[]>([]) // 所有用户列表
+const filteredUserList = ref<UserApi.UserVO[]>([]) // 当前部门过滤后的用户列表
+const selectedUserIdList: any = ref([]) // 选中的用户列表
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const activityId = ref()
+
+/** 计算属性:合并已选择的用户和当前部门过滤后的用户 */
+const transferUserList = computed(() => {
+  // 1.1 获取所有已选择的用户
+  const selectedUsers = userList.value.filter((user: any) =>
+    selectedUserIdList.value.includes(user.id)
+  )
+
+  // 1.2 获取当前部门过滤后的未选择用户
+  const filteredUnselectedUsers = filteredUserList.value.filter(
+    (user: any) => !selectedUserIdList.value.includes(user.id)
+  )
+
+  // 2. 合并并去重
+  return [...selectedUsers, ...filteredUnselectedUsers]
+})
+
+/** 打开弹窗 */
+const open = async (id: number, selectedList?: any[]) => {
+  activityId.value = id
+  resetForm()
+
+  // 加载部门、用户列表
+  deptTree.value = handleTree(await DeptApi.getSimpleDeptList())
+  userList.value = await UserApi.getSimpleUserList()
+
+  // 初始状态下,过滤列表等于所有用户列表
+  filteredUserList.value = [...userList.value]
+  selectedUserIdList.value = selectedList?.map((item: any) => item.id) || []
+  dialogVisible.value = true
+}
+
+/** 获取部门过滤后的用户列表 */
+const getUserList = async (deptId?: number) => {
+  formLoading.value = true
+  try {
+    // @ts-ignore
+    // TODO @芋艿:替换到 simple List 暂不支持 deptId 过滤
+    // TODO @Zqqq:这个,可以使用前端过滤么?通过 deptList 获取到 deptId 子节点,然后去 userList
+    const data = await UserApi.getUserPage({ pageSize: 100, pageNo: 1, deptId })
+    // 更新过滤后的用户列表
+    filteredUserList.value = data.list
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 提交选择 */
+const submitForm = async () => {
+  try {
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 从所有用户列表中筛选出已选择的用户
+    const emitUserList = userList.value.filter((user: any) =>
+      selectedUserIdList.value.includes(user.id)
+    )
+    // 发送操作成功的事件
+    emit('confirm', activityId.value, emitUserList)
+  } finally {
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  deptTree.value = []
+  userList.value = []
+  filteredUserList.value = []
+  selectedUserIdList.value = []
+}
+
+/** 处理部门被点击 */
+const handleNodeClick = (row: { [key: string]: any }) => {
+  getUserList(row.id)
+}
+
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>
+
+<style lang="scss" scoped>
+:deep() {
+  .el-transfer {
+    display: flex;
+  }
+  .el-transfer__buttons {
+    display: flex !important;
+    flex-direction: column-reverse;
+    justify-content: center;
+    gap: 20px;
+    .el-transfer__button:nth-child(2) {
+      margin: 0;
+    }
+  }
+}
+</style>

+ 327 - 612
src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue

@@ -1,664 +1,379 @@
 <template>
-  <div class="my-process-designer">
-    <div class="my-process-designer__container">
-      <div class="my-process-designer__canvas" style="height: 760px" ref="bpmnCanvas"></div>
+  <div class="process-viewer">
+    <div style="height: 100%" ref="processCanvas" v-show="!isLoading"> </div>
+    <!-- 自定义箭头样式,用于已完成状态下流程连线箭头 -->
+    <defs ref="customDefs">
+      <marker
+        id="sequenceflow-end-white-success"
+        viewBox="0 0 20 20"
+        refX="11"
+        refY="10"
+        markerWidth="10"
+        markerHeight="10"
+        orient="auto"
+      >
+        <path
+          class="success-arrow"
+          d="M 1 5 L 11 10 L 1 15 Z"
+          style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1"
+        />
+      </marker>
+      <marker
+        id="conditional-flow-marker-white-success"
+        viewBox="0 0 20 20"
+        refX="-1"
+        refY="10"
+        markerWidth="10"
+        markerHeight="10"
+        orient="auto"
+      >
+        <path
+          class="success-conditional"
+          d="M 0 10 L 8 6 L 16 10 L 8 14 Z"
+          style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1"
+        />
+      </marker>
+    </defs>
+
+    <!-- 审批记录 -->
+    <el-dialog :title="dialogTitle || '审批记录'" v-model="dialogVisible" width="1000px">
+      <el-row>
+        <el-table
+          :data="selectTasks"
+          size="small"
+          border
+          header-cell-class-name="table-header-gray"
+        >
+          <el-table-column
+            label="序号"
+            header-align="center"
+            align="center"
+            type="index"
+            width="50"
+          />
+          <el-table-column
+            label="审批人"
+            min-width="100"
+            align="center"
+            v-if="selectActivityType === 'bpmn:UserTask'"
+          >
+            <template #default="scope">
+              {{ scope.row.assigneeUser?.nickname || scope.row.ownerUser?.nickname }}
+            </template>
+          </el-table-column>
+          <el-table-column
+            label="发起人"
+            prop="assigneeUser.nickname"
+            min-width="100"
+            align="center"
+            v-else
+          />
+          <el-table-column label="部门" min-width="100" align="center">
+            <template #default="scope">
+              {{ scope.row.assigneeUser?.deptName || scope.row.ownerUser?.deptName }}
+            </template>
+          </el-table-column>
+          <el-table-column
+            :formatter="dateFormatter"
+            align="center"
+            label="开始时间"
+            prop="createTime"
+            min-width="140"
+          />
+          <el-table-column
+            :formatter="dateFormatter"
+            align="center"
+            label="结束时间"
+            prop="endTime"
+            min-width="140"
+          />
+          <el-table-column align="center" label="审批状态" prop="status" min-width="90">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" />
+            </template>
+          </el-table-column>
+          <el-table-column
+            align="center"
+            label="审批建议"
+            prop="reason"
+            min-width="120"
+            v-if="selectActivityType === 'bpmn:UserTask'"
+          />
+          <el-table-column align="center" label="耗时" prop="durationInMillis" width="100">
+            <template #default="scope">
+              {{ formatPast2(scope.row.durationInMillis) }}
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-row>
+    </el-dialog>
+
+    <!-- Zoom:放大、缩小 -->
+    <div style="position: absolute; top: 0; left: 0; width: 100%">
+      <el-row type="flex" justify="end">
+        <el-button-group key="scale-control" size="default">
+          <el-button
+            size="default"
+            :plain="true"
+            :disabled="defaultZoom <= 0.3"
+            :icon="ZoomOut"
+            @click="processZoomOut()"
+          />
+          <el-button size="default" style="width: 90px">
+            {{ Math.floor(defaultZoom * 10 * 10) + '%' }}
+          </el-button>
+          <el-button
+            size="default"
+            :plain="true"
+            :disabled="defaultZoom >= 3.9"
+            :icon="ZoomIn"
+            @click="processZoomIn()"
+          />
+          <el-button size="default" :icon="ScaleToOriginal" @click="processReZoom()" />
+        </el-button-group>
+      </el-row>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
+import '../theme/index.scss'
 import BpmnViewer from 'bpmn-js/lib/Viewer'
-import DefaultEmptyXML from './plugins/defaultEmpty'
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { formatDate } from '@/utils/formatTime'
-import { isEmpty } from '@/utils/is'
-
-defineOptions({ name: 'MyProcessViewer' })
+import MoveCanvasModule from 'diagram-js/lib/navigation/movecanvas'
+import { ZoomOut, ZoomIn, ScaleToOriginal } from '@element-plus/icons-vue'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter, formatPast2 } from '@/utils/formatTime'
+import { BpmProcessInstanceStatus } from '@/utils/constants'
 
 const props = defineProps({
-  value: {
-    // BPMN XML 字符串
-    type: String,
-    default: ''
-  },
-  prefix: {
-    // 使用哪个引擎
+  xml: {
     type: String,
-    default: 'camunda'
+    required: true
   },
-  activityData: {
-    // 活动的数据。传递时,可高亮流程
-    type: Array,
-    default: () => []
-  },
-  processInstanceData: {
-    // 流程实例的数据。传递时,可展示流程发起人等信息
+  view: {
     type: Object,
-    default: () => {}
-  },
-  taskData: {
-    // 任务实例的数据。传递时,可展示 UserTask 审核相关的信息
-    type: Array,
-    default: () => []
+    require: true
   }
 })
 
-provide('configGlobal', props)
-
-const emit = defineEmits(['destroy'])
+const processCanvas = ref()
+const bpmnViewer = ref<BpmnViewer | null>(null)
+const customDefs = ref()
+const defaultZoom = ref(1) // 默认缩放比例
+const isLoading = ref(false) // 是否加载中
 
-let bpmnModeler
+const processInstance = ref<any>({}) // 流程实例
+const tasks = ref([]) // 流程任务
 
-const xml = ref('')
-const activityLists = ref<any[]>([])
-const processInstance = ref<any>(undefined)
-const taskList = ref<any[]>([])
-const bpmnCanvas = ref()
-// const element = ref()
-const elementOverlayIds = ref<any>(null)
-const overlays = ref<any>(null)
+const dialogVisible = ref(false) // 弹窗可见性
+const dialogTitle = ref<string | undefined>(undefined) // 弹窗标题
+const selectActivityType = ref<string | undefined>(undefined) // 选中 Task 的活动编号
+const selectTasks = ref<any[]>([]) // 选中的任务数组
 
-const initBpmnModeler = () => {
-  if (bpmnModeler) return
-  bpmnModeler = new BpmnViewer({
-    container: bpmnCanvas.value,
-    bpmnRenderer: {}
-  })
+/** Zoom:恢复 */
+const processReZoom = () => {
+  defaultZoom.value = 1
+  bpmnViewer.value?.get('canvas').zoom('fit-viewport', 'auto')
 }
 
-/* 创建新的流程图 */
-const createNewDiagram = async (xml) => {
-  // 将字符串转换成图显示出来
-  let newId = `Process_${new Date().getTime()}`
-  let newName = `业务流程_${new Date().getTime()}`
-  let xmlString = xml || DefaultEmptyXML(newId, newName, props.prefix)
-  try {
-    let { warnings } = await bpmnModeler.importXML(xmlString)
-    if (warnings && warnings.length) {
-      warnings.forEach((warn) => console.warn(warn))
-    }
-    // 高亮流程图
-    await highlightDiagram()
-    const canvas = bpmnModeler.get('canvas')
-    canvas.zoom('fit-viewport', 'auto')
-  } catch (e) {
-    console.error(e)
-    // console.error(`[Process Designer Warn]: ${e?.message || e}`);
+/** Zoom:放大 */
+const processZoomIn = (zoomStep = 0.1) => {
+  let newZoom = Math.floor(defaultZoom.value * 100 + zoomStep * 100) / 100
+  if (newZoom > 4) {
+    throw new Error('[Process Designer Warn ]: The zoom ratio cannot be greater than 4')
   }
+  defaultZoom.value = newZoom
+  bpmnViewer.value?.get('canvas').zoom(defaultZoom.value)
 }
 
-/* 高亮流程图 */
-// TODO 芋艿:如果多个 endActivity 的话,目前的逻辑可能有一定的问题。https://www.jdon.com/workflow/multi-events.html
-const highlightDiagram = async () => {
-  const activityList = activityLists.value
-  if (activityList.length === 0) {
-    return
-  }
-  // 参考自 https://gitee.com/tony2y/RuoYi-flowable/blob/master/ruoyi-ui/src/components/Process/index.vue#L222 实现
-  // 再次基础上,增加不同审批结果的颜色等等
-  let canvas = bpmnModeler.get('canvas')
-  let todoActivity: any = activityList.find((m: any) => !m.endTime) // 找到待办的任务
-  let endActivity: any = activityList[activityList.length - 1] // 获得最后一个任务
-  let findProcessTask = false //是否已经高亮了进行中的任务
-  //进行中高亮之后的任务 key 集合,用于过滤掉 taskList 进行中后面的任务,避免进行中后面的数据 Hover 还有数据
-  let removeTaskDefinitionKeyList = []
-  // debugger
-  bpmnModeler.getDefinitions().rootElements[0].flowElements?.forEach((n: any) => {
-    let activity: any = activityList.find((m: any) => m.key === n.id) // 找到对应的活动
-    if (!activity) {
-      return
-    }
-    if (n.$type === 'bpmn:UserTask') {
-      // 用户任务
-      // 处理用户任务的高亮
-      const task: any = taskList.value.find((m: any) => m.id === activity.taskId) // 找到活动对应的 taskId
-      if (!task) {
-        return
-      }
-      // 进行中的任务已经高亮过了,则不高亮后面的任务了
-      if (findProcessTask) {
-        removeTaskDefinitionKeyList.push(n.id)
-        return
-      }
-      // 高亮任务
-      canvas.addMarker(n.id, getResultCss(task.status))
-      //标记是否高亮了进行中任务
-      if (task.status === 1) {
-        findProcessTask = true
-      }
-      // 如果非通过,就不走后面的线条了
-      if (task.status !== 2) {
-        return
-      }
-      // 处理 outgoing 出线
-      const outgoing = getActivityOutgoing(activity)
-      outgoing?.forEach((nn: any) => {
-        // debugger
-        let targetActivity: any = activityList.find((m: any) => m.key === nn.targetRef.id)
-        // 如果目标活动存在,则根据该活动是否结束,进行【bpmn:SequenceFlow】连线的高亮设置
-        if (targetActivity) {
-          canvas.addMarker(nn.id, targetActivity.endTime ? 'highlight' : 'highlight-todo')
-        } else if (nn.targetRef.$type === 'bpmn:ExclusiveGateway') {
-          // TODO 芋艿:这个流程,暂时没走到过
-          canvas.addMarker(nn.id, activity.endTime ? 'highlight' : 'highlight-todo')
-          canvas.addMarker(nn.targetRef.id, activity.endTime ? 'highlight' : 'highlight-todo')
-        } else if (nn.targetRef.$type === 'bpmn:EndEvent') {
-          // TODO 芋艿:这个流程,暂时没走到过
-          if (!todoActivity && endActivity.key === n.id) {
-            canvas.addMarker(nn.id, 'highlight')
-            canvas.addMarker(nn.targetRef.id, 'highlight')
-          }
-          if (!activity.endTime) {
-            canvas.addMarker(nn.id, 'highlight-todo')
-            canvas.addMarker(nn.targetRef.id, 'highlight-todo')
-          }
-        }
-      })
-    } else if (n.$type === 'bpmn:ExclusiveGateway') {
-      // 排它网关
-      // 设置【bpmn:ExclusiveGateway】排它网关的高亮
-      canvas.addMarker(n.id, getActivityHighlightCss(activity))
-      // 查找需要高亮的连线
-      let matchNN: any = undefined
-      let matchActivity: any = undefined
-      n.outgoing?.forEach((nn: any) => {
-        let targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id)
-        if (!targetActivity) {
-          return
-        }
-        // 特殊判断 endEvent 类型的原因,ExclusiveGateway 可能后续连有 2 个路径:
-        //  1. 一个是 UserTask => EndEvent
-        //  2. 一个是 EndEvent
-        // 在选择路径 1 时,其实 EndEvent 可能也存在,导致 1 和 2 都高亮,显然是不正确的。
-        // 所以,在 matchActivity 为 EndEvent 时,需要进行覆盖~~
-        if (!matchActivity || matchActivity.type === 'endEvent') {
-          matchNN = nn
-          matchActivity = targetActivity
-        }
-      })
-      if (matchNN && matchActivity) {
-        canvas.addMarker(matchNN.id, getActivityHighlightCss(matchActivity))
-      }
-    } else if (n.$type === 'bpmn:ParallelGateway') {
-      // 并行网关
-      // 设置【bpmn:ParallelGateway】并行网关的高亮
-      canvas.addMarker(n.id, getActivityHighlightCss(activity))
-      n.outgoing?.forEach((nn: any) => {
-        // 获得连线是否有指向目标。如果有,则进行高亮
-        const targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id)
-        if (targetActivity) {
-          canvas.addMarker(nn.id, getActivityHighlightCss(targetActivity)) // 高亮【bpmn:SequenceFlow】连线
-          // 高亮【...】目标。其中 ... 可以是 bpm:UserTask、也可以是其它的。当然,如果是 bpm:UserTask 的话,其实不做高亮也没问题,因为上面有逻辑做了这块。
-          canvas.addMarker(nn.targetRef.id, getActivityHighlightCss(targetActivity))
-        }
-      })
-    } else if (n.$type === 'bpmn:StartEvent') {
-      // 开始节点
-      canvas.addMarker(n.id, 'highlight')
-      n.outgoing?.forEach((nn) => {
-        // outgoing 例如说【bpmn:SequenceFlow】连线
-        // 获得连线是否有指向目标。如果有,则进行高亮
-        let targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id)
-        if (targetActivity) {
-          canvas.addMarker(nn.id, 'highlight') // 高亮【bpmn:SequenceFlow】连线
-          canvas.addMarker(n.id, 'highlight') // 高亮【bpmn:StartEvent】开始节点(自己)
-        }
-      })
-    } else if (n.$type === 'bpmn:EndEvent') {
-      // 结束节点
-      if (!processInstance.value || processInstance.value.status === 1) {
-        return
-      }
-      canvas.addMarker(n.id, getResultCss(processInstance.value.status))
-    } else if (n.$type === 'bpmn:ServiceTask') {
-      //服务任务
-      if (activity.startTime > 0 && activity.endTime === 0) {
-        //进入执行,标识进行色
-        canvas.addMarker(n.id, getResultCss(1))
-      }
-      if (activity.endTime > 0) {
-        // 执行完成,节点标识完成色, 所有outgoing标识完成色。
-        canvas.addMarker(n.id, getResultCss(2))
-        const outgoing = getActivityOutgoing(activity)
-        outgoing?.forEach((out) => {
-          canvas.addMarker(out.id, getResultCss(2))
-        })
-      }
-    } else if (n.$type === 'bpmn:SequenceFlow') {
-      let targetActivity = activityList.find((m: any) => m.key === n.targetRef.id)
-      if (targetActivity) {
-        canvas.addMarker(n.id, getActivityHighlightCss(targetActivity))
-      }
-    }
-  })
-  if (!isEmpty(removeTaskDefinitionKeyList)) {
-    taskList.value = taskList.value.filter(
-      (item) => !removeTaskDefinitionKeyList.includes(item.taskDefinitionKey)
-    )
+/** Zoom:缩小 */
+const processZoomOut = (zoomStep = 0.1) => {
+  let newZoom = Math.floor(defaultZoom.value * 100 - zoomStep * 100) / 100
+  if (newZoom < 0.2) {
+    throw new Error('[Process Designer Warn ]: The zoom ratio cannot be less than 0.2')
   }
+  defaultZoom.value = newZoom
+  bpmnViewer.value?.get('canvas').zoom(defaultZoom.value)
 }
 
-const getActivityHighlightCss = (activity) => {
-  return activity.endTime ? 'highlight' : 'highlight-todo'
-}
-
-const getResultCss = (status) => {
-  if (status === 1) {
-    // 审批中
-    return 'highlight-todo'
-  } else if (status === 2) {
-    // 已通过
-    return 'highlight'
-  } else if (status === 3) {
-    // 不通过
-    return 'highlight-reject'
-  } else if (status === 4) {
-    // 已取消
-    return 'highlight-cancel'
-  } else if (status === 5) {
-    // 退回
-    return 'highlight-return'
-  } else if (status === 6) {
-    // 委派
-    return 'highlight-todo'
-  } else if (status === 7) {
-    // 审批通过中
-    return 'highlight-todo'
-  } else if (status === 0) {
-    // 待审批
-    return 'highlight-todo'
+/** 流程图预览清空 */
+const clearViewer = () => {
+  if (processCanvas.value) {
+    processCanvas.value.innerHTML = ''
+  }
+  if (bpmnViewer.value) {
+    bpmnViewer.value.destroy()
   }
-  return ''
+  bpmnViewer.value = null
 }
 
-const getActivityOutgoing = (activity) => {
-  // 如果有 outgoing,则直接使用它
-  if (activity.outgoing && activity.outgoing.length > 0) {
-    return activity.outgoing
+/** 添加自定义箭头 */
+// TODO 芋艿:自定义箭头不生效,有点奇怪!!!!相关的 marker-end、marker-start 暂时也注释了!!!
+const addCustomDefs = () => {
+  if (!bpmnViewer.value) {
+    return
   }
-  // 如果没有,则遍历获得起点为它的【bpmn:SequenceFlow】节点们。原因是:bpmn-js 的 UserTask 拿不到 outgoing
-  const flowElements = bpmnModeler.getDefinitions().rootElements[0].flowElements
-  const outgoing: any[] = []
-  flowElements.forEach((item: any) => {
-    if (item.$type !== 'bpmn:SequenceFlow') {
-      return
-    }
-    if (item.sourceRef.id === activity.key) {
-      outgoing.push(item)
-    }
-  })
-  return outgoing
+  const canvas = bpmnViewer.value?.get('canvas')
+  const svg = canvas?._svg
+  svg.appendChild(customDefs.value)
 }
-const initModelListeners = () => {
-  const EventBus = bpmnModeler.get('eventBus')
-  // 注册需要的监听事件
-  EventBus.on('element.hover', function (eventObj) {
-    let element = eventObj ? eventObj.element : null
-    elementHover(element)
-  })
-  EventBus.on('element.out', function (eventObj) {
-    let element = eventObj ? eventObj.element : null
-    elementOut(element)
-  })
-}
-// 流程图的元素被 hover
-const elementHover = (element) => {
-  element.value = element
-  !elementOverlayIds.value && (elementOverlayIds.value = {})
-  !overlays.value && (overlays.value = bpmnModeler.get('overlays'))
-  // 展示信息
-  // console.log(activityLists.value, 'activityLists.value')
-  // console.log(element.value, 'element.value')
-  const activity = activityLists.value.find((m) => m.key === element.value.id)
-  // console.log(activity, 'activityactivityactivityactivity')
-  if (!activity) {
+
+/** 节点选中 */
+const onSelectElement = (element: any) => {
+  // 清空原选中
+  selectActivityType.value = undefined
+  dialogTitle.value = undefined
+  if (!element || !processInstance.value?.id) {
     return
   }
-  if (!elementOverlayIds.value[element.value.id] && element.value.type !== 'bpmn:Process') {
-    let html = `<div class="element-overlays">
-            <p>Elemet id: ${element.value.id}</p>
-            <p>Elemet type: ${element.value.type}</p>
-          </div>` // 默认值
-    if (element.value.type === 'bpmn:StartEvent' && processInstance.value) {
-      html = `<p>发起人:${processInstance.value.startUser.nickname}</p>
-                  <p>部门:${processInstance.value.startUser.deptName}</p>
-                  <p>创建时间:${formatDate(processInstance.value.createTime)}`
-    } else if (element.value.type === 'bpmn:UserTask') {
-      let task = taskList.value.find((m) => m.id === activity.taskId) // 找到活动对应的 taskId
-      if (!task) {
-        return
-      }
-      let optionData = getIntDictOptions(DICT_TYPE.BPM_TASK_STATUS)
-      let dataResult = ''
-      optionData.forEach((element) => {
-        if (element.value == task.status) {
-          dataResult = element.label
-        }
-      })
-      html = `<p>审批人:${task.assigneeUser.nickname}</p>
-                  <p>部门:${task.assigneeUser.deptName}</p>
-                  <p>结果:${dataResult}</p>
-                  <p>创建时间:${formatDate(task.createTime)}</p>`
-      // html = `<p>审批人:${task.assigneeUser.nickname}</p>
-      //             <p>部门:${task.assigneeUser.deptName}</p>
-      //             <p>结果:${getIntDictOptions(
-      //               DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT,
-      //               task.status
-      //             )}</p>
-      //             <p>创建时间:${formatDate(task.createTime)}</p>`
-      if (task.endTime) {
-        html += `<p>结束时间:${formatDate(task.endTime)}</p>`
-      }
-      if (task.reason) {
-        html += `<p>审批建议:${task.reason}</p>`
-      }
-    } else if (element.value.type === 'bpmn:ServiceTask' && processInstance.value) {
-      if (activity.startTime > 0) {
-        html = `<p>创建时间:${formatDate(activity.startTime)}</p>`
-      }
-      if (activity.endTime > 0) {
-        html += `<p>结束时间:${formatDate(activity.endTime)}</p>`
-      }
-      console.log(html)
-    } else if (element.value.type === 'bpmn:EndEvent' && processInstance.value) {
-      let optionData = getIntDictOptions(DICT_TYPE.BPM_TASK_STATUS)
-      let dataResult = ''
-      optionData.forEach((element) => {
-        if (element.value == processInstance.value.status) {
-          dataResult = element.label
-        }
-      })
-      html = `<p>结果:${dataResult}</p>`
-      // html = `<p>结果:${getIntDictOptions(
-      //   DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT,
-      //   processInstance.value.status
-      // )}</p>`
-      if (processInstance.value.endTime) {
-        html += `<p>结束时间:${formatDate(processInstance.value.endTime)}</p>`
+
+  // UserTask 的情况
+  const activityType = element.type
+  selectActivityType.value = activityType
+  if (activityType === 'bpmn:UserTask') {
+    dialogTitle.value = element.businessObject ? element.businessObject.name : undefined
+    selectTasks.value = tasks.value.filter((item: any) => item?.taskDefinitionKey === element.id)
+    dialogVisible.value = true
+  } else if (activityType === 'bpmn:EndEvent' || activityType === 'bpmn:StartEvent') {
+    dialogTitle.value = '审批信息'
+    selectTasks.value = [
+      {
+        assigneeUser: processInstance.value.startUser,
+        createTime: processInstance.value.startTime,
+        endTime: processInstance.value.endTime,
+        status: processInstance.value.status,
+        durationInMillis: processInstance.value.durationInMillis
       }
-    }
-    // console.log(html, 'html111111111111111')
-    elementOverlayIds.value[element.value.id] = toRaw(overlays.value)?.add(element.value, {
-      position: { left: 0, bottom: 0 },
-      html: `<div class="element-overlays">${html}</div>`
-    })
+    ]
+    dialogVisible.value = true
   }
 }
 
-// 流程图的元素被 out
-const elementOut = (element) => {
-  toRaw(overlays.value).remove({ element })
-  elementOverlayIds.value[element.id] = null
-}
+/** 初始化 BPMN 视图 */
+const importXML = async (xml: string) => {
+  // 清空流程图
+  clearViewer()
 
-onMounted(() => {
-  xml.value = props.value
-  activityLists.value = props.activityData
-  // 初始化
-  initBpmnModeler()
-  createNewDiagram(xml.value)
-  // 初始模型的监听器
-  initModelListeners()
-})
+  // 初始化流程图
+  if (xml != null && xml !== '') {
+    try {
+      bpmnViewer.value = new BpmnViewer({
+        additionalModules: [MoveCanvasModule],
+        container: processCanvas.value
+      })
+      // 增加点击事件
+      bpmnViewer.value.on('element.click', ({ element }) => {
+        onSelectElement(element)
+      })
 
-onBeforeUnmount(() => {
-  // this.$once('hook:beforeDestroy', () => {
-  // })
-  if (bpmnModeler) bpmnModeler.destroy()
-  emit('destroy', bpmnModeler)
-  bpmnModeler = null
-})
+      // 初始化 BPMN 视图
+      isLoading.value = true
+      await bpmnViewer.value.importXML(xml)
+      // 自定义成功的箭头
+      addCustomDefs()
+    } catch (e) {
+      clearViewer()
+    } finally {
+      isLoading.value = false
+      // 高亮流程
+      setProcessStatus(props.view)
+    }
+  }
+}
 
-watch(
-  () => props.value,
-  (newValue) => {
-    xml.value = newValue
-    createNewDiagram(xml.value)
+/** 高亮流程 */
+const setProcessStatus = (view: any) => {
+  // 设置相关变量
+  if (!view || !view.processInstance) {
+    return
   }
-)
-watch(
-  () => props.activityData,
-  (newActivityData) => {
-    activityLists.value = newActivityData
-    createNewDiagram(xml.value)
+  processInstance.value = view.processInstance
+  tasks.value = view.tasks
+  if (isLoading.value || !bpmnViewer.value) {
+    return
   }
-)
-watch(
-  () => props.processInstanceData,
-  (newProcessInstanceData) => {
-    processInstance.value = newProcessInstanceData
-    createNewDiagram(xml.value)
+  const {
+    unfinishedTaskActivityIds,
+    finishedTaskActivityIds,
+    finishedSequenceFlowActivityIds,
+    rejectedTaskActivityIds
+  } = view
+  const canvas = bpmnViewer.value.get('canvas')
+  const elementRegistry = bpmnViewer.value.get('elementRegistry')
+
+  // 已完成节点
+  if (Array.isArray(finishedSequenceFlowActivityIds)) {
+    finishedSequenceFlowActivityIds.forEach((item: any) => {
+      if (item != null) {
+        canvas.addMarker(item, 'success')
+        const element = elementRegistry.get(item)
+        const conditionExpression = element.businessObject.conditionExpression
+        if (conditionExpression) {
+          canvas.addMarker(item, 'condition-expression')
+        }
+      }
+    })
   }
-)
-watch(
-  () => props.taskData,
-  (newTaskListData) => {
-    taskList.value = newTaskListData
-    createNewDiagram(xml.value)
+  if (Array.isArray(finishedTaskActivityIds)) {
+    finishedTaskActivityIds.forEach((item: any) => canvas.addMarker(item, 'success'))
   }
-)
-</script>
 
-<style lang="scss">
-/** 处理中 */
-.highlight-todo.djs-connection > .djs-visual > path {
-  stroke: #1890ff !important;
-  stroke-dasharray: 4px !important;
-  fill-opacity: 0.2 !important;
-}
-
-.highlight-todo.djs-shape .djs-visual > :nth-child(1) {
-  fill: #1890ff !important;
-  stroke: #1890ff !important;
-  stroke-dasharray: 4px !important;
-  fill-opacity: 0.2 !important;
-}
-
-:deep(.highlight-todo.djs-connection > .djs-visual > path) {
-  stroke: #1890ff !important;
-  stroke-dasharray: 4px !important;
-  fill-opacity: 0.2 !important;
-  marker-end: url('#sequenceflow-end-_E7DFDF-_E7DFDF-803g1kf6zwzmcig1y2ulm5egr');
-}
-
-:deep(.highlight-todo.djs-shape .djs-visual > :nth-child(1)) {
-  fill: #1890ff !important;
-  stroke: #1890ff !important;
-  stroke-dasharray: 4px !important;
-  fill-opacity: 0.2 !important;
-}
-
-/** 通过 */
-.highlight.djs-shape .djs-visual > :nth-child(1) {
-  fill: green !important;
-  stroke: green !important;
-  fill-opacity: 0.2 !important;
-}
-
-.highlight.djs-shape .djs-visual > :nth-child(2) {
-  fill: green !important;
-}
-
-.highlight.djs-shape .djs-visual > path {
-  fill: green !important;
-  fill-opacity: 0.2 !important;
-  stroke: green !important;
-}
-
-.highlight.djs-connection > .djs-visual > path {
-  stroke: green !important;
-}
-
-.highlight:not(.djs-connection) .djs-visual > :nth-child(1) {
-  fill: green !important; /* color elements as green */
-}
-
-:deep(.highlight.djs-shape .djs-visual > :nth-child(1)) {
-  fill: green !important;
-  stroke: green !important;
-  fill-opacity: 0.2 !important;
-}
-
-:deep(.highlight.djs-shape .djs-visual > :nth-child(2)) {
-  fill: green !important;
-}
-
-:deep(.highlight.djs-shape .djs-visual > path) {
-  fill: green !important;
-  fill-opacity: 0.2 !important;
-  stroke: green !important;
-}
-
-:deep(.highlight.djs-connection > .djs-visual > path) {
-  stroke: green !important;
-}
-
-.djs-element.highlight > .djs-visual > path {
-  stroke: green !important;
-}
-
-/** 不通过 */
-.highlight-reject.djs-shape .djs-visual > :nth-child(1) {
-  fill: red !important;
-  stroke: red !important;
-  fill-opacity: 0.2 !important;
-}
-
-.highlight-reject.djs-shape .djs-visual > :nth-child(2) {
-  fill: red !important;
-}
-
-.highlight-reject.djs-shape .djs-visual > path {
-  fill: red !important;
-  fill-opacity: 0.2 !important;
-  stroke: red !important;
-}
-
-.highlight-reject.djs-connection > .djs-visual > path {
-  stroke: red !important;
-  marker-end: url(#sequenceflow-end-white-success) !important;
-}
-
-.highlight-reject:not(.djs-connection) .djs-visual > :nth-child(1) {
-  fill: red !important; /* color elements as green */
-}
-
-:deep(.highlight-reject.djs-shape .djs-visual > :nth-child(1)) {
-  fill: red !important;
-  stroke: red !important;
-  fill-opacity: 0.2 !important;
-}
-
-:deep(.highlight-reject.djs-shape .djs-visual > :nth-child(2)) {
-  fill: red !important;
-}
-
-:deep(.highlight-reject.djs-shape .djs-visual > path) {
-  fill: red !important;
-  fill-opacity: 0.2 !important;
-  stroke: red !important;
-}
-
-:deep(.highlight-reject.djs-connection > .djs-visual > path) {
-  stroke: red !important;
-}
-
-/** 已取消 */
-.highlight-cancel.djs-shape .djs-visual > :nth-child(1) {
-  fill: grey !important;
-  stroke: grey !important;
-  fill-opacity: 0.2 !important;
-}
-
-.highlight-cancel.djs-shape .djs-visual > :nth-child(2) {
-  fill: grey !important;
-}
-
-.highlight-cancel.djs-shape .djs-visual > path {
-  fill: grey !important;
-  fill-opacity: 0.2 !important;
-  stroke: grey !important;
-}
-
-.highlight-cancel.djs-connection > .djs-visual > path {
-  stroke: grey !important;
-}
-
-.highlight-cancel:not(.djs-connection) .djs-visual > :nth-child(1) {
-  fill: grey !important; /* color elements as green */
-}
-
-:deep(.highlight-cancel.djs-shape .djs-visual > :nth-child(1)) {
-  fill: grey !important;
-  stroke: grey !important;
-  fill-opacity: 0.2 !important;
-}
-
-:deep(.highlight-cancel.djs-shape .djs-visual > :nth-child(2)) {
-  fill: grey !important;
-}
-
-:deep(.highlight-cancel.djs-shape .djs-visual > path) {
-  fill: grey !important;
-  fill-opacity: 0.2 !important;
-  stroke: grey !important;
-}
-
-:deep(.highlight-cancel.djs-connection > .djs-visual > path) {
-  stroke: grey !important;
-}
-
-/** 回退 */
-.highlight-return.djs-shape .djs-visual > :nth-child(1) {
-  fill: #e6a23c !important;
-  stroke: #e6a23c !important;
-  fill-opacity: 0.2 !important;
-}
-
-.highlight-return.djs-shape .djs-visual > :nth-child(2) {
-  fill: #e6a23c !important;
-}
-
-.highlight-return.djs-shape .djs-visual > path {
-  fill: #e6a23c !important;
-  fill-opacity: 0.2 !important;
-  stroke: #e6a23c !important;
-}
-
-.highlight-return.djs-connection > .djs-visual > path {
-  stroke: #e6a23c !important;
-}
+  // 未完成节点
+  if (Array.isArray(unfinishedTaskActivityIds)) {
+    unfinishedTaskActivityIds.forEach((item: any) => canvas.addMarker(item, 'primary'))
+  }
 
-.highlight-return:not(.djs-connection) .djs-visual > :nth-child(1) {
-  fill: #e6a23c !important; /* color elements as green */
-}
+  // 被拒绝节点
+  if (Array.isArray(rejectedTaskActivityIds)) {
+    rejectedTaskActivityIds.forEach((item: any) => {
+      if (item != null) {
+        canvas.addMarker(item, 'danger')
+      }
+    })
+  }
 
-:deep(.highlight-return.djs-shape .djs-visual > :nth-child(1)) {
-  fill: #e6a23c !important;
-  stroke: #e6a23c !important;
-  fill-opacity: 0.2 !important;
+  // 特殊:处理 end 节点的高亮。因为 end 在拒绝、取消时,被后端计算成了 finishedTaskActivityIds 里
+  if (
+    [BpmProcessInstanceStatus.CANCEL, BpmProcessInstanceStatus.REJECT].includes(
+      processInstance.value.status
+    )
+  ) {
+    const endNodes = elementRegistry.filter((element: any) => element.type === 'bpmn:EndEvent')
+    endNodes.forEach((item: any) => {
+      canvas.removeMarker(item.id, 'success')
+      if (processInstance.value.status === BpmProcessInstanceStatus.CANCEL) {
+        canvas.addMarker(item.id, 'cancel')
+      } else {
+        canvas.addMarker(item.id, 'danger')
+      }
+    })
+  }
 }
 
-:deep(.highlight-return.djs-shape .djs-visual > :nth-child(2)) {
-  fill: #e6a23c !important;
-}
+watch(
+  () => props.xml,
+  (newXml) => {
+    importXML(newXml)
+  },
+  { immediate: true }
+)
 
-:deep(.highlight-return.djs-shape .djs-visual > path) {
-  fill: #e6a23c !important;
-  fill-opacity: 0.2 !important;
-  stroke: #e6a23c !important;
-}
+watch(
+  () => props.view,
+  (newView) => {
+    setProcessStatus(newView)
+  },
+  { immediate: true }
+)
 
-:deep(.highlight-return.djs-connection > .djs-visual > path) {
-  stroke: #e6a23c !important;
-}
+/** mounted:初始化 */
+onMounted(() => {
+  importXML(props.xml)
+  setProcessStatus(props.view)
+})
 
-.element-overlays {
-  width: 200px;
-  padding: 8px;
-  color: #fafafa;
-  background: rgb(0 0 0 / 60%);
-  border-radius: 4px;
-  box-sizing: border-box;
-}
-</style>
+/** unmount:销毁 */
+onBeforeUnmount(() => {
+  clearViewer()
+})
+</script>

+ 70 - 0
src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json

@@ -1211,6 +1211,76 @@
           "isAttr": true
         }
       ]
+    },
+    {
+      "name": "AssignStartUserHandlerType",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"]
+      },
+      "properties": [
+      {
+        "name": "value",
+        "type": "Integer",
+        "isBody": true
+      }
+      ]
+    },
+    {
+      "name": "RejectHandlerType",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "value",
+          "type": "Integer",
+          "isBody": true
+        }
+      ]
+    },
+    {
+      "name": "RejectReturnTaskId",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "value",
+          "type": "String",
+          "isBody": true
+        }
+      ]
+    },
+    {
+      "name": "AssignEmptyHandlerType",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "value",
+          "type": "Integer",
+          "isBody": true
+        }
+      ]
+    },
+    {
+      "name": "AssignEmptyUserIds",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "value",
+          "type": "String",
+          "isBody": true
+        }
+      ]
     }
   ],
   "emumerations": []

+ 5 - 1
src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="process-panel__container" :style="{ width: `${width}px` }">
+  <div class="process-panel__container" :style="{ width: `${width}px`,maxHeight: '700px' }">
     <el-collapse v-model="activeTab">
       <el-collapse-item name="base">
         <!-- class="panel-tab__title" -->
@@ -54,6 +54,10 @@
         <template #title><Icon icon="ep:promotion" />其他</template>
         <element-other-config :id="elementId" />
       </el-collapse-item>
+      <el-collapse-item name="customConfig" v-if="elementType.indexOf('Task') !== -1" key="customConfig">
+        <template #title><Icon icon="ep:circle-plus-filled" />自定义配置</template>
+        <element-custom-config :id="elementId" :type="elementType" />
+      </el-collapse-item>
     </el-collapse>
   </div>
 </template>

+ 283 - 0
src/components/bpmnProcessDesigner/package/penal/custom-config/ElementCustomConfig.vue

@@ -0,0 +1,283 @@
+<!-- UserTask 自定义配置:
+     1. 审批人与提交人为同一人时
+     2. 审批人拒绝时
+     3. 审批人为空时
+-->
+<template>
+  <div class="panel-tab__content">
+    <el-divider content-position="left">审批人拒绝时</el-divider>
+    <el-form-item prop="rejectHandlerType">
+      <el-radio-group
+        v-model="rejectHandlerType"
+        :disabled="returnTaskList.length === 0"
+        @change="updateRejectHandlerType"
+      >
+        <div class="flex-col">
+          <div v-for="(item, index) in REJECT_HANDLER_TYPES" :key="index">
+            <el-radio :key="item.value" :value="item.value" :label="item.label" />
+          </div>
+        </div>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item
+      v-if="rejectHandlerType == RejectHandlerType.RETURN_USER_TASK"
+      label="驳回节点"
+      prop="returnNodeId"
+    >
+      <el-select v-model="returnNodeId" clearable style="width: 100%" @change="updateReturnNodeId">
+        <el-option
+          v-for="item in returnTaskList"
+          :key="item.id"
+          :label="item.name"
+          :value="item.id"
+        />
+      </el-select>
+    </el-form-item>
+
+    <el-divider content-position="left">审批人为空时</el-divider>
+    <el-form-item prop="assignEmptyHandlerType">
+      <el-radio-group v-model="assignEmptyHandlerType" @change="updateAssignEmptyHandlerType">
+        <div class="flex-col">
+          <div v-for="(item, index) in ASSIGN_EMPTY_HANDLER_TYPES" :key="index">
+            <el-radio :key="item.value" :value="item.value" :label="item.label" />
+          </div>
+        </div>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item
+      v-if="assignEmptyHandlerType == AssignEmptyHandlerType.ASSIGN_USER"
+      label="指定用户"
+      prop="assignEmptyHandlerUserIds"
+      span="24"
+    >
+      <el-select
+        v-model="assignEmptyUserIds"
+        clearable
+        multiple
+        style="width: 100%"
+        @change="updateAssignEmptyUserIds"
+      >
+        <el-option
+          v-for="item in userOptions"
+          :key="item.id"
+          :label="item.nickname"
+          :value="item.id"
+        />
+      </el-select>
+    </el-form-item>
+
+    <el-divider content-position="left">审批人与提交人为同一人时</el-divider>
+    <el-radio-group v-model="assignStartUserHandlerType" @change="updateAssignStartUserHandlerType">
+      <div class="flex-col">
+        <div v-for="(item, index) in ASSIGN_START_USER_HANDLER_TYPES" :key="index">
+          <el-radio :key="item.value" :value="item.value" :label="item.label" />
+        </div>
+      </div>
+    </el-radio-group>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  ASSIGN_START_USER_HANDLER_TYPES,
+  RejectHandlerType,
+  REJECT_HANDLER_TYPES,
+  ASSIGN_EMPTY_HANDLER_TYPES,
+  AssignEmptyHandlerType
+} from '@/components/SimpleProcessDesignerV2/src/consts'
+import * as UserApi from '@/api/system/user'
+
+defineOptions({ name: 'ElementCustomConfig' })
+const props = defineProps({
+  id: String,
+  type: String
+})
+const prefix = inject('prefix')
+
+// 审批人与提交人为同一人时
+const assignStartUserHandlerTypeEl = ref()
+const assignStartUserHandlerType = ref()
+
+// 审批人拒绝时
+const rejectHandlerTypeEl = ref()
+const rejectHandlerType = ref()
+const returnNodeIdEl = ref()
+const returnNodeId = ref()
+const returnTaskList = ref([])
+
+// 审批人为空时
+const assignEmptyHandlerTypeEl = ref()
+const assignEmptyHandlerType = ref()
+const assignEmptyUserIdsEl = ref()
+const assignEmptyUserIds = ref()
+
+const elExtensionElements = ref()
+const otherExtensions = ref()
+const bpmnElement = ref()
+const bpmnInstances = () => (window as any)?.bpmnInstances
+
+const resetCustomConfigList = () => {
+  bpmnElement.value = bpmnInstances().bpmnElement
+
+  // 获取可回退的列表
+  returnTaskList.value = findAllPredecessorsExcludingStart(
+    bpmnElement.value.id,
+    bpmnInstances().modeler
+  )
+
+  // 获取元素扩展属性 或者 创建扩展属性
+  elExtensionElements.value =
+    bpmnElement.value.businessObject?.extensionElements ??
+    bpmnInstances().moddle.create('bpmn:ExtensionElements', { values: [] })
+
+  // 审批人与提交人为同一人时
+  assignStartUserHandlerTypeEl.value =
+    elExtensionElements.value.values?.filter(
+      (ex) => ex.$type === `${prefix}:AssignStartUserHandlerType`
+    )?.[0] || bpmnInstances().moddle.create(`${prefix}:AssignStartUserHandlerType`, { value: 1 })
+  assignStartUserHandlerType.value = assignStartUserHandlerTypeEl.value.value
+
+  // 审批人拒绝时
+  rejectHandlerTypeEl.value =
+    elExtensionElements.value.values?.filter(
+      (ex) => ex.$type === `${prefix}:RejectHandlerType`
+    )?.[0] || bpmnInstances().moddle.create(`${prefix}:RejectHandlerType`, { value: 1 })
+  rejectHandlerType.value = rejectHandlerTypeEl.value.value
+  returnNodeIdEl.value =
+    elExtensionElements.value.values?.filter(
+      (ex) => ex.$type === `${prefix}:RejectReturnTaskId`
+    )?.[0] || bpmnInstances().moddle.create(`${prefix}:RejectReturnTaskId`, { value: '' })
+  returnNodeId.value = returnNodeIdEl.value.value
+
+  // 审批人为空时
+  assignEmptyHandlerTypeEl.value =
+    elExtensionElements.value.values?.filter(
+      (ex) => ex.$type === `${prefix}:AssignEmptyHandlerType`
+    )?.[0] || bpmnInstances().moddle.create(`${prefix}:AssignEmptyHandlerType`, { value: 1 })
+  assignEmptyHandlerType.value = assignEmptyHandlerTypeEl.value.value
+  assignEmptyUserIdsEl.value =
+    elExtensionElements.value.values?.filter(
+      (ex) => ex.$type === `${prefix}:AssignEmptyUserIds`
+    )?.[0] || bpmnInstances().moddle.create(`${prefix}:AssignEmptyUserIds`, { value: '' })
+  assignEmptyUserIds.value = assignEmptyUserIdsEl.value.value.split(',').map((item) => {
+    // 如果数字超出了最大安全整数范围,则将其作为字符串处理
+    let num = Number(item)
+    return num > Number.MAX_SAFE_INTEGER || num < -Number.MAX_SAFE_INTEGER ? item : num
+  })
+
+  // 保留剩余扩展元素,便于后面更新该元素对应属性
+  otherExtensions.value =
+    elExtensionElements.value.values?.filter(
+      (ex) =>
+        ex.$type !== `${prefix}:AssignStartUserHandlerType` &&
+        ex.$type !== `${prefix}:RejectHandlerType` &&
+        ex.$type !== `${prefix}:RejectReturnTaskId` &&
+        ex.$type !== `${prefix}:AssignEmptyHandlerType` &&
+        ex.$type !== `${prefix}:AssignEmptyUserIds`
+    ) ?? []
+
+  // 更新元素扩展属性,避免后续报错
+  updateElementExtensions()
+}
+
+const updateAssignStartUserHandlerType = () => {
+  assignStartUserHandlerTypeEl.value.value = assignStartUserHandlerType.value
+
+  updateElementExtensions()
+}
+
+const updateRejectHandlerType = () => {
+  rejectHandlerTypeEl.value.value = rejectHandlerType.value
+
+  returnNodeId.value = returnTaskList.value[0].id
+  returnNodeIdEl.value.value = returnNodeId.value
+
+  updateElementExtensions()
+}
+
+const updateReturnNodeId = () => {
+  returnNodeIdEl.value.value = returnNodeId.value
+
+  updateElementExtensions()
+}
+
+const updateAssignEmptyHandlerType = () => {
+  assignEmptyHandlerTypeEl.value.value = assignEmptyHandlerType.value
+
+  updateElementExtensions()
+}
+
+const updateAssignEmptyUserIds = () => {
+  assignEmptyUserIdsEl.value.value = assignEmptyUserIds.value.toString()
+
+  updateElementExtensions()
+}
+
+const updateElementExtensions = () => {
+  const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
+    values: [
+      ...otherExtensions.value,
+      assignStartUserHandlerTypeEl.value,
+      rejectHandlerTypeEl.value,
+      returnNodeIdEl.value,
+      assignEmptyHandlerTypeEl.value,
+      assignEmptyUserIdsEl.value
+    ]
+  })
+  bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
+    extensionElements: extensions
+  })
+}
+
+watch(
+  () => props.id,
+  (val) => {
+    val &&
+      val.length &&
+      nextTick(() => {
+        resetCustomConfigList()
+      })
+  },
+  { immediate: true }
+)
+
+function findAllPredecessorsExcludingStart(elementId, modeler) {
+  const elementRegistry = modeler.get('elementRegistry')
+  const allConnections = elementRegistry.filter((element) => element.type === 'bpmn:SequenceFlow')
+  const predecessors = new Set() // 使用 Set 来避免重复节点
+
+  // 检查是否是开始事件节点
+  function isStartEvent(element) {
+    return element.type === 'bpmn:StartEvent'
+  }
+
+  function findPredecessorsRecursively(element) {
+    // 获取与当前节点相连的所有连接
+    const incomingConnections = allConnections.filter((connection) => connection.target === element)
+
+    incomingConnections.forEach((connection) => {
+      const source = connection.source // 获取前置节点
+
+      // 只添加不是开始事件的前置节点
+      if (!isStartEvent(source)) {
+        predecessors.add(source.businessObject)
+        // 递归查找前置节点
+        findPredecessorsRecursively(source)
+      }
+    })
+  }
+
+  const targetElement = elementRegistry.get(elementId)
+  if (targetElement) {
+    findPredecessorsRecursively(targetElement)
+  }
+
+  return Array.from(predecessors) // 返回前置节点数组
+}
+
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+onMounted(async () => {
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 3 - 3
src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue

@@ -268,9 +268,9 @@ const bpmnInstances = () => (window as any)?.bpmnInstances
 const resetFormList = () => {
   bpmnELement.value = bpmnInstances().bpmnElement
   formKey.value = bpmnELement.value.businessObject.formKey
-  if (formKey.value?.length > 0) {
-    formKey.value = parseInt(formKey.value)
-  }
+  // if (formKey.value?.length > 0) {
+  //   formKey.value = parseInt(formKey.value)
+  // }
   // 获取元素扩展属性 或者 创建扩展属性
   elExtensionElements.value =
     bpmnELement.value.businessObject.get('extensionElements') ||

+ 1 - 1
src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue

@@ -80,7 +80,7 @@ const resetAttributesList = () => {
   otherExtensionList.value = [] // 其他扩展配置
   bpmnElementProperties.value =
     // bpmnElement.value.businessObject?.extensionElements?.filter((ex) => {
-    bpmnElement.value.businessObject?.extensionElements?.values.filter((ex) => {
+    bpmnElement.value.businessObject?.extensionElements?.values?.filter((ex) => {
       if (ex.$type !== `${prefix}:Properties`) {
         otherExtensionList.value.push(ex)
       }

+ 1 - 1
src/components/bpmnProcessDesigner/package/theme/element-variables.scss

@@ -5,7 +5,7 @@ $--color-danger: #ff4d4f;
 /* 改变 icon 字体路径变量,必需 */
 $--font-path: '~element-ui/lib/theme-chalk/fonts';
 
-@import '~element-ui/packages/theme-chalk/src/index';
+@use '~element-ui/packages/theme-chalk/src/index';
 
 .el-table td,
 .el-table th {

+ 117 - 2
src/components/bpmnProcessDesigner/package/theme/index.scss

@@ -1,2 +1,117 @@
-@import './process-designer.scss';
-@import './process-panel.scss';
+@use './process-designer.scss';
+@use './process-panel.scss';
+
+$success-color: #4eb819;
+$primary-color: #409EFF;
+$danger-color: #F56C6C;
+$cancel-color: #909399;
+
+.process-viewer {
+  position: relative;
+  border: 1px solid #EFEFEF;
+  background: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9ImEiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHBhdGggZD0iTTAgMTBoNDBNMTAgMHY0ME0wIDIwaDQwTTIwIDB2NDBNMCAzMGg0ME0zMCAwdjQwIiBmaWxsPSJub25lIiBzdHJva2U9IiNlMGUwZTAiIG9wYWNpdHk9Ii4yIi8+PHBhdGggZD0iTTQwIDBIMHY0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZTBlMGUwIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ1cmwoI2EpIi8+PC9zdmc+') repeat!important;
+
+  .success-arrow {
+    fill: $success-color;
+    stroke: $success-color;
+  }
+
+  .success-conditional {
+    fill: white;
+    stroke: $success-color;
+  }
+
+  .success.djs-connection {
+    .djs-visual path {
+      stroke: $success-color!important;
+      //marker-end: url(#sequenceflow-end-white-success)!important;
+    }
+  }
+
+  .success.djs-connection.condition-expression {
+    .djs-visual path {
+      //marker-start: url(#conditional-flow-marker-white-success)!important;
+    }
+  }
+
+  .success.djs-shape {
+    .djs-visual rect {
+      stroke: $success-color!important;
+      fill: $success-color!important;
+      fill-opacity: 0.15!important;
+    }
+
+    .djs-visual polygon {
+      stroke: $success-color!important;
+    }
+
+    .djs-visual path:nth-child(2) {
+      stroke: $success-color!important;
+      fill: $success-color!important;
+    }
+
+    .djs-visual circle {
+      stroke: $success-color!important;
+      fill: $success-color!important;
+      fill-opacity: 0.15!important;
+    }
+  }
+
+  .primary.djs-shape {
+    .djs-visual rect {
+      stroke: $primary-color!important;
+      fill: $primary-color!important;
+      fill-opacity: 0.15!important;
+    }
+
+    .djs-visual polygon {
+      stroke: $primary-color!important;
+    }
+
+    .djs-visual circle {
+      stroke: $primary-color!important;
+      fill: $primary-color!important;
+      fill-opacity: 0.15!important;
+    }
+  }
+
+  .danger.djs-shape {
+    .djs-visual rect {
+      stroke: $danger-color!important;
+      fill: $danger-color!important;
+      fill-opacity: 0.15!important;
+    }
+
+    .djs-visual polygon {
+      stroke: $danger-color!important;
+    }
+
+    .djs-visual circle {
+      stroke: $danger-color!important;
+      fill: $danger-color!important;
+      fill-opacity: 0.15!important;
+    }
+  }
+
+  .cancel.djs-shape {
+    .djs-visual rect {
+      stroke: $cancel-color!important;
+      fill: $cancel-color!important;
+      fill-opacity: 0.15!important;
+    }
+
+    .djs-visual polygon {
+      stroke: $cancel-color!important;
+    }
+
+    .djs-visual circle {
+      stroke: $cancel-color!important;
+      fill: $cancel-color!important;
+      fill-opacity: 0.15!important;
+    }
+  }
+}
+
+.process-viewer .djs-tooltip-container, .process-viewer .djs-overlay-container, .process-viewer .djs-palette {
+  display: none;
+}

+ 3 - 3
src/components/bpmnProcessDesigner/package/theme/process-designer.scss

@@ -1,6 +1,6 @@
-@import 'bpmn-js-token-simulation/assets/css/bpmn-js-token-simulation.css';
-@import 'bpmn-js-token-simulation/assets/css/font-awesome.min.css';
-@import 'bpmn-js-token-simulation/assets/css/normalize.css';
+@use 'bpmn-js-token-simulation/assets/css/bpmn-js-token-simulation.css';
+@use 'bpmn-js-token-simulation/assets/css/font-awesome.min.css';
+@use 'bpmn-js-token-simulation/assets/css/normalize.css';
 
 // 边框被 token-simulation 样式覆盖了
 .djs-palette {

+ 4 - 8
src/config/axios/index.ts

@@ -5,16 +5,12 @@ import { config } from './config'
 const { default_headers } = config
 
 const request = (option: any) => {
-  const { url, method, params, data, headersType, responseType, ...config } = option
+  const { headersType, headers, ...otherOption } = option
   return service({
-    url: url,
-    method,
-    params,
-    data,
-    ...config,
-    responseType: responseType,
+    ...otherOption,
     headers: {
-      'Content-Type': headersType || default_headers
+      'Content-Type': headersType || default_headers,
+      ...headers
     }
   })
 }

+ 21 - 26
src/config/axios/service.ts

@@ -1,10 +1,4 @@
-import axios, {
-  AxiosError,
-  AxiosInstance,
-  AxiosRequestHeaders,
-  AxiosResponse,
-  InternalAxiosRequestConfig
-} from 'axios'
+import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
 
 import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
 import qs from 'qs'
@@ -37,7 +31,11 @@ const whiteList: string[] = ['/login', '/refresh-token']
 const service: AxiosInstance = axios.create({
   baseURL: base_url, // api 的 base_url
   timeout: request_timeout, // 请求超时时间
-  withCredentials: false // 禁用 Cookie 等信息
+  withCredentials: false, // 禁用 Cookie 等信息
+  // 自定义参数序列化函数
+  paramsSerializer: (params) => {
+    return qs.stringify(params, { allowDots: true })
+  }
 })
 
 // request拦截器
@@ -46,34 +44,31 @@ service.interceptors.request.use(
     // 是否需要设置 token
     let isToken = (config!.headers || {}).isToken === false
     whiteList.some((v) => {
-      if (config.url) {
-        config.url.indexOf(v) > -1
+      if (config.url && config.url.indexOf(v) > -1) {
         return (isToken = false)
       }
     })
     if (getAccessToken() && !isToken) {
-      ;(config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
+      config.headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
     }
     // 设置租户
     if (tenantEnable && tenantEnable === 'true') {
       const tenantId = getTenantId()
-      if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId
+      if (tenantId) config.headers['tenant-id'] = tenantId
     }
-    const params = config.params || {}
-    const data = config.data || false
-    if (
-      config.method?.toUpperCase() === 'POST' &&
-      (config.headers as AxiosRequestHeaders)['Content-Type'] ===
-        'application/x-www-form-urlencoded'
-    ) {
-      config.data = qs.stringify(data)
+    const method = config.method?.toUpperCase()
+    // 防止 GET 请求缓存
+    if (method === 'GET') {
+      config.headers['Cache-Control'] = 'no-cache'
+      config.headers['Pragma'] = 'no-cache'
     }
-    // get参数编码
-    if (config.method?.toUpperCase() === 'GET' && params) {
-      config.params = {}
-      const paramsStr = qs.stringify(params, { allowDots: true })
-      if (paramsStr) {
-        config.url = config.url + '?' + paramsStr
+    // 自定义参数序列化函数
+    else if (method === 'POST') {
+      const contentType = config.headers['Content-Type'] || config.headers['content-type']
+      if (contentType === 'application/x-www-form-urlencoded') {
+        if (config.data && typeof config.data !== 'string') {
+          config.data = qs.stringify(config.data)
+        }
       }
     }
     return config

+ 11 - 0
src/directives/index.ts

@@ -11,3 +11,14 @@ export const setupAuth = (app: App<Element>) => {
   hasRole(app)
   hasPermi(app)
 }
+
+/**
+ * 导出指令:v-mountedFocus
+ */
+export const setupMountedFocus = (app: App<Element>) => {
+  app.directive('mountedFocus', {
+    mounted(el) {
+      el.focus()
+    }
+  })
+}

+ 2 - 1
src/directives/permission/hasPermi.ts

@@ -8,7 +8,8 @@ export function hasPermi(app: App<Element>) {
     const { wsCache } = useCache()
     const { value } = binding
     const all_permission = '*:*:*'
-    const permissions = wsCache.get(CACHE_KEY.USER).permissions
+    const userInfo = wsCache.get(CACHE_KEY.USER)
+    const permissions = userInfo?.permissions || []
 
     if (value && value instanceof Array && value.length > 0) {
       const permissionFlag = value

Some files were not shown because too many files changed in this diff