Browse Source

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

YunaiV 10 tháng trước cách đây
mục cha
commit
13706de227
84 tập tin đã thay đổi với 8296 bổ sung2906 xóa
  1. 11 11
      package.json
  2. 208 168
      pnpm-lock.yaml
  3. 5 1
      src/api/bpm/model/index.ts
  4. 41 1
      src/api/bpm/processInstance/index.ts
  5. 15 0
      src/api/bpm/simple/index.ts
  6. 46 0
      src/api/bpm/task/index.ts
  7. 74 0
      src/api/iot/device/index.ts
  8. 62 0
      src/api/iot/product/index.ts
  9. 55 0
      src/api/iot/thinkmodelfunction/index.ts
  10. 1 1
      src/components/ContentWrap/src/ContentWrap.vue
  11. 4 1
      src/components/DiyEditor/components/mobile/TabBar/property.vue
  12. 1 0
      src/components/RouterSearch/index.vue
  13. 0 237
      src/components/SimpleProcessDesigner/src/addNode.vue
  14. 0 297
      src/components/SimpleProcessDesigner/src/nodeWrap.vue
  15. 0 165
      src/components/SimpleProcessDesigner/src/util.ts
  16. 0 1292
      src/components/SimpleProcessDesigner/theme/workflow.css
  17. 168 0
      src/components/SimpleProcessDesignerV2/src/NodeHandler.vue
  18. 107 0
      src/components/SimpleProcessDesignerV2/src/ProcessNodeTree.vue
  19. 212 0
      src/components/SimpleProcessDesignerV2/src/SimpleProcessDesigner.vue
  20. 544 0
      src/components/SimpleProcessDesignerV2/src/consts.ts
  21. 4 0
      src/components/SimpleProcessDesignerV2/src/index.ts
  22. 478 0
      src/components/SimpleProcessDesignerV2/src/node.ts
  23. 419 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/ConditionNodeConfig.vue
  24. 307 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/CopyTaskNodeConfig.vue
  25. 136 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/StartUserNodeConfig.vue
  26. 901 0
      src/components/SimpleProcessDesignerV2/src/nodes-config/UserTaskNodeConfig.vue
  27. 79 0
      src/components/SimpleProcessDesignerV2/src/nodes/CopyTaskNode.vue
  28. 13 0
      src/components/SimpleProcessDesignerV2/src/nodes/EndEventNode.vue
  29. 207 0
      src/components/SimpleProcessDesignerV2/src/nodes/ExclusiveNode.vue
  30. 181 0
      src/components/SimpleProcessDesignerV2/src/nodes/ParallelNode.vue
  31. 69 0
      src/components/SimpleProcessDesignerV2/src/nodes/StartUserNode.vue
  32. 88 0
      src/components/SimpleProcessDesignerV2/src/nodes/UserTaskNode.vue
  33. 33 0
      src/components/SimpleProcessDesignerV2/src/utils.ts
  34. BIN
      src/components/SimpleProcessDesignerV2/theme/iconfont.ttf
  35. BIN
      src/components/SimpleProcessDesignerV2/theme/iconfont.woff
  36. BIN
      src/components/SimpleProcessDesignerV2/theme/iconfont.woff2
  37. 714 0
      src/components/SimpleProcessDesignerV2/theme/simple-process-designer.scss
  38. 11 0
      src/directives/index.ts
  39. 4 2
      src/main.ts
  40. 60 7
      src/plugins/formCreate/index.ts
  41. 39 1
      src/router/modules/remaining.ts
  42. 10 10
      src/store/modules/bpm/simpleWorkflow.ts
  43. 12 0
      src/utils/constants.ts
  44. 15 1
      src/utils/dict.ts
  45. 48 9
      src/views/bpm/form/editor/index.vue
  46. 139 83
      src/views/bpm/model/ModelForm.vue
  47. 0 141
      src/views/bpm/model/ModelImportForm.vue
  48. 3 3
      src/views/bpm/model/editor/index.vue
  49. 133 144
      src/views/bpm/model/index.vue
  50. 85 18
      src/views/bpm/processInstance/detail/ProcessInstanceOperationButton.vue
  51. 202 130
      src/views/bpm/processInstance/detail/ProcessInstanceTimeline.vue
  52. 125 23
      src/views/bpm/processInstance/detail/index.vue
  53. 176 85
      src/views/bpm/processInstance/detail/index_new.vue
  54. 4 4
      src/views/bpm/processInstance/index.vue
  55. 4 0
      src/views/bpm/processInstance/manager/index.vue
  56. 9 24
      src/views/bpm/simpleWorkflow/index.vue
  57. 8 3
      src/views/bpm/task/copy/index.vue
  58. 2 1
      src/views/bpm/task/done/index.vue
  59. 2 1
      src/views/bpm/task/todo/index.vue
  60. 47 11
      src/views/infra/build/index.vue
  61. 4 2
      src/views/infra/webSocket/index.vue
  62. 156 0
      src/views/iot/device/DeviceForm.vue
  63. 76 0
      src/views/iot/device/detail/DeviceDetailsHeader.vue
  64. 123 0
      src/views/iot/device/detail/DeviceDetailsInfo.vue
  65. 66 0
      src/views/iot/device/detail/index.vue
  66. 267 0
      src/views/iot/device/index.vue
  67. 204 0
      src/views/iot/product/ProductForm.vue
  68. 103 0
      src/views/iot/product/detail/ProductDetailsHeader.vue
  69. 44 0
      src/views/iot/product/detail/ProductDetailsInfo.vue
  70. 243 0
      src/views/iot/product/detail/ProductTopic.vue
  71. 154 0
      src/views/iot/product/detail/ThinkModelFunction.vue
  72. 229 0
      src/views/iot/product/detail/ThinkModelFunctionForm.vue
  73. 80 0
      src/views/iot/product/detail/index.vue
  74. 191 0
      src/views/iot/product/index.vue
  75. 1 1
      src/views/mall/promotion/coupon/components/CouponSelect.vue
  76. 1 1
      src/views/mall/promotion/coupon/template/CouponTemplateForm.vue
  77. 1 1
      src/views/mall/promotion/discountActivity/DiscountActivityForm.vue
  78. 0 11
      src/views/mall/promotion/discountActivity/discountActivity.data.ts
  79. 6 4
      src/views/mall/promotion/kefu/components/KeFuConversationList.vue
  80. 15 3
      src/views/mall/promotion/kefu/components/message/OrderItem.vue
  81. 4 2
      src/views/mall/promotion/kefu/index.vue
  82. 2 2
      src/views/mall/promotion/rewardActivity/components/RewardRuleCouponSelect.vue
  83. 2 2
      src/views/member/user/index.vue
  84. 3 2
      src/views/report/jmreport/index.vue

+ 11 - 11
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 ./node_modules/vite/bin/vite.js build",
+    "build:dev": "node ./node_modules/vite/bin/vite.js build --mode dev",
+    "build:test": "node ./node_modules/vite/bin/vite.js build --mode test",
+    "build:stage": "node ./node_modules/vite/bin/vite.js build --mode stage",
+    "build:prod": "node ./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",
@@ -67,7 +67,7 @@
     "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",
@@ -130,7 +130,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"
   },

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 208 - 168
pnpm-lock.yaml


+ 5 - 1
src/api/bpm/model/index.ts

@@ -30,7 +30,7 @@ export const getModelPage = async (params) => {
   return await request.get({ url: '/bpm/model/page', params })
 }
 
-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,10 @@ export const updateModel = async (data: ModelVO) => {
   return await request.put({ url: '/bpm/model/update', data: data })
 }
 
+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 = {

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

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 import { ProcessDefinitionVO } from '@/api/bpm/model'
-
+import { NodeType } 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
+  status: number
+  startTime?: Date
+  endTime?: Date
+  candidateUserList?: User[]
+  tasks: ApprovalTaskInfo[]
+}
+
 export const getProcessInstanceMyPage = async (params: any) => {
   return await request.get({ url: '/bpm/process-instance/my-page', params })
 }
@@ -57,3 +86,14 @@ 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 (processInstanceId?:string, processDefinitionId?:string) => {
+  const param = processInstanceId ? '?processInstanceId='+ processInstanceId : '?processDefinitionId='+ processDefinitionId
+  return await request.get({ url: 'bpm/process-instance/get-approval-detail'+ param })
+}
+
+// 获取表单字段权限
+export const getFormFieldsPermission = async (params: any) => {
+  return await request.get({ url: '/bpm/process-instance/get-form-fields-permission', params })
+}

+ 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
+  })
+}

+ 46 - 0
src/api/bpm/task/index.ts

@@ -1,5 +1,51 @@
 import request from '@/config/axios'
 
+/**
+ * 任务状态枚举
+ */
+export enum TaskStatusEnum {
+  /**
+   * 未开始
+   */
+  NOT_START = -1,
+
+   /**
+   * 待审批
+   */
+   WAIT = 0,
+  /**
+   * 审批中
+   */
+  RUNNING = 1,
+  /**
+   * 审批通过
+   */
+  APPROVE = 2,
+
+  /**
+   * 审批不通过
+   */
+  REJECT = 3,
+  
+  /**
+   * 已取消
+   */
+  CANCEL = 4,
+  /**
+   * 已退回
+   */
+  RETURN = 5,
+  /**
+   * 委派中
+   */
+  DELEGATE = 6,
+  /**
+   * 审批通过中
+   */
+  APPROVING = 7,
+
+}
+
 export type TaskVO = {
   id: number
 }

+ 74 - 0
src/api/iot/device/index.ts

@@ -0,0 +1,74 @@
+import request from '@/config/axios'
+
+// IoT 设备 VO
+export interface DeviceVO {
+  id: number // 设备 ID,主键,自增
+  deviceKey: string // 设备唯一标识符
+  deviceName: string // 设备名称
+  productId: number // 产品编号
+  productKey: string // 产品标识
+  deviceType: number // 设备类型
+  nickname: string // 设备备注名称
+  gatewayId: number // 网关设备 ID
+  status: number // 设备状态
+  statusLastUpdateTime: Date // 设备状态最后更新时间
+  lastOnlineTime: Date // 最后上线时间
+  lastOfflineTime: Date // 最后离线时间
+  activeTime: Date // 设备激活时间
+  createTime: Date // 创建时间
+  ip: string // 设备的 IP 地址
+  firmwareVersion: string // 设备的固件版本
+  deviceSecret: string // 设备密钥,用于设备认证,需安全存储
+  mqttClientId: string // MQTT 客户端 ID
+  mqttUsername: string // MQTT 用户名
+  mqttPassword: string // MQTT 密码
+  authType: string // 认证类型
+  latitude: number // 设备位置的纬度
+  longitude: number // 设备位置的经度
+  areaId: number // 地区编码
+  address: string // 设备详细地址
+  serialNumber: string // 设备序列号
+}
+
+export interface DeviceUpdateStatusVO {
+  id: number // 设备 ID,主键,自增
+  status: number // 设备状态
+}
+
+// 设备 API
+export const DeviceApi = {
+  // 查询设备分页
+  getDevicePage: async (params: any) => {
+    return await request.get({ url: `/iot/device/page`, params })
+  },
+
+  // 查询设备详情
+  getDevice: async (id: number) => {
+    return await request.get({ url: `/iot/device/get?id=` + id })
+  },
+
+  // 新增设备
+  createDevice: async (data: DeviceVO) => {
+    return await request.post({ url: `/iot/device/create`, data })
+  },
+
+  // 修改设备
+  updateDevice: async (data: DeviceVO) => {
+    return await request.put({ url: `/iot/device/update`, data })
+  },
+
+  // 修改设备状态
+  updateDeviceStatus: async (data: DeviceUpdateStatusVO) => {
+    return await request.put({ url: `/iot/device/update-status`, data })
+  },
+
+  // 删除设备
+  deleteDevice: async (id: number) => {
+    return await request.delete({ url: `/iot/device/delete?id=` + id })
+  },
+
+  // 获取设备数量
+  getDeviceCount: async (productId: number) => {
+    return await request.get({ url: `/iot/device/count?productId=` + productId })
+  }
+}

+ 62 - 0
src/api/iot/product/index.ts

@@ -0,0 +1,62 @@
+import request from '@/config/axios'
+
+// IoT 产品 VO
+export interface ProductVO {
+  id: number // 产品编号
+  name: string // 产品名称
+  productKey: string // 产品标识
+  protocolId: number // 协议编号
+  categoryId: number // 产品所属品类标识符
+  description: string // 产品描述
+  validateType: number // 数据校验级别
+  status: number // 产品状态
+  deviceType: number // 设备类型
+  netType: number // 联网方式
+  protocolType: number // 接入网关协议
+  dataFormat: number // 数据格式
+  deviceCount: number // 设备数量
+  createTime: Date // 创建时间
+}
+
+// IoT 产品 API
+export const ProductApi = {
+  // 查询产品分页
+  getProductPage: async (params: any) => {
+    return await request.get({ url: `/iot/product/page`, params })
+  },
+
+  // 查询产品详情
+  getProduct: async (id: number) => {
+    return await request.get({ url: `/iot/product/get?id=` + id })
+  },
+
+  // 新增产品
+  createProduct: async (data: ProductVO) => {
+    return await request.post({ url: `/iot/product/create`, data })
+  },
+
+  // 修改产品
+  updateProduct: async (data: ProductVO) => {
+    return await request.put({ url: `/iot/product/update`, data })
+  },
+
+  // 删除产品
+  deleteProduct: async (id: number) => {
+    return await request.delete({ url: `/iot/product/delete?id=` + id })
+  },
+
+  // 导出产品 Excel
+  exportProduct: async (params) => {
+    return await request.download({ url: `/iot/product/export-excel`, params })
+  },
+
+  // 更新产品状态
+  updateProductStatus: async (id: number, status: number) => {
+    return await request.put({ url: `/iot/product/update-status?id=` + id + `&status=` + status })
+  },
+
+  // 查询产品(精简)列表
+  getSimpleProductList() {
+    return request.get({ url: '/iot/product/list-all-simple' })
+  }
+}

+ 55 - 0
src/api/iot/thinkmodelfunction/index.ts

@@ -0,0 +1,55 @@
+import request from '@/config/axios'
+
+// IoT 产品物模型 VO
+export interface ThinkModelFunctionVO {
+  id: number // 物模型功能编号
+  identifier: string // 功能标识
+  name: string // 功能名称
+  description: string // 功能描述
+  productId: number // 产品编号
+  productKey: string // 产品标识
+  type: number // 功能类型
+  property: string // 属性
+  event: string // 事件
+  service: string // 服务
+}
+
+// IoT 产品物模型 API
+export const ThinkModelFunctionApi = {
+  // 查询产品物模型分页
+  getThinkModelFunctionPage: async (params: any) => {
+    return await request.get({ url: `/iot/think-model-function/page`, params })
+  },
+  // 获得产品物模型
+  getThinkModelFunctionListByProductId: async (params: any) => {
+    return await request.get({
+      url: `/iot/think-model-function/list-by-product-id`,
+      params
+    })
+  },
+
+  // 查询产品物模型详情
+  getThinkModelFunction: async (id: number) => {
+    return await request.get({ url: `/iot/think-model-function/get?id=` + id })
+  },
+
+  // 新增产品物模型
+  createThinkModelFunction: async (data: ThinkModelFunctionVO) => {
+    return await request.post({ url: `/iot/think-model-function/create`, data })
+  },
+
+  // 修改产品物模型
+  updateThinkModelFunction: async (data: ThinkModelFunctionVO) => {
+    return await request.put({ url: `/iot/think-model-function/update`, data })
+  },
+
+  // 删除产品物模型
+  deleteThinkModelFunction: async (id: number) => {
+    return await request.delete({ url: `/iot/think-model-function/delete?id=` + id })
+  },
+
+  // 导出产品物模型 Excel
+  exportThinkModelFunction: async (params) => {
+    return await request.download({ url: `/iot/think-model-function/export-excel`, params })
+  }
+}

+ 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>
 

+ 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)

+ 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
-}
-
-

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

@@ -0,0 +1,168 @@
+<template>
+  <div class="node-handler-wrapper">
+    <div class="node-handler" v-if="props.showAdd">
+      <el-popover
+        trigger="hover"
+        v-model:visible="popoverShow"
+        placement="right-start"
+        width="auto"
+      >
+        <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 condition">
+              <span class="iconfont icon-size icon-parallel"></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 popoverShow = ref(false)
+
+const props = defineProps({
+  childNode: {
+    type: Object as () => SimpleFlowNode,
+    default: null
+  },
+  showAdd: {
+    // 是否显示添加节点
+    type: Boolean,
+    default: true
+  }
+})
+
+const emits = defineEmits(['update:childNode'])
+
+const addNode = (type: number) => {
+  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)
+  }
+}
+</script>
+
+<style lang="scss" scoped></style>

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

@@ -0,0 +1,107 @@
+<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"
+  />
+  <!-- 递归显示孩子节点  -->
+  <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" />
+</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 { 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>

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

@@ -0,0 +1,212 @@
+<template>
+  <div class="simple-flow-canvas" v-loading="loading">
+    <div class="simple-flow-container">
+      <div class="top-area-container">
+        <div class="top-actions">
+          <div class="canvas-control">
+            <span class="control-scale-group">
+              <span class="control-scale-button"> <Icon icon="ep:plus" @click="zoomOut()" /></span>
+              <span class="control-scale-label">{{ scaleValue }}%</span>
+              <span class="control-scale-button"><Icon icon="ep:minus" @click="zoomIn()" /></span>
+            </span>
+          </div>
+          <el-button type="primary" @click="saveSimpleFlowModel">保存</el-button>
+          <!-- <el-button type="primary">全局设置</el-button> -->
+        </div>
+      </div>
+      <div class="scale-container" :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>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ProcessNodeTree from './ProcessNodeTree.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 router = useRouter() // 路由
+const props = defineProps({
+  modelId: {
+    type: String,
+    required: true
+  }
+})
+const loading = ref(true)
+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 () => {
+  if (!props.modelId) {
+    message.error('缺少模型 modelId 编号')
+    return
+  }
+  errorNodes = []
+  validateNode(processNodeTree.value, errorNodes)
+  if (errorNodes.length > 0) {
+    errorDialogVisible.value = true
+    return
+  }
+  const data = {
+    id: props.modelId,
+    simpleModel: processNodeTree.value
+  }
+
+  const result = await updateBpmSimpleModel(data)
+  if (result) {
+    message.success('修改成功')
+    close()
+  } else {
+    message.alert('修改失败')
+  }
+}
+// 校验节点设置。 暂时以 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) {
+      if (!showText) {
+        errorNodes.push(node)
+      }
+      validateNode(node.childNode, errorNodes)
+    }
+    if (type === NodeType.COPY_TASK_NODE) {
+      if (!showText) {
+        errorNodes.push(node)
+      }
+      validateNode(node.childNode, errorNodes)
+    }
+    if (type === NodeType.CONDITION_NODE) {
+      if (!showText) {
+        errorNodes.push(node)
+      }
+      validateNode(node.childNode, errorNodes)
+    }
+
+    if (type == NodeType.CONDITION_BRANCH_NODE) {
+      conditionNodes?.forEach((item) => {
+        validateNode(item, errorNodes)
+      })
+      validateNode(node.childNode, errorNodes)
+    }
+  }
+}
+
+const close = () => {
+  router.push({ path: '/bpm/manager/model' })
+}
+let scaleValue = ref(100)
+const MAX_SCALE_VALUE = 200
+const MIN_SCALE_VALUE = 50
+// 放大
+const zoomOut = () => {
+  if (scaleValue.value == MAX_SCALE_VALUE) {
+    return
+  }
+  scaleValue.value += 10
+}
+// 缩小
+const zoomIn = () => {
+  if (scaleValue.value == MIN_SCALE_VALUE) {
+    return
+  }
+  scaleValue.value -= 10
+}
+
+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>

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

@@ -0,0 +1,544 @@
+// @ts-ignore
+import { DictDataVO } from '@/api/system/dict/types'
+
+/**
+ * 节点类型
+ */
+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, string>>
+  // 审批任务超时处理
+  timeoutHandler?: TimeoutHandler
+  // 审批任务拒绝处理
+  rejectHandler?: RejectHandler
+  // 审批人为空的处理
+  assignEmptyHandler?: AssignEmptyHandler
+  // 审批节点的审批人与发起人相同时,对应的处理类型
+  assignStartUserHandlerType?: number
+  // 条件类型
+  conditionType?: ConditionType
+  // 条件表达式
+  conditionExpression?: string
+  // 条件组
+  conditionGroups?: ConditionGroup
+  // 是否默认的条件
+  defaultFlow?: boolean
+
+}
+// 候选人策略枚举 ( 用于审批节点。抄送节点 )
+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,
+  /**
+   * 流程表达式
+   */
+  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
+}
+
+/**
+ * 条件规则结构定义
+ */
+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.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, '回退')
+
+// 默认的按钮权限设置
+export const DEFAULT_BUTTON_SETTING: ButtonSetting[] = [
+  { id: OperationButtonType.APPROVE, displayName: '通过', enable: true },
+  { id: OperationButtonType.REJECT, displayName: '拒绝', enable: true },
+  { 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 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 }
+]

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

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

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

@@ -0,0 +1,478 @@
+import { cloneDeep } from 'lodash-es'
+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
+} from './consts'
+export function useWatchNode(props: { flowNode: SimpleFlowNode }): Ref<SimpleFlowNode> {
+  const node = ref<SimpleFlowNode>(props.flowNode)
+  watch(
+    () => props.flowNode,
+    (newValue) => {
+      node.value = newValue
+    }
+  )
+  return node
+}
+
+/**
+ * @description 表单数据权限配置,用于发起人节点 、审批节点、抄送节点
+ */
+export function useFormFieldsPermission(defaultPermission: FieldPermissionType) {
+  // 字段权限配置. 需要有 field, title,  permissioin 属性
+  const fieldsPermissionConfig = ref<Array<Record<string, string>>>([])
+
+  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[]) => {
+    const defaultFieldsPermission: Array<Record<string, string>> = []
+    if (formFields) {
+      formFields.forEach((fieldStr: string) => {
+        parseFieldsSetDefaultPermission(JSON.parse(fieldStr), defaultFieldsPermission)
+      })
+    }
+    return defaultFieldsPermission
+  }
+  // 解析字段。赋给默认权限
+  const parseFieldsSetDefaultPermission = (
+    rule: Record<string, any>,
+    fieldsPermission: Array<Record<string, string>>,
+    parentTitle: string = ''
+  ) => {
+    const { /**type,*/ field, title: tempTitle, children } = rule
+    if (field && tempTitle) {
+      let title = tempTitle
+      if (parentTitle) {
+        title = `${parentTitle}.${tempTitle}`
+      }
+      fieldsPermission.push({
+        field,
+        title,
+        permission: defaultPermission
+      })
+      // TODO 子表单 需要处理子表单字段
+      // if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
+      //   // 解析子表单的字段
+      //   rule.props.rule.forEach((item) => {
+      //     parseFieldsSetDefaultPermission(item, fieldsPermission, title)
+      //   })
+      // }
+    }
+    if (children && Array.isArray(children)) {
+      children.forEach((rule) => {
+        parseFieldsSetDefaultPermission(rule, fieldsPermission)
+      })
+    }
+  }
+
+  return {
+    formType,
+    fieldsPermissionConfig,
+    getNodeConfigFormFields
+  }
+}
+/**
+ * @description 获取表单的字段
+ */
+export function useFormFields() {
+  // 解析后的表单字段
+  const formFields = inject<Ref<string[]>>('formFields') // 流程表单字段
+  const parseFormFields = () => {
+    const parsedFormFields: Array<Record<string, string>> = []
+    if (formFields) {
+      formFields.value.forEach((fieldStr: string) => {
+        parseField(JSON.parse(fieldStr), parsedFormFields)
+      })
+    }
+    return parsedFormFields
+  }
+  // 解析字段。
+  const parseField = (
+    rule: Record<string, any>,
+    parsedFormFields: Array<Record<string, string>>,
+    parentTitle: string = ''
+  ) => {
+    const { field, title: tempTitle, children, type } = rule
+    if (field && tempTitle) {
+      let title = tempTitle
+      if (parentTitle) {
+        title = `${parentTitle}.${tempTitle}`
+      }
+      parsedFormFields.push({
+        field,
+        title,
+        type
+      })
+      // TODO 子表单 需要处理子表单字段
+      // if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
+      //   // 解析子表单的字段
+      //   rule.props.rule.forEach((item) => {
+      //     parseFieldsSetDefaultPermission(item, fieldsPermission, title)
+      //   })
+      // }
+    }
+    if (children && Array.isArray(children)) {
+      children.forEach((rule) => {
+        parseField(rule, parsedFormFields)
+      })
+    }
+  }
+
+  return parseFormFields()
+}
+
+export type UserTaskFormType = {
+  //candidateParamArray: any[]
+  candidateStrategy: CandidateStrategy
+  approveMethod: ApproveMethodType
+  roleIds?: number[] // 角色
+  deptIds?: number[] // 部门
+  deptLevel?: number // 部门层级
+  userIds?: number[] // 用户
+  userGroups?: number[] // 用户组
+  postIds?: number[] // 岗位
+  expression?: 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[] // 岗位
+  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 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.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.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
+      }
+      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.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
+      }
+      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
+  }
+}

+ 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>

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

@@ -0,0 +1,307 @@
+<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
+              "
+              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.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
+} 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 { 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.READ
+)
+// 抄送人表单配置
+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' }],
+  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_SELECT &&
+      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
+}
+// 保存配置
+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>

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

@@ -0,0 +1,136 @@
+<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
+  console.log('currentNode.value.buttonsSetting==>', currentNode.value.buttonsSetting)
+  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>

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

@@ -0,0 +1,901 @@
+<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.MULTI_LEVEL_DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.START_USER_DEPT_LEADER ||
+                configForm.candidateStrategy == CandidateStrategy.START_USER_MULTI_LEVEL_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.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%"
+                @change="changedCandidateUsers"
+              >
+                <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>
+            <!-- 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"
+                      :disabled="
+                        item.value !== ApproveMethodType.RANDOM_SELECT_ONE_APPROVE &&
+                        notAllowedMultiApprovers
+                      "
+                    >
+                      {{ 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 {
+    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, getNodeConfigFormFields } = useFormFieldsPermission(
+  FieldPermissionType.READ
+)
+// 操作按钮设置
+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' }],
+  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 notAllowedMultiApprovers = ref(false)
+// 改变审批人设置策略
+const changeCandidateStrategy = () => {
+  configForm.value.userIds = []
+  configForm.value.deptIds = []
+  configForm.value.roleIds = []
+  configForm.value.postIds = []
+  configForm.value.userGroups = []
+  configForm.value.deptLevel = 1
+  configForm.value.approveMethod = ApproveMethodType.SEQUENTIAL_APPROVE
+  if (
+    configForm.value.candidateStrategy === CandidateStrategy.START_USER ||
+    configForm.value.candidateStrategy === CandidateStrategy.USER
+  ) {
+    notAllowedMultiApprovers.value = true
+  } else {
+    notAllowedMultiApprovers.value = false
+  }
+}
+// 改变审批候选人
+const changedCandidateUsers = () => {
+  if (
+    configForm.value.userIds &&
+    configForm.value.userIds?.length <= 1 &&
+    configForm.value.candidateStrategy === CandidateStrategy.USER
+  ) {
+    configForm.value.approveMethod = ApproveMethodType.RANDOM_SELECT_ONE_APPROVE
+    configForm.value.rejectHandlerType = RejectHandlerType.FINISH_PROCESS
+    notAllowedMultiApprovers.value = true
+  } else {
+    notAllowedMultiApprovers.value = false
+  }
+}
+// 审批方式改变
+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)
+  if (configForm.value.userIds && configForm.value.userIds.length > 1) {
+    notAllowedMultiApprovers.value = true
+  } else {
+    notAllowedMultiApprovers.value = false
+  }
+  // 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>

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

@@ -0,0 +1,79 @@
+<template>
+  <div class="node-wrapper">
+    <div class="node-container">
+      <div class="node-box" :class="{ 'node-config-error': !currentNode.showText }">
+        <div class="node-title-container">
+          <div class="node-title-icon copy-task"><span class="iconfont icon-copy"></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="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 icon="ep:arrow-right-bold" />
+        </div>
+        <div 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" />
+    </div>
+    <CopyTaskNodeConfig v-if="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 } 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 currentNode = useWatchNode(props)
+// 节点名称编辑
+const { showInput, blurEvent, clickTitle } = useNodeName2(currentNode, NodeType.COPY_TASK_NODE)
+
+const nodeSetting = ref()
+// 打开节点配置
+const openNodeConfig = () => {
+  nodeSetting.value.showCopyTaskNodeConfig(currentNode.value)
+  nodeSetting.value.openDrawer()
+}
+
+// 删除节点。更新当前节点为孩子节点
+const deleteNode = () => {
+  emits('update:flowNode', currentNode.value.childNode)
+}
+</script>
+
+<style lang="scss" scoped></style>

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

@@ -0,0 +1,13 @@
+<template>
+  <div class="end-node-wrapper">
+    <div class="end-node-box">
+      <span class="node-fixed-name" title="结束">结束</span>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+defineOptions({
+  name: 'EndEventNode'
+})
+</script>
+<style lang="scss" scoped></style>

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

@@ -0,0 +1,207 @@
+<template>
+  <div class="branch-node-wrapper">
+    <div class="branch-node-container">
+      <div class="branch-node-add" @click="addCondition">添加条件</div>
+      <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 }">
+              <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"> 优先级{{ 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="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" />
+          </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" />
+  </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 { generateUUID } from '@/utils'
+import ConditionNodeConfig from '../nodes-config/ConditionNodeConfig.vue'
+const { proxy } = getCurrentInstance() as any
+defineOptions({
+  name: 'ExclusiveNode'
+})
+const props = defineProps({
+  // parentNode : {
+  //   type: Object as () => SimpleFlowNode,
+  //   required: true
+  // },
+  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 conditionNodes = computed(() => currentNode.value.conditionNodes);
+
+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) => {
+  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_EVENT_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>

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

@@ -0,0 +1,181 @@
+<template>
+  <div class="branch-node-wrapper">
+    <div class="branch-node-container">
+      <div class="branch-node-add" @click="addCondition">添加分支</div>
+      <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">
+              <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 class="node-toolbar">
+                <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" />
+          </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" />
+  </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 { 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)
+
+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_EVENT_NODE) {
+    return
+  }
+  if (node.type === nodeType) {
+    nodeList.push(node)
+  }
+  // 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点并行节点(NodeType.PARALLEL_NODE) 继续查找
+  emits('find:parentNode', nodeList, nodeType)
+}
+</script>

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

@@ -0,0 +1,69 @@
+<template>
+  <div class="node-wrapper">
+    <div class="node-container">
+      <div class="node-box" :class="{ 'node-config-error': !currentNode.showText }">
+        <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="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.START_USER_NODE) }}
+          </div>
+          <Icon icon="ep:arrow-right-bold" />
+        </div>
+      </div>
+      <!-- 传递子节点给添加节点组件。会在子节点前面添加节点 -->
+      <NodeHandler v-if="currentNode" v-model:child-node="currentNode.childNode" />
+    </div>
+  </div>
+  <StartUserNodeConfig v-if="currentNode" ref="nodeSetting" :flow-node="currentNode" />
+</template>
+<script setup lang="ts">
+import NodeHandler from '../NodeHandler.vue'
+import { useWatchNode, useNodeName2 } from '../node'
+import { SimpleFlowNode, NODE_DEFAULT_TEXT, NodeType } from '../consts'
+import StartUserNodeConfig from '../nodes-config/StartUserNodeConfig.vue'
+defineOptions({
+  name: 'StartEventNode'
+})
+const props = defineProps({
+  flowNode: {
+    type: Object as () => SimpleFlowNode,
+    default: () => null
+  }
+})
+// 定义事件,更新父组件。
+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 openNodeConfig = () => {
+  // 把当前节点传递给配置组件
+  nodeSetting.value.showStartUserNodeConfig(currentNode.value)
+  nodeSetting.value.openDrawer()
+}
+</script>
+<style lang="scss" scoped></style>

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

@@ -0,0 +1,88 @@
+<template>
+  <div class="node-wrapper">
+    <div class="node-container">
+      <div class="node-box" :class="{ 'node-config-error': !currentNode.showText }">
+        <div class="node-title-container">
+          <div class="node-title-icon user-task"><span class="iconfont icon-approve"></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="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.USER_TASK_NODE) }}
+          </div>
+          <Icon icon="ep:arrow-right-bold" />
+        </div>
+        <div 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" />
+    </div>
+  </div>
+  <UserTaskNodeConfig
+    v-if="currentNode"
+    ref="nodeSetting"
+    :flow-node="currentNode"
+    @find:return-task-nodes="findReturnTaskNodes"
+  />
+</template>
+<script setup lang="ts">
+import { SimpleFlowNode, NodeType, NODE_DEFAULT_TEXT } from '../consts'
+import { useWatchNode, useNodeName2 } from '../node'
+import NodeHandler from '../NodeHandler.vue'
+import UserTaskNodeConfig from '../nodes-config/UserTaskNodeConfig.vue'
+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 currentNode = useWatchNode(props)
+// 节点名称编辑
+const { showInput, blurEvent, clickTitle } = useNodeName2(currentNode, NodeType.START_USER_NODE)
+const nodeSetting = ref()
+// 打开节点配置
+const openNodeConfig = () => {
+  // 把当前节点传递给配置组件
+  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)
+}
+</script>
+<style lang="scss" scoped></style>

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

@@ -0,0 +1,33 @@
+import { TimeUnitType, ApproveType, APPROVE_TYPE } from './consts'
+
+// 获取条件节点默认的名称
+export const getDefaultConditionNodeName = (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
+}

BIN
src/components/SimpleProcessDesignerV2/theme/iconfont.ttf


BIN
src/components/SimpleProcessDesignerV2/theme/iconfont.woff


BIN
src/components/SimpleProcessDesignerV2/theme/iconfont.woff2


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

@@ -0,0 +1,714 @@
+.simple-flow-canvas {
+  position: absolute;
+  inset: 0;
+  z-index: 1;
+  overflow: auto;
+  background-color: #fafafa;
+  user-select: none;
+
+  .simple-flow-container {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+
+    .top-area-container {
+      position: sticky;
+      inset: 0;
+      display: flex;
+      width: 100%;
+      height: 42px;
+      z-index: 1;
+      // padding: 4px 0;
+      background-color: #fff;
+      justify-content: flex-end;
+      align-items: center;
+
+      .top-actions {
+        display: flex;
+        margin: 4px;
+        margin-right: 8px;
+        align-items: center;
+
+        .canvas-control {
+          font-size: 16px;
+
+          .control-scale-group {
+            display: inline-flex;
+            align-items: center;
+            margin-right: 8px;
+
+            .control-scale-button {
+              display: inline-flex;
+              width: 28px;
+              height: 28px;
+              padding: 2px;
+              text-align: center;
+              cursor: pointer;
+              justify-content: center;
+              align-items: center;
+            }
+
+            .control-scale-label {
+              margin: 0 4px;
+              font-size: 14px;
+            }
+          }
+        }
+      }
+    }
+
+    .scale-container {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+      margin-top: 16px;
+      background-color: #fafafa;
+      transform-origin: 50% 0 0;
+      transform: scale(1);
+      transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+
+      // 节点容器 定义节点宽度
+      .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-color: #0089ff;
+        border-radius: 8px;
+        // border-color: #0089ff;
+        box-shadow: 0 1px 4px 0 rgba(10, 30, 65, 0.16);
+        transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
+
+        &: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;
+            font-size: 14px;
+            font-weight: 600;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            color: #1f1f1f;
+            line-height: 18px;
+            &: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 {
+            font-size: 13px;
+            font-weight: 600;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            color: #f60;
+            &:hover {
+              border-bottom: 1px dashed #000;
+            }
+          }
+
+          .branch-priority {
+            min-width: 50px;
+            font-size: 13px;
+          }
+        }
+
+        .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: rgba(0, 0, 0, 0.03);
+          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 8px;
+          margin-top: 4px;
+          line-height: 32px;
+          align-items: center;
+          color: #111f2c;
+          border-radius: 4px;
+
+          .branch-node-text {
+            overflow: hidden;
+            font-size: 14px;
+            line-height: 24px;
+            text-overflow: ellipsis;
+            word-break: break-all;
+            -webkit-line-clamp: 2; /* 这将限制文本显示为两行 */
+            -webkit-box-orient: vertical;
+          }
+        }
+
+        // 节点操作 :删除
+        .node-toolbar {
+          opacity: 0;
+          position: absolute;
+          top: -20px;
+          right: 0px;
+          display: flex;
+
+          .toolbar-icon {
+            text-align: center;
+            vertical-align: middle;
+          }
+        }
+
+        // 条件节点左右移动
+        .branch-node-move {
+          position: absolute;
+          width: 10px;
+          cursor: pointer;
+          display: none;
+          align-items: center;
+          height: 100%;
+          justify-content: center;
+        }
+
+        .move-node-left {
+          left: -2px;
+          top: 0px;
+          background: rgba(126, 134, 142, 0.08);
+          border-top-left-radius: 8px;
+          border-bottom-left-radius: 8px;
+        }
+
+        .move-node-right {
+          right: -2px;
+          top: 0px;
+          background: rgba(126, 134, 142, 0.08);
+          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;
+          right: 0;
+          left: 0;
+          // bottom: 5px;
+          bottom: 0px;
+          z-index: 0;
+          width: 2px;
+          height: 100%;
+          // height: calc(100% - 5px);
+          margin: auto;
+          background-color: #dedede;
+          content: '';
+        }
+
+        .node-handler {
+          .add-icon {
+            position: relative;
+            top: -5px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            cursor: pointer;
+            width: 25px;
+            height: 25px;
+            color: #fff;
+            background-color: #0089ff;
+            border-radius: 50%;
+
+            &: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;
+            height: 100%;
+            width: 4px;
+            background-color: #fafafa;
+            content: '';
+            left: 50%;
+            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;
+            color: #222;
+            cursor: pointer;
+            background: #fff;
+            border: 2px solid #dedede;
+            border-radius: 18px;
+            transform: translateX(-50%);
+            transform-origin: center center;
+            transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+          }
+
+          .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: #2c2c2c;
+            background: #fafafa;
+            border-radius: 30px;
+            box-shadow: 0 1px 5px 0 rgba(10, 30, 65, 0.08);
+            box-sizing: border-box;
+          }
+        }
+      }
+
+      // 结束节点包装
+      .end-node-wrapper {
+        margin-bottom: 16px;
+
+        .end-node-box {
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          width: 80px;
+          height: 36px;
+          color: #212121;
+          // background: #6e6e6e;
+          background: #fafafa;
+          border-radius: 30px;
+          box-shadow: 0 1px 5px 0 rgba(10, 30, 65, 0.08);
+          box-sizing: border-box;
+        }
+      }
+
+      // 可编辑的 title 输入框
+      .editable-title-input {
+        height: 20px;
+        max-width: 145px;
+        line-height: 20px;
+        font-size: 12px;
+        margin-left: 4px;
+        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);
+        }
+      }
+    }
+  }
+}
+
+// 配置节点头部
+.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 {
+    margin-right: 8px;
+  }
+
+  .handler-item-icon {
+    width: 80px;
+    height: 80px;
+    background: #fff;
+    border: 1px solid #e2e2e2;
+    border-radius: 50%;
+    transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+    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: 35px;
+      line-height: 80px;
+    }
+  }
+
+  .approve {
+    color: #ff943e;
+  }
+  .copy {
+    color: #3296fa;
+  }
+
+  .condition {
+    color: #15bc83;
+  }
+
+  .handler-item-text {
+    margin-top: 4px;
+    width: 80px;
+    text-align: center;
+  }
+}
+
+// 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';
+}

+ 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()
+    }
+  })
+}

+ 4 - 2
src/main.ts

@@ -28,8 +28,8 @@ import '@/plugins/animate.css'
 // 路由
 import router, { setupRouter } from '@/router'
 
-// 权限
-import { setupAuth } from '@/directives'
+// 指令
+import { setupAuth, setupMountedFocus } from '@/directives'
 
 import { createApp } from 'vue'
 
@@ -58,7 +58,9 @@ const setupAll = async () => {
 
   setupRouter(app)
 
+  // directives 指令
   setupAuth(app)
+  setupMountedFocus(app)
 
   await router.isReady()
 

+ 60 - 7
src/plugins/formCreate/index.ts

@@ -1,7 +1,37 @@
 import type { App } from 'vue'
 // 👇使用 form-create 需额外全局引入 element plus 组件
 import {
+  // ElAutocomplete,
+  // ElButton,
+  // ElCascader,
+  // ElCheckbox,
+  // ElCheckboxButton,
+  // ElCheckboxGroup,
+  // ElCol,
+  // ElColorPicker,
+  // ElDatePicker,
+  // ElDialog,
+  // ElForm,
+  // ElInput,
+  // ElInputNumber,
+  // ElPopover,
+  // ElRadio,
+  // ElRadioButton,
+  // ElRadioGroup,
+  // ElRate,
+  // ElRow,
+  // ElSelect,
+  // ElSlider,
+  // ElSwitch,
+  // ElTimePicker,
+  // ElTooltip,
+  // ElTree,
+  // ElUpload,
+  // ElIcon,
+  // ElProgress,
+  // 以上会由 @form-create/element-ui/auto-import 自动引入
   ElAlert,
+  ElTransfer,
   ElAside,
   ElContainer,
   ElDivider,
@@ -12,7 +42,18 @@ import {
   ElTableColumn,
   ElTabPane,
   ElTabs,
-  ElTransfer
+  ElDropdown,
+  ElDropdownMenu,
+  ElDropdownItem,
+  ElBadge,
+  ElTag,
+  ElText,
+  ElMenu,
+  ElMenuItem,
+  ElFooter,
+  ElMessage
+  // ElFormItem,
+  // ElOption
 } from 'element-plus'
 import FcDesigner from '@form-create/designer'
 import formCreate from '@form-create/element-ui'
@@ -41,18 +82,30 @@ const ApiSelect = useApiSelect({
 })
 
 const components = [
+  ElAlert,
+  ElTransfer,
   ElAside,
-  ElPopconfirm,
-  ElHeader,
-  ElMain,
   ElContainer,
   ElDivider,
-  ElTransfer,
-  ElAlert,
-  ElTabs,
+  ElHeader,
+  ElMain,
+  ElPopconfirm,
   ElTable,
   ElTableColumn,
   ElTabPane,
+  ElTabs,
+  ElDropdown,
+  ElDropdownMenu,
+  ElDropdownItem,
+  ElBadge,
+  ElTag,
+  ElText,
+  ElMenu,
+  ElMenuItem,
+  ElFooter,
+  ElMessage,
+  // ElFormItem,
+  // ElOption,
   UploadImg,
   UploadImgs,
   UploadFile,

+ 39 - 1
src/router/modules/remaining.ts

@@ -292,6 +292,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       },
       {
         path: 'process-instance/detail',
+        // component: () => import('@/views/bpm/processInstance/detail/index_new.vue'), // TODO 芋艿:新审批界面,已适配 simple 模式,未来会适配 bpmn 模式
         component: () => import('@/views/bpm/processInstance/detail/index.vue'),
         name: 'BpmProcessInstanceDetail',
         meta: {
@@ -300,7 +301,12 @@ const remainingRouter: AppRouteRecordRaw[] = [
           canTo: true,
           title: '流程详情',
           activeMenu: '/bpm/task/my'
-        }
+        },
+        props: (route) => ({
+          id: route.query.id,
+          taskId: route.query.taskId,
+          activityId: route.query.activityId
+        })
       },
       {
         path: 'oa/leave/create',
@@ -603,6 +609,38 @@ const remainingRouter: AppRouteRecordRaw[] = [
       hidden: true,
       breadcrumb: false
     }
+  },
+  {
+    path: '/iot',
+    component: Layout,
+    name: 'IOT',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'product/detail/:id',
+        name: 'IoTProductDetail',
+        meta: {
+          title: '产品详情',
+          noCache: true,
+          hidden: true,
+          activeMenu: '/iot/product'
+        },
+        component: () => import('@/views/iot/product/detail/index.vue')
+      },
+      {
+        path: 'device/detail/:id',
+        name: 'IoTDeviceDetail',
+        meta: {
+          title: '设备详情',
+          noCache: true,
+          hidden: true,
+          activeMenu: '/iot/device'
+        },
+        component: () => import('@/views/iot/device/detail/index.vue')
+      }
+    ]
   }
 ]
 

+ 10 - 10
src/store/modules/simpleWorkflow.ts → src/store/modules/bpm/simpleWorkflow.ts

@@ -1,4 +1,4 @@
-import { store } from '../index'
+import { store } from '../../index'
 import { defineStore } from 'pinia'
 
 export const useWorkFlowStore = defineStore('simpleWorkflow', {
@@ -6,15 +6,15 @@ export const useWorkFlowStore = defineStore('simpleWorkflow', {
     tableId: '',
     isTried: false,
     promoterDrawer: false,
-    flowPermission1: {},
     approverDrawer: false,
     approverConfig1: {},
     copyerDrawer: false,
-    copyerConfig1: {},
+    copyerConfig: {},
     conditionDrawer: false,
     conditionsConfig1: {
       conditionNodes: []
-    }
+    },
+    userTaskConfig: {}
   }),
   actions: {
     setTableId(payload) {
@@ -26,26 +26,26 @@ export const useWorkFlowStore = defineStore('simpleWorkflow', {
     setPromoter(payload) {
       this.promoterDrawer = payload
     },
-    setFlowPermission(payload) {
-      this.flowPermission1 = payload
-    },
-    setApprover(payload) {
+    setApproverDrawer(payload) {
       this.approverDrawer = payload
     },
     setApproverConfig(payload) {
       this.approverConfig1 = payload
     },
-    setCopyer(payload) {
+    setCopyerDrawer(payload) {
       this.copyerDrawer = payload
     },
     setCopyerConfig(payload) {
-      this.copyerConfig1 = payload
+      this.copyerConfig = payload
     },
     setCondition(payload) {
       this.conditionDrawer = payload
     },
     setConditionsConfig(payload) {
       this.conditionsConfig1 = payload
+    },
+    setUserTaskConfig(payload) {
+      this.userTaskConfig = payload
     }
   }
 })

+ 12 - 0
src/utils/constants.ts

@@ -437,3 +437,15 @@ export const ErpBizType = {
   SALE_OUT: 21,
   SALE_RETURN: 22
 }
+
+// ========== BPM 模块 ==========
+
+export const BpmModelType = {
+  BPMN: 10, // BPMN 设计器
+  SIMPLE: 20 // 简易设计器
+}
+
+export const BpmModelFormType = {
+  NORMAL: 10, // 流程表单
+  CUSTOM: 20 // 业务表单
+}

+ 15 - 1
src/utils/dict.ts

@@ -143,6 +143,7 @@ export enum DICT_TYPE {
   INFRA_OPERATE_TYPE = 'infra_operate_type',
 
   // ========== BPM 模块 ==========
+  BPM_MODEL_TYPE = 'bpm_model_type',
   BPM_MODEL_FORM_TYPE = 'bpm_model_form_type',
   BPM_TASK_CANDIDATE_STRATEGY = 'bpm_task_candidate_strategy',
   BPM_PROCESS_INSTANCE_STATUS = 'bpm_process_instance_status',
@@ -225,5 +226,18 @@ export enum DICT_TYPE {
   AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度
   AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
   AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
-  AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言
+  AI_WRITE_LANGUAGE = 'ai_write_language', // AI 写作语言
+
+  // ========== IOT - 物联网模块  ==========
+  IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式
+  IOT_VALIDATE_TYPE = 'iot_validate_type', // IOT 数据校验级别
+  IOT_PRODUCT_STATUS = 'iot_product_status', // IOT 产品状态
+  IOT_PRODUCT_DEVICE_TYPE = 'iot_product_device_type', // IOT 产品设备类型
+  IOT_DATA_FORMAT = 'iot_data_format', // IOT 数据格式
+  IOT_PROTOCOL_TYPE = 'iot_protocol_type', // IOT 接入网关协议
+  IOT_DEVICE_STATUS = 'iot_device_status', // IOT 设备状态
+  IOT_PRODUCT_FUNCTION_TYPE = 'iot_product_function_type', // IOT 产品功能类型
+  IOT_DATA_TYPE = 'iot_data_type', // IOT 数据类型
+  IOT_UNIT_TYPE = 'iot_unit_type', // IOT 单位类型
+  IOT_RW_TYPE = 'iot_rw_type' // IOT 读写类型
 }

+ 48 - 9
src/views/bpm/form/editor/index.vue

@@ -1,14 +1,18 @@
 <template>
-  <ContentWrap>
+  <ContentWrap :body-style="{ padding: '0px' }" class="!mb-0">
     <!-- 表单设计器 -->
-    <FcDesigner ref="designer" height="780px">
-      <template #handle>
-        <el-button round size="small" type="primary" @click="handleSave">
-          <Icon class="mr-5px" icon="ep:plus" />
-          保存
-        </el-button>
-      </template>
-    </FcDesigner>
+    <div
+      class="h-[calc(100vh-var(--top-tool-height)-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-2px)]"
+    >
+      <fc-designer class="my-designer" ref="designer" :config="designerConfig">
+        <template #handle>
+          <el-button size="small" type="success" plain @click="handleSave">
+            <Icon class="mr-5px" icon="ep:plus" />
+            保存
+          </el-button>
+        </template>
+      </fc-designer>
+    </div>
   </ContentWrap>
 
   <!-- 表单保存的弹窗 -->
@@ -55,6 +59,31 @@ const { push, currentRoute } = useRouter() // 路由
 const { query } = useRoute() // 路由信息
 const { delView } = useTagsViewStore() // 视图操作
 
+// 表单设计器配置
+const designerConfig = ref({
+  switchType: [], // 是否可以切换组件类型,或者可以相互切换的字段
+  autoActive: true, // 是否自动选中拖入的组件
+  useTemplate: false, // 是否生成vue2语法的模板组件
+  formOptions: {}, // 定义表单配置默认值
+  fieldReadonly: false, // 配置field是否可以编辑
+  hiddenDragMenu: false, // 隐藏拖拽操作按钮
+  hiddenDragBtn: false, // 隐藏拖拽按钮
+  hiddenMenu: [], // 隐藏部分菜单
+  hiddenItem: [], // 隐藏部分组件
+  hiddenItemConfig: {}, // 隐藏组件的部分配置项
+  disabledItemConfig: {}, // 禁用组件的部分配置项
+  showSaveBtn: false, // 是否显示保存按钮
+  showConfig: true, // 是否显示右侧的配置界面
+  showBaseForm: true, // 是否显示组件的基础配置表单
+  showControl: true, // 是否显示组件联动
+  showPropsForm: true, // 是否显示组件的属性配置表单
+  showEventForm: true, // 是否显示组件的事件配置表单
+  showValidateForm: true, // 是否显示组件的验证配置表单
+  showFormConfig: true, // 是否显示表单配置
+  showInputData: true, // 是否显示录入按钮
+  showDevice: true, // 是否显示多端适配选项
+  appendConfigData: [] // 定义渲染规则所需的formData
+})
 const designer = ref() // 表单设计器
 useFormCreateDesigner(designer) // 表单设计器增强
 const dialogVisible = ref(false) // 弹窗是否展示
@@ -119,3 +148,13 @@ onMounted(async () => {
   setConfAndFields(designer, data.conf, data.fields)
 })
 </script>
+
+<style>
+.my-designer {
+  ._fc-l,
+  ._fc-m,
+  ._fc-r {
+    border-top: none;
+  }
+}
+</style>

+ 139 - 83
src/views/bpm/model/ModelForm.vue

@@ -8,12 +8,7 @@
       label-width="110px"
     >
       <el-form-item label="流程标识" prop="key">
-        <el-input
-          v-model="formData.key"
-          :disabled="!!formData.id"
-          placeholder="请输入流标标识"
-          style="width: 330px"
-        />
+        <el-input v-model="formData.key" :disabled="!!formData.id" placeholder="请输入流标标识" />
         <el-tooltip
           v-if="!formData.id"
           class="item"
@@ -35,7 +30,7 @@
           placeholder="请输入流程名称"
         />
       </el-form-item>
-      <el-form-item v-if="formData.id" label="流程分类" prop="category">
+      <el-form-item label="流程分类" prop="category">
         <el-select
           v-model="formData.category"
           clearable
@@ -50,73 +45,108 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item v-if="formData.id" label="流程图标" prop="icon">
-        <UploadImg v-model="formData.icon" :limit="1" height="128px" width="128px" />
+      <el-form-item label="流程图标" prop="icon">
+        <UploadImg v-model="formData.icon" :limit="1" height="64px" width="64px" />
       </el-form-item>
       <el-form-item label="流程描述" prop="description">
         <el-input v-model="formData.description" clearable type="textarea" />
       </el-form-item>
-      <div v-if="formData.id">
-        <el-form-item label="表单类型" prop="formType">
-          <el-radio-group v-model="formData.formType">
-            <el-radio
-              v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_FORM_TYPE)"
-              :key="dict.value"
-              :value="dict.value"
-            >
-              {{ dict.label }}
-            </el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item v-if="formData.formType === 10" label="流程表单" prop="formId">
-          <el-select v-model="formData.formId" clearable style="width: 100%">
-            <el-option
-              v-for="form in formList"
-              :key="form.id"
-              :label="form.name"
-              :value="form.id"
-            />
-          </el-select>
-        </el-form-item>
-        <el-form-item
-          v-if="formData.formType === 20"
-          label="表单提交路由"
-          prop="formCustomCreatePath"
+      <el-form-item label="流程类型" prop="type">
+        <el-radio-group v-model="formData.type">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="表单类型" prop="formType">
+        <el-radio-group v-model="formData.formType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_FORM_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item v-if="formData.formType === 10" label="流程表单" prop="formId">
+        <el-select v-model="formData.formId" clearable style="width: 100%">
+          <el-option v-for="form in formList" :key="form.id" :label="form.name" :value="form.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        v-if="formData.formType === 20"
+        label="表单提交路由"
+        prop="formCustomCreatePath"
+      >
+        <el-input
+          v-model="formData.formCustomCreatePath"
+          placeholder="请输入表单提交路由"
+          style="width: 330px"
+        />
+        <el-tooltip
+          class="item"
+          content="自定义表单的提交路径,使用 Vue 的路由地址,例如说:bpm/oa/leave/create.vue"
+          effect="light"
+          placement="top"
         >
-          <el-input
-            v-model="formData.formCustomCreatePath"
-            placeholder="请输入表单提交路由"
-            style="width: 330px"
-          />
-          <el-tooltip
-            class="item"
-            content="自定义表单的提交路径,使用 Vue 的路由地址,例如说:bpm/oa/leave/create"
-            effect="light"
-            placement="top"
+          <i class="el-icon-question" style="padding-left: 5px"></i>
+        </el-tooltip>
+      </el-form-item>
+      <el-form-item v-if="formData.formType === 20" label="表单查看地址" prop="formCustomViewPath">
+        <el-input
+          v-model="formData.formCustomViewPath"
+          placeholder="请输入表单查看的组件地址"
+          style="width: 330px"
+        />
+        <el-tooltip
+          class="item"
+          content="自定义表单的查看组件地址,使用 Vue 的组件地址,例如说:bpm/oa/leave/detail.vue"
+          effect="light"
+          placement="top"
+        >
+          <i class="el-icon-question" style="padding-left: 5px"></i>
+        </el-tooltip>
+      </el-form-item>
+      <el-form-item label="是否可见" prop="visible">
+        <el-radio-group v-model="formData.visible">
+          <el-radio
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value as string"
+            :label="dict.value"
           >
-            <i class="el-icon-question" style="padding-left: 5px"></i>
-          </el-tooltip>
-        </el-form-item>
-        <el-form-item
-          v-if="formData.formType === 20"
-          label="表单查看地址"
-          prop="formCustomViewPath"
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="谁可以发起" prop="startUserIds">
+        <el-select
+          v-model="formData.startUserIds"
+          multiple
+          placeholder="请选择可发起人,默认(不选择)则所有人都可以发起"
         >
-          <el-input
-            v-model="formData.formCustomViewPath"
-            placeholder="请输入表单查看的组件地址"
-            style="width: 330px"
+          <el-option
+            v-for="user in userList"
+            :key="user.id"
+            :label="user.nickname"
+            :value="user.id"
           />
-          <el-tooltip
-            class="item"
-            content="自定义表单的查看组件地址,使用 Vue 的组件地址,例如说:bpm/oa/leave/detail"
-            effect="light"
-            placement="top"
-          >
-            <i class="el-icon-question" style="padding-left: 5px"></i>
-          </el-tooltip>
-        </el-form-item>
-      </div>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="流程管理员" prop="managerUserIds">
+        <el-select v-model="formData.managerUserIds" multiple placeholder="请选择流程管理员">
+          <el-option
+            v-for="user in userList"
+            :key="user.id"
+            :label="user.nickname"
+            :value="user.id"
+          />
+        </el-select>
+      </el-form-item>
     </el-form>
     <template #footer>
       <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
@@ -125,45 +155,62 @@
   </Dialog>
 </template>
 <script lang="ts" setup>
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict'
 import { ElMessageBox } from 'element-plus'
 import * as ModelApi from '@/api/bpm/model'
 import * as FormApi from '@/api/bpm/form'
 import { CategoryApi } from '@/api/bpm/category'
+import { BpmModelFormType, BpmModelType } from '@/utils/constants'
+import { UserVO } from '@/api/system/user'
+import * as UserApi from '@/api/system/user'
+import { useUserStoreWithOut } from '@/store/modules/user'
 
 defineOptions({ name: 'ModelForm' })
 
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
+const userStore = useUserStoreWithOut() // 用户信息缓存
 
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const formType = ref('') // 表单的类型:create - 新增;update - 修改
 const formData = ref({
-  formType: 10,
+  id: undefined,
   name: '',
+  key: '',
   category: undefined,
   icon: undefined,
   description: '',
+  type: BpmModelType.BPMN,
+  formType: BpmModelFormType.NORMAL,
   formId: '',
   formCustomCreatePath: '',
-  formCustomViewPath: ''
+  formCustomViewPath: '',
+  visible: true,
+  startUserIds: [],
+  managerUserIds: []
 })
 const formRules = reactive({
-  name: [{ required: true, message: '参数名称不能为空', trigger: 'blur' }],
-  key: [{ required: true, message: '参数键名不能为空', trigger: 'blur' }],
-  category: [{ required: true, message: '参数分类不能为空', trigger: 'blur' }],
-  icon: [{ required: true, message: '参数图标不能为空', trigger: 'blur' }],
-  value: [{ required: true, message: '参数键值不能为空', trigger: 'blur' }],
-  visible: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }]
+  name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }],
+  key: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }],
+  category: [{ required: true, message: '流程分类不能为空', trigger: 'blur' }],
+  icon: [{ required: true, message: '流程图标不能为空', trigger: 'blur' }],
+  type: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }],
+  formType: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }],
+  formId: [{ required: true, message: '流程表单不能为空', trigger: 'blur' }],
+  formCustomCreatePath: [{ required: true, message: '表单提交路由不能为空', trigger: 'blur' }],
+  formCustomViewPath: [{ required: true, message: '表单查看地址不能为空', trigger: 'blur' }],
+  visible: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }],
+  managerUserIds: [{ required: true, message: '流程管理员不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
 const formList = ref([]) // 流程表单的下拉框的数据
 const categoryList = ref([]) // 流程分类列表
+const userList = ref<UserVO[]>([]) // 用户列表
 
 /** 打开弹窗 */
-const open = async (type: string, id?: number) => {
+const open = async (type: string, id?: string) => {
   dialogVisible.value = true
   dialogTitle.value = t('action.' + type)
   formType.value = type
@@ -176,11 +223,15 @@ const open = async (type: string, id?: number) => {
     } finally {
       formLoading.value = false
     }
+  } else {
+    formData.value.managerUserIds.push(userStore.getUser.id)
   }
   // 获得流程表单的下拉框的数据
   formList.value = await FormApi.getFormSimpleList()
   // 查询流程分类列表
   categoryList.value = await CategoryApi.getCategorySimpleList()
+  // 查询用户列表
+  userList.value = await UserApi.getSimpleUserList()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
@@ -199,10 +250,9 @@ const submitForm = async () => {
       await ModelApi.createModel(data)
       // 提示,引导用户做后续的操作
       await ElMessageBox.alert(
-        '<strong>新建模型成功!</strong>后续需要执行如下 3 个步骤:' +
-          '<div>1. 点击【修改流程】按钮,配置流程的分类、表单信息</div>' +
-          '<div>2. 点击【设计流程】按钮,绘制流程图</div>' +
-          '<div>3. 点击【发布流程】按钮,完成流程的最终发布</div>' +
+        '<strong>新建模型成功!</strong>后续需要执行如下 2 个步骤:' +
+          '<div>1. 点击【设计流程】按钮,绘制流程图</div>' +
+          '<div>2. 点击【发布流程】按钮,完成流程的最终发布</div>' +
           '另外,每次流程修改后,都需要点击【发布流程】按钮,才能正式生效!!!',
         '重要提示',
         {
@@ -225,14 +275,20 @@ const submitForm = async () => {
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
-    formType: 10,
+    id: undefined,
     name: '',
+    key: '',
     category: undefined,
-    icon: '',
+    icon: undefined,
     description: '',
+    type: BpmModelType.BPMN,
+    formType: BpmModelFormType.NORMAL,
     formId: '',
     formCustomCreatePath: '',
-    formCustomViewPath: ''
+    formCustomViewPath: '',
+    visible: true,
+    startUserIds: [],
+    managerUserIds: []
   }
   formRef.value?.resetFields()
 }

+ 0 - 141
src/views/bpm/model/ModelImportForm.vue

@@ -1,141 +0,0 @@
-<template>
-  <Dialog v-model="dialogVisible" title="导入流程" width="400">
-    <div>
-      <el-upload
-        ref="uploadRef"
-        v-model:file-list="fileList"
-        :action="importUrl"
-        :auto-upload="false"
-        :data="formData"
-        :disabled="formLoading"
-        :headers="uploadHeaders"
-        :limit="1"
-        :on-error="submitFormError"
-        :on-exceed="handleExceed"
-        :on-success="submitFormSuccess"
-        accept=".bpmn, .xml"
-        drag
-        name="bpmnFile"
-      >
-        <Icon class="el-icon--upload" icon="ep:upload-filled" />
-        <div class="el-upload__text"> 将文件拖到此处,或 <em>点击上传</em></div>
-        <template #tip>
-          <div class="el-upload__tip" style="color: red">
-            提示:仅允许导入“bpm”或“xml”格式文件!
-          </div>
-          <div>
-            <el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
-              <el-form-item label="流程标识" prop="key">
-                <el-input
-                  v-model="formData.key"
-                  placeholder="请输入流标标识"
-                  style="width: 250px"
-                />
-              </el-form-item>
-              <el-form-item label="流程名称" prop="name">
-                <el-input v-model="formData.name" clearable placeholder="请输入流程名称" />
-              </el-form-item>
-              <el-form-item label="流程描述" prop="description">
-                <el-input v-model="formData.description" clearable type="textarea" />
-              </el-form-item>
-            </el-form>
-          </div>
-        </template>
-      </el-upload>
-    </div>
-    <template #footer>
-      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
-      <el-button @click="dialogVisible = false">取 消</el-button>
-    </template>
-  </Dialog>
-</template>
-<script lang="ts" setup>
-import { getAccessToken, getTenantId } from '@/utils/auth'
-
-defineOptions({ name: 'ModelImportForm' })
-
-const message = useMessage() // 消息弹窗
-
-const dialogVisible = ref(false) // 弹窗的是否展示
-const formLoading = ref(false) // 表单的加载中
-const formData = ref({
-  key: '',
-  name: '',
-  description: ''
-})
-const formRules = reactive({
-  key: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }],
-  name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }]
-})
-const formRef = ref() // 表单 Ref
-const uploadRef = ref() // 上传 Ref
-const importUrl = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/bpm/model/import'
-const uploadHeaders = ref() // 上传 Header 头
-const fileList = ref([]) // 文件列表
-
-/** 打开弹窗 */
-const open = async () => {
-  dialogVisible.value = true
-  resetForm()
-}
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
-
-/** 提交表单 */
-const submitForm = async () => {
-  // 校验表单
-  if (!formRef) return
-  const valid = await formRef.value.validate()
-  if (!valid) return
-  if (fileList.value.length == 0) {
-    message.error('请上传文件')
-    return
-  }
-  // 提交请求
-  uploadHeaders.value = {
-    Authorization: 'Bearer ' + getAccessToken(),
-    'tenant-id': getTenantId()
-  }
-  formLoading.value = true
-  uploadRef.value!.submit()
-}
-
-/** 文件上传成功 */
-const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
-const submitFormSuccess = async (response: any) => {
-  if (response.code !== 0) {
-    message.error(response.msg)
-    formLoading.value = false
-    return
-  }
-  // 提示成功
-  message.success('导入流程成功!请点击【设计流程】按钮,进行编辑保存后,才可以进行【发布流程】')
-  dialogVisible.value = false
-  // 发送操作成功的事件
-  emit('success')
-}
-
-/** 上传错误提示 */
-const submitFormError = (): void => {
-  message.error('导入流程失败,请您重新上传!')
-  formLoading.value = false
-}
-
-/** 重置表单 */
-const resetForm = () => {
-  // 重置上传状态和文件
-  formLoading.value = false
-  uploadRef.value?.clearFiles()
-  // 重置表单
-  formData.value = {
-    key: '',
-    name: '',
-    description: ''
-  }
-  formRef.value?.resetFields()
-}
-
-/** 文件数超出提示 */
-const handleExceed = (): void => {
-  message.error('最多只能上传一个文件!')
-}
-</script>

+ 3 - 3
src/views/bpm/model/editor/index.vue

@@ -58,17 +58,17 @@ const initModeler = (item) => {
 }
 
 /** 添加/修改模型 */
-const save = async (bpmnXml) => {
+const save = async (bpmnXml: string) => {
   const data = {
     ...model.value,
     bpmnXml: bpmnXml // bpmnXml 只是初始化流程图,后续修改无法通过它获得
   } as unknown as ModelApi.ModelVO
   // 提交
   if (data.id) {
-    await ModelApi.updateModel(data)
+    await ModelApi.updateModelBpmn(data)
     message.success('修改成功')
   } else {
-    await ModelApi.createModel(data)
+    await ModelApi.updateModelBpmn(data)
     message.success('新增成功')
   }
   // 跳转回去

+ 133 - 144
src/views/bpm/model/index.vue

@@ -58,10 +58,7 @@
           @click="openForm('create')"
           v-hasPermi="['bpm:model:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新建流程
-        </el-button>
-        <el-button type="success" plain @click="openImportForm" v-hasPermi="['bpm:model:import']">
-          <Icon icon="ep:upload" class="mr-5px" /> 导入流程
+          <Icon icon="ep:plus" class="mr-5px" /> 新建
         </el-button>
       </el-form-item>
     </el-form>
@@ -70,21 +67,34 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="流程标识" align="center" prop="key" width="200" />
-      <el-table-column label="流程名称" align="center" prop="name" width="200">
+      <el-table-column label="流程名称" align="center" prop="name" min-width="200" />
+      <el-table-column label="流程图标" align="center" prop="icon" min-width="100">
         <template #default="scope">
-          <el-button type="primary" link @click="handleBpmnDetail(scope.row)">
-            <span>{{ scope.row.name }}</span>
-          </el-button>
+          <el-image :src="scope.row.icon" class="h-32px w-32px" />
         </template>
       </el-table-column>
-      <el-table-column label="流程图标" align="center" prop="icon" width="100">
+      <el-table-column label="可见范围" align="center" prop="startUserIds" min-width="100">
         <template #default="scope">
-          <el-image :src="scope.row.icon" class="w-32px h-32px" />
+          <el-text v-if="!scope.row.startUsers || scope.row.startUsers.length === 0">
+            全部可见
+          </el-text>
+          <el-text v-else-if="scope.row.startUsers.length == 1">
+            {{ scope.row.startUsers[0].nickname }}
+          </el-text>
+          <el-text v-else>
+            <el-tooltip
+              class="box-item"
+              effect="dark"
+              placement="top"
+              :content="scope.row.startUsers.map((user: any) => user.nickname).join('、')"
+            >
+              {{ scope.row.startUsers[0].nickname }}等 {{ scope.row.startUsers.length }} 人可见
+            </el-tooltip>
+          </el-text>
         </template>
       </el-table-column>
-      <el-table-column label="流程分类" align="center" prop="categoryName" width="100" />
-      <el-table-column label="表单信息" align="center" prop="formType" width="200">
+      <el-table-column label="流程分类" align="center" prop="categoryName" min-width="100" />
+      <el-table-column label="表单信息" align="center" prop="formType" min-width="200">
         <template #default="scope">
           <el-button
             v-if="scope.row.formType === 10"
@@ -105,101 +115,87 @@
           <label v-else>暂无表单</label>
         </template>
       </el-table-column>
-      <el-table-column
-        label="创建时间"
-        align="center"
-        prop="createTime"
-        width="180"
-        :formatter="dateFormatter"
-      />
-      <el-table-column label="最新部署的流程定义" align="center">
-        <el-table-column
-          label="流程版本"
-          align="center"
-          prop="processDefinition.version"
-          width="100"
-        >
-          <template #default="scope">
-            <el-tag v-if="scope.row.processDefinition">
-              v{{ scope.row.processDefinition.version }}
-            </el-tag>
-            <el-tag v-else type="warning">未部署</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column
-          label="激活状态"
-          align="center"
-          prop="processDefinition.version"
-          width="85"
-        >
-          <template #default="scope">
-            <el-switch
-              v-if="scope.row.processDefinition"
-              v-model="scope.row.processDefinition.suspensionState"
-              :active-value="1"
-              :inactive-value="2"
-              @change="handleChangeState(scope.row)"
-            />
-          </template>
-        </el-table-column>
-        <el-table-column label="部署时间" align="center" prop="deploymentTime" width="180">
-          <template #default="scope">
-            <span v-if="scope.row.processDefinition">
-              {{ formatDate(scope.row.processDefinition.deploymentTime) }}
-            </span>
-          </template>
-        </el-table-column>
+      <el-table-column label="最后发布" align="center" prop="deploymentTime" min-width="250">
+        <template #default="scope">
+          <span v-if="scope.row.processDefinition">
+            {{ formatDate(scope.row.processDefinition.deploymentTime) }}
+          </span>
+          <el-tag v-if="scope.row.processDefinition" class="ml-10px">
+            v{{ scope.row.processDefinition.version }}
+          </el-tag>
+          <el-tag v-else type="warning">未部署</el-tag>
+          <el-tag
+            v-if="scope.row.processDefinition?.suspensionState === 2"
+            type="warning"
+            class="ml-10px"
+          >
+            已停用
+          </el-tag>
+        </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" min-width="240" fixed="right">
+      <el-table-column label="操作" align="center" width="200" fixed="right">
         <template #default="scope">
           <el-button
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
             v-hasPermi="['bpm:model:update']"
+            :disabled="!isManagerUser(scope.row)"
           >
-            修改流程
+            修改
           </el-button>
           <el-button
             link
+            class="!ml-5px"
             type="primary"
             @click="handleDesign(scope.row)"
             v-hasPermi="['bpm:model:update']"
+            :disabled="!isManagerUser(scope.row)"
           >
-            设计流程
-          </el-button>
-          <el-button
-            link
-            type="primary"
-            @click="handleSimpleDesign(scope.row.id)"
-            v-hasPermi="['bpm:model:update']"
-          >
-            仿钉钉设计流程
+            设计
           </el-button>
           <el-button
             link
+            class="!ml-5px"
             type="primary"
             @click="handleDeploy(scope.row)"
             v-hasPermi="['bpm:model:deploy']"
+            :disabled="!isManagerUser(scope.row)"
           >
-            发布流程
-          </el-button>
-          <el-button
-            link
-            type="primary"
-            v-hasPermi="['bpm:process-definition:query']"
-            @click="handleDefinitionList(scope.row)"
-          >
-            流程定义
+            发布
           </el-button>
-          <el-button
-            link
-            type="danger"
-            @click="handleDelete(scope.row.id)"
-            v-hasPermi="['bpm:model:delete']"
+          <el-dropdown
+            class="!align-middle ml-5px"
+            @command="(command) => handleCommand(command, scope.row)"
+            v-hasPermi="['bpm:process-definition:query', 'bpm:model:update', 'bpm:model:delete']"
           >
-            删除
-          </el-button>
+            <el-button type="primary" link>更多</el-button>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item
+                  command="handleDefinitionList"
+                  v-if="checkPermi(['bpm:process-definition:query'])"
+                >
+                  历史
+                </el-dropdown-item>
+                <el-dropdown-item
+                  command="handleChangeState"
+                  v-if="checkPermi(['bpm:model:update']) && scope.row.processDefinition"
+                  :disabled="!isManagerUser(scope.row)"
+                >
+                  {{ scope.row.processDefinition.suspensionState === 1 ? '停用' : '启用' }}
+                </el-dropdown-item>
+                <el-dropdown-item
+                  type="danger"
+                  command="handleDelete"
+                  v-if="checkPermi(['bpm:model:delete'])"
+                  :disabled="!isManagerUser(scope.row)"
+                >
+                  删除
+                </el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
         </template>
       </el-table-column>
     </el-table>
@@ -215,41 +211,29 @@
   <!-- 表单弹窗:添加/修改流程 -->
   <ModelForm ref="formRef" @success="getList" />
 
-  <!-- 表单弹窗:导入流程 -->
-  <ModelImportForm ref="importFormRef" @success="getList" />
-
   <!-- 弹窗:表单详情 -->
   <Dialog title="表单详情" v-model="formDetailVisible" width="800">
     <form-create :rule="formDetailPreview.rule" :option="formDetailPreview.option" />
   </Dialog>
-
-  <!-- 弹窗:流程模型图的预览 -->
-  <Dialog title="流程图" v-model="bpmnDetailVisible" width="800">
-    <MyProcessViewer
-      key="designer"
-      v-model="bpmnXML"
-      :value="bpmnXML as any"
-      v-bind="bpmnControlForm"
-      :prefix="bpmnControlForm.prefix"
-    />
-  </Dialog>
 </template>
 
 <script lang="ts" setup>
-import { dateFormatter, formatDate } from '@/utils/formatTime'
-import { MyProcessViewer } from '@/components/bpmnProcessDesigner/package'
+import { formatDate } from '@/utils/formatTime'
 import * as ModelApi from '@/api/bpm/model'
 import * as FormApi from '@/api/bpm/form'
 import ModelForm from './ModelForm.vue'
-import ModelImportForm from '@/views/bpm/model/ModelImportForm.vue'
 import { setConfAndFields2 } from '@/utils/formCreate'
 import { CategoryApi } from '@/api/bpm/category'
+import { BpmModelType } from '@/utils/constants'
+import { checkPermi } from '@/utils/permission'
+import { useUserStoreWithOut } from '@/store/modules/user'
 
 defineOptions({ name: 'BpmModel' })
 
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 const { push } = useRouter() // 路由
+const userStore = useUserStoreWithOut() // 用户信息缓存
 
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
@@ -288,25 +272,36 @@ const resetQuery = () => {
   handleQuery()
 }
 
+/** '更多'操作按钮 */
+const handleCommand = (command: string, row: any) => {
+  switch (command) {
+    case 'handleDefinitionList':
+      handleDefinitionList(row)
+      break
+    case 'handleDelete':
+      handleDelete(row)
+      break
+    case 'handleChangeState':
+      handleChangeState(row)
+      break
+    default:
+      break
+  }
+}
+
 /** 添加/修改操作 */
 const formRef = ref()
 const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
-/** 添加/修改操作 */
-const importFormRef = ref()
-const openImportForm = () => {
-  importFormRef.value.open()
-}
-
 /** 删除按钮操作 */
-const handleDelete = async (id: number) => {
+const handleDelete = async (row: any) => {
   try {
     // 删除的二次确认
     await message.delConfirm()
     // 发起删除
-    await ModelApi.deleteModel(id)
+    await ModelApi.deleteModel(row.id)
     message.success(t('common.delSuccess'))
     // 刷新列表
     await getList()
@@ -314,45 +309,45 @@ const handleDelete = async (id: number) => {
 }
 
 /** 更新状态操作 */
-const handleChangeState = async (row) => {
+const handleChangeState = async (row: any) => {
   const state = row.processDefinition.suspensionState
+  const newState = state === 1 ? 2 : 1
   try {
     // 修改状态的二次确认
     const id = row.id
-    const statusState = state === 1 ? '激活' : '挂起'
+    debugger
+    const statusState = state === 1 ? '停用' : '启用'
     const content = '是否确认' + statusState + '流程名字为"' + row.name + '"的数据项?'
     await message.confirm(content)
     // 发起修改状态
-    await ModelApi.updateModelState(id, state)
+    await ModelApi.updateModelState(id, newState)
+    message.success(statusState + '成功')
     // 刷新列表
     await getList()
-  } catch {
-    // 取消后,进行恢复按钮
-    row.processDefinition.suspensionState = state === 1 ? 2 : 1
-  }
+  } catch {}
 }
 
 /** 设计流程 */
-const handleDesign = (row) => {
-  push({
-    name: 'BpmModelEditor',
-    query: {
-      modelId: row.id
-    }
-  })
-}
-
-const handleSimpleDesign = (row) => {
-  push({
-    name: 'SimpleWorkflowDesignEditor',
-    query: {
-      modelId: row.id
-    }
-  })
+const handleDesign = (row: any) => {
+  if (row.type == BpmModelType.BPMN) {
+    push({
+      name: 'BpmModelEditor',
+      query: {
+        modelId: row.id
+      }
+    })
+  } else {
+    push({
+      name: 'SimpleWorkflowDesignEditor',
+      query: {
+        modelId: row.id
+      }
+    })
+  }
 }
 
 /** 发布流程 */
-const handleDeploy = async (row) => {
+const handleDeploy = async (row: any) => {
   try {
     // 删除的二次确认
     await message.confirm('是否部署该流程!!')
@@ -380,7 +375,7 @@ const formDetailPreview = ref({
   rule: [],
   option: {}
 })
-const handleFormDetail = async (row) => {
+const handleFormDetail = async (row: any) => {
   if (row.formType == 10) {
     // 设置表单
     const data = await FormApi.getForm(row.formId)
@@ -394,16 +389,10 @@ const handleFormDetail = async (row) => {
   }
 }
 
-/** 流程图的详情按钮操作 */
-const bpmnDetailVisible = ref(false)
-const bpmnXML = ref(null)
-const bpmnControlForm = ref({
-  prefix: 'flowable'
-})
-const handleBpmnDetail = async (row) => {
-  const data = await ModelApi.getModel(row.id)
-  bpmnXML.value = data.bpmnXml || ''
-  bpmnDetailVisible.value = true
+/** 判断是否可以操作 */
+const isManagerUser = (row: any) => {
+  const userId = userStore.getUser.id
+  return row.managerUserIds && row.managerUserIds.includes(userId)
 }
 
 /** 初始化 **/

+ 85 - 18
src/views/bpm/processInstance/detail/ProcessInstanceOperationButton.vue

@@ -1,13 +1,22 @@
 <template>
   <div
-    class="h-50px position-fixed bottom-10 text-14px flex items-center color-#32373c dark:color-#fff font-bold btn-container"
+    class="h-50px bottom-10 text-14px flex items-center color-#32373c dark:color-#fff font-bold btn-container"
+    v-if="runningTask.id"
   >
-    <el-popover :visible="passVisible" placement="top-end" :width="500" trigger="click">
+    <!-- 【通过】按钮 -->
+    <el-popover
+      :visible="passVisible"
+      placement="top-end"
+      :width="500"
+      trigger="click"
+      v-if="isShowButton(OperationButtonType.APPROVE)"
+    >
       <template #reference>
         <el-button plain type="success" @click="openPopover('1')">
-          <Icon icon="ep:select" />&nbsp; 通过
+          <Icon icon="ep:select" />&nbsp; {{ getButtonDisplayName(OperationButtonType.APPROVE) }}
         </el-button>
       </template>
+      <!-- 审批表单 -->
       <div class="flex flex-col flex-1 pt-20px px-20px" v-loading="formLoading">
         <el-form
           label-position="top"
@@ -50,19 +59,28 @@
 
           <el-form-item>
             <el-button :disabled="formLoading" type="success" @click="handleAudit(true)">
-              通过
+              {{ getButtonDisplayName(OperationButtonType.APPROVE) }}
             </el-button>
             <el-button @click="passVisible = false"> 取消 </el-button>
           </el-form-item>
         </el-form>
       </div>
     </el-popover>
-    <el-popover :visible="rejectVisible" placement="top-end" :width="500" trigger="click">
+
+    <!-- 【拒绝】按钮 -->
+    <el-popover
+      :visible="rejectVisible"
+      placement="top-end"
+      :width="500"
+      trigger="click"
+      v-if="isShowButton(OperationButtonType.REJECT)"
+    >
       <template #reference>
         <el-button class="mr-20px" plain type="danger" @click="openPopover('2')">
-          <Icon icon="ep:close" />&nbsp; 拒绝
+          <Icon icon="ep:close" />&nbsp; {{ getButtonDisplayName(OperationButtonType.REJECT) }}
         </el-button>
       </template>
+      <!-- 审批表单 -->
       <div class="flex flex-col flex-1 pt-20px px-20px" v-loading="formLoading">
         <el-form
           label-position="top"
@@ -105,21 +123,46 @@
 
           <el-form-item>
             <el-button :disabled="formLoading" type="danger" @click="handleAudit(false)">
-              拒绝
+              {{ getButtonDisplayName(OperationButtonType.REJECT) }}
             </el-button>
             <el-button @click="rejectVisible = false"> 取消 </el-button>
           </el-form-item>
         </el-form>
       </div>
     </el-popover>
+
+    <!-- 【抄送】按钮 -->
     <div @click="handleSend"> <Icon :size="14" icon="svg-icon:send" />&nbsp;抄送 </div>
-    <div @click="openTaskUpdateAssigneeForm">
-      <Icon :size="14" icon="fa:share-square-o" />&nbsp;转交
+
+    <!-- 【转交】按钮 -->
+    <div @click="openTaskUpdateAssigneeForm" v-if="isShowButton(OperationButtonType.TRANSFER)">
+      <Icon :size="14" icon="fa:share-square-o" />&nbsp;
+      {{ getButtonDisplayName(OperationButtonType.TRANSFER) }}
+    </div>
+
+    <!-- 【委托】按钮 -->
+    <div @click="handleDelegate" v-if="isShowButton(OperationButtonType.DELEGATE)">
+      <Icon :size="14" icon="ep:position" />&nbsp;
+      {{ getButtonDisplayName(OperationButtonType.DELEGATE) }}
+    </div>
+
+    <!-- 【加签】 -->
+    <div @click="handleSign" v-if="isShowButton(OperationButtonType.ADD_SIGN)">
+      <Icon :size="14" icon="ep:plus" />&nbsp;
+      {{ getButtonDisplayName(OperationButtonType.ADD_SIGN) }}
+    </div>
+    <!-- TODO @jason:减签 -->
+
+    <!-- 【退回】按钮 -->
+    <div @click="handleBack" v-if="isShowButton(OperationButtonType.RETURN)">
+      <Icon :size="14" icon="fa:mail-reply" />&nbsp;
+      {{ getButtonDisplayName(OperationButtonType.RETURN) }}
     </div>
-    <div @click="handleDelegate"> <Icon :size="14" icon="ep:position" />&nbsp;委派 </div>
-    <div @click="handleSign"> <Icon :size="14" icon="ep:plus" />&nbsp;加签 </div>
-    <div @click="handleBack"> <Icon :size="14" icon="fa:mail-reply" />&nbsp;退回 </div>
+
+    <!--TODO @jason:撤回 -->
+    <!--TODO @jason:再次发起 -->
   </div>
+
   <!-- 弹窗:转派审批人 -->
   <TaskTransferForm ref="taskTransferFormRef" @success="getDetail" />
   <!-- 弹窗:回退节点 -->
@@ -129,7 +172,6 @@
   <!-- 弹窗:加签,当前任务审批人为A,向前加签选了一个C,则需要C先审批,然后再是A审批,向后加签B,A审批完,需要B再审批完,才算完成这个任务节点 -->
   <TaskSignCreateForm ref="taskSignCreateFormRef" @success="getDetail" />
 </template>
-
 <script lang="ts" setup>
 import { setConfAndFields2 } from '@/utils/formCreate'
 import { useUserStore } from '@/store/modules/user'
@@ -140,7 +182,10 @@ import TaskDelegateForm from './dialog/TaskDelegateForm.vue'
 import TaskTransferForm from './dialog/TaskTransferForm.vue'
 import TaskSignCreateForm from './dialog/TaskSignCreateForm.vue'
 import { isEmpty } from '@/utils/is'
-
+import {
+  OperationButtonType,
+  OPERATION_BUTTON_NAME
+} from '@/components/SimpleProcessDesignerV2/src/consts'
 defineOptions({ name: 'ProcessInstanceBtnConatiner' })
 
 const userId = useUserStore().getUser.id // 当前登录的编号
@@ -175,15 +220,17 @@ watch(
     deep: true
   }
 )
+
+// TODO @jaosn:具体的审批任务,要不改成后端返回。让前端弱化下
 /**
  * 设置 runningTasks 中的任务
  */
-const loadRunningTask = (tasks) => {
+const loadRunningTask = (tasks: any[]) => {
   runningTask.value = {}
   auditForm.value = {}
   approveForm.value = {}
   approveFormFApi.value = {}
-  tasks.forEach((task) => {
+  tasks.forEach((task: any) => {
     if (!isEmpty(task.children)) {
       loadRunningTask(task.children)
     }
@@ -214,7 +261,7 @@ const loadRunningTask = (tasks) => {
 }
 
 /** 处理审批通过和不通过的操作 */
-const handleAudit = async (pass) => {
+const handleAudit = async (pass: any) => {
   formLoading.value = true
   try {
     const auditFormRef = proxy.$refs['formRef']
@@ -254,6 +301,7 @@ const handleAudit = async (pass) => {
 /* 抄送 TODO */
 const handleSend = () => {}
 
+// TODO 代码优化:这里 flag 改成 approve: boolean 。因为 flag 目前就只有 1 和 2
 const openPopover = (flag) => {
   passVisible.value = false
   rejectVisible.value = false
@@ -289,6 +337,24 @@ const getDetail = () => {
   emit('success')
 }
 
+/** 是否显示按钮 */
+const isShowButton = (btnType: OperationButtonType): boolean => {
+  let isShow = true
+  if (runningTask.value.buttonsSetting && runningTask.value.buttonsSetting[btnType]) {
+    isShow = runningTask.value.buttonsSetting[btnType].enable
+  }
+  return isShow
+}
+
+/** 获取按钮的显示名称 */
+const getButtonDisplayName = (btnType: OperationButtonType) => {
+  let displayName = OPERATION_BUTTON_NAME.get(btnType)
+  if (runningTask.value.buttonsSetting && runningTask.value.buttonsSetting[btnType]) {
+    displayName = runningTask.value.buttonsSetting[btnType].displayName
+  }
+  return displayName
+}
+
 defineExpose({ loadRunningTask })
 </script>
 
@@ -299,10 +365,11 @@ defineExpose({ loadRunningTask })
 
 .btn-container {
   > div {
+    display: flex;
     margin: 0 15px;
     cursor: pointer;
-    display: flex;
     align-items: center;
+
     &:hover {
       color: #6db5ff;
     }

+ 202 - 130
src/views/bpm/processInstance/detail/ProcessInstanceTimeline.vue

@@ -1,9 +1,111 @@
+<!-- 审批详情的右侧:审批流 -->
 <template>
   <el-timeline class="pt-20px">
-    <el-timeline-item v-for="(activity, index) in mockData" :key="index" size="large">
+    <!-- 遍历每个审批节点 -->
+    <el-timeline-item
+      v-for="(activity, index) in approveNodes"
+      :key="index"
+      size="large"
+      :icon="getApprovalNodeIcon(activity.status, activity.nodeType)"
+      :color="getApprovalNodeColor(activity.status)"
+    >
       <div class="flex flex-col items-start">
         <div class="font-bold"> {{ activity.name }}</div>
-        <div class="color-#a1a6ae text-12px mb-10px"> {{ activity.assigneeUser.nickname }}</div>
+        <div class="flex items-center mt-1">
+          <!-- 情况一:遍历每个审批节点下的【进行中】task 任务 -->
+          <div v-for="(task, idx) in activity.tasks" :key="idx" class="flex items-center">
+            <div class="flex items-center flex-col pr-2">
+              <div class="position-relative" v-if="task.assigneeUser || task.ownerUser">
+                <!-- 信息:头像 -->
+                <el-avatar
+                  :size="36"
+                  v-if="task.assigneeUser && task.assigneeUser.avatar"
+                  :src="task.assigneeUser.avatar"
+                />
+                <el-avatar v-else-if="task.assigneeUser && task.assigneeUser.nickname">
+                  {{ task.assigneeUser.nickname.substring(0, 1) }}
+                </el-avatar>
+                <el-avatar
+                  v-else-if="task.ownerUser && task.ownerUser.avatar"
+                  :src="task.ownerUser.avatar"
+                />
+                <el-avatar v-else-if="task.ownerUser && task.ownerUser.nickname">
+                  {{ task.ownerUser.nickname.substring(0, 1) }}
+                </el-avatar>
+                <!-- 信息:任务 ICON -->
+                <div
+                  class="position-absolute top-26px left-26px bg-#fff rounded-full flex items-center p-2px"
+                >
+                  <Icon
+                    :size="12"
+                    :icon="statusIconMap2[task.status]?.icon"
+                    :color="statusIconMap2[task.status]?.color"
+                  />
+                </div>
+              </div>
+              <div class="flex flex-col mt-1">
+                <!-- 信息:昵称 -->
+                <div
+                  v-if="task.assigneeUser && task.assigneeUser.nickname"
+                  class="text-10px text-align-center"
+                >
+                  {{ task.assigneeUser.nickname }}
+                </div>
+                <div
+                  v-else-if="task.ownerUser && task.ownerUser.nickname"
+                  class="text-10px text-align-center"
+                >
+                  {{ task.ownerUser.nickname }}
+                </div>
+                <!-- TODO @jason:审批意见,要展示哈。 -->
+                <!-- <div v-if="task.reason" :title="task.reason" class="text-13px text-truncate w-150px mt-1"> 审批意见: {{ task.reason }}</div> -->
+              </div>
+            </div>
+          </div>
+          <!-- 情况二:遍历每个审批节点下的【候选的】task 任务。例如说,1)依次审批,2)未来的审批任务等 -->
+          <div
+            v-for="(user, idx1) in activity.candidateUserList"
+            :key="idx1"
+            class="flex items-center"
+          >
+            <div class="flex items-center flex-col pr-2">
+              <div class="position-relative">
+                <!-- 信息:头像 -->
+                <el-avatar :size="36" v-if="user.avatar" :src="user.avatar" />
+                <el-avatar v-else-if="user.nickname && user.nickname">
+                  {{ user.nickname.substring(0, 1) }}
+                </el-avatar>
+                <!-- 信息:任务 ICON -->
+                <div
+                  class="position-absolute top-26px left-26px bg-#fff rounded-full flex items-center p-2px"
+                >
+                  <Icon
+                    :size="12"
+                    :icon="statusIconMap2['-1']?.icon"
+                    :color="statusIconMap2['-1']?.color"
+                  />
+                </div>
+              </div>
+              <div class="flex flex-col mt-1">
+                <!-- 信息:昵称 -->
+                <div v-if="user.nickname" class="text-10px text-align-center">
+                  {{ user.nickname }}
+                </div>
+                <!-- <div v-if="task.reason" :title="task.reason" class="text-13px text-truncate w-150px mt-1"> 审批意见: {{ task.reason }}</div> -->
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- 信息:时间 -->
+        <div
+          v-if="activity.status !== TaskStatusEnum.NOT_START"
+          class="text-#a5a5a5 text-13px mt-1"
+        >
+          {{ getApprovalNodeTime(activity) }}
+        </div>
+
+        <!-- TODO @jason:审批意见,要展示哈。 -->
+        <!-- <div class="color-#a1a6ae text-12px mb-10px"> {{ activity.assigneeUser.nickname }}</div>
         <div v-if="activity.opinion" class="text-#a5a5a5 text-12px w-100%">
           <div class="mb-5px">审批意见:</div>
           <div
@@ -14,148 +116,118 @@
         </div>
         <div v-if="activity.createTime" class="text-#a5a5a5 text-13px">
           {{ formatDate(activity.createTime) }}
-        </div>
+        </div> -->
       </div>
-      <!-- 该节点用户的头像 -->
-      <template #dot>
-        <div class="w-35px h-35px position-relative">
-          <img
-            src="@/assets/imgs/avatar.jpg"
-            class="rounded-full w-full h-full position-absolute bottom-6px right-12px"
-            alt=""
-          />
-          <div
-            class="position-absolute top-16px left-8px bg-#fff rounded-full flex items-center content-center p-2px"
-          >
-            <Icon
-              :size="12"
-              :icon="optIconMap[activity.status]?.icon"
-              :color="optIconMap[activity.status]?.color"
-            />
-          </div>
-        </div>
-      </template>
     </el-timeline-item>
   </el-timeline>
 </template>
 
 <script lang="ts" setup>
 import { formatDate } from '@/utils/formatTime'
-import { propTypes } from '@/utils/propTypes'
-
+import * as ProcessInstanceApi from '@/api/bpm/processInstance'
+import { TaskStatusEnum } from '@/api/bpm/task'
+import { NodeType } from '@/components/SimpleProcessDesignerV2/src/consts'
+import { Check, Close, Loading, Clock, Minus, Delete } from '@element-plus/icons-vue'
 defineOptions({ name: 'BpmProcessInstanceTimeline' })
-defineProps({
-  tasks: propTypes.array // 流程任务的数组
+const props = defineProps({
+  // 流程实例编号
+  processInstanceId: {
+    type: String,
+    required: false,
+    default: ''
+  },
+  // 流程定义编号
+  processDefinitionId: {
+    type: String,
+    required: false,
+    default: ''
+  }
 })
 
-const optIconMap = {
+// 审批节点
+const approveNodes = ref<ProcessInstanceApi.ApprovalNodeInfo[]>([])
+
+const statusIconMap2 = {
+  // 未开始
+  '-1': { color: '#e5e7ec', icon: 'ep-clock' },
+  // 待审批
+  '0': { color: '#e5e7ec', icon: 'ep:loading' },
   // 审批中
-  '1': {
-    color: '#00b32a',
-    icon: 'fa-solid:clock'
-  },
+  '1': { color: '#448ef7', icon: 'ep:loading' },
   // 审批通过
-  '2': { color: '#00b32a', icon: 'fa-solid:check-circle' },
+  '2': { color: '#00b32a', icon: 'ep:circle-check-filled' },
   // 审批不通过
-  '3': { color: '#f46b6c', icon: 'fa-solid:times-circle' }
+  '3': { color: '#f46b6c', icon: 'fa-solid:times-circle' },
+  // 取消
+  '4': { color: '#cccccc', icon: 'ep:delete-filled' },
+  // 回退
+  '5': { color: '#f46b6c', icon: 'ep:remove-filled' },
+  // 委派中
+  '6': { color: '#448ef7', icon: 'ep:loading' },
+  // 审批通过中
+  '7': { color: '#00b32a', icon: 'ep:circle-check-filled' }
 }
 
-const mockData: any = [
-  {
-    id: 'fe1190ee-68c3-11ef-9c7d-00a6181404fd',
-    name: '发起人',
-    createTime: 1725237646192,
-    endTime: null,
-    durationInMillis: null,
-    status: 1,
-    reason: null,
-    ownerUser: null,
-    assigneeUser: {
-      id: 104,
-      nickname: '测试号',
-      deptId: 107,
-      deptName: '运维部门'
-    },
-    taskDefinitionKey: 'task-01',
-    processInstanceId: 'fe0c60c6-68c3-11ef-9c7d-00a6181404fd',
-    processInstance: {
-      id: 'fe0c60c6-68c3-11ef-9c7d-00a6181404fd',
-      name: 'oa_leave',
-      createTime: null,
-      processDefinitionId: 'oa_leave:1:6e5ac269-5f87-11ef-bdb6-00a6181404fd',
-      startUser: null
-    },
-    parentTaskId: null,
-    children: null,
-    formId: null,
-    formName: null,
-    formConf: null,
-    formFields: null,
-    formVariables: null
-  },
-  {
-    id: 'fe1190ee-68c3-11ef-9c7d-00a6181404fd',
-    name: '领导审批',
-    createTime: 1725237646192,
-    endTime: null,
-    durationInMillis: null,
-    status: 2,
-    reason: null,
-    ownerUser: null,
-    assigneeUser: {
-      id: 104,
-      nickname: '领导',
-      deptId: 107,
-      deptName: '运维部门'
-    },
-    taskDefinitionKey: 'task-01',
-    processInstanceId: 'fe0c60c6-68c3-11ef-9c7d-00a6181404fd',
-    processInstance: {
-      id: 'fe0c60c6-68c3-11ef-9c7d-00a6181404fd',
-      name: 'oa_leave',
-      createTime: null,
-      processDefinitionId: 'oa_leave:1:6e5ac269-5f87-11ef-bdb6-00a6181404fd',
-      startUser: null
-    },
-    parentTaskId: null,
-    children: null,
-    formId: null,
-    formName: null,
-    formConf: null,
-    formFields: null,
-    formVariables: null
-  },
-  {
-    id: 'fe1190ee-68c3-11ef-9c7d-00a6181404fd',
-    name: '财务总监审核',
-    createTime: 1725237646192,
-    endTime: null,
-    durationInMillis: null,
-    status: 3,
-    reason: null,
-    ownerUser: null,
-    assigneeUser: {
-      id: 104,
-      nickname: '财务总监',
-      deptId: 107,
-      deptName: '运维部门'
-    },
-    taskDefinitionKey: 'task-01',
-    processInstanceId: 'fe0c60c6-68c3-11ef-9c7d-00a6181404fd',
-    processInstance: {
-      id: 'fe0c60c6-68c3-11ef-9c7d-00a6181404fd',
-      name: 'oa_leave',
-      createTime: null,
-      processDefinitionId: 'oa_leave:1:6e5ac269-5f87-11ef-bdb6-00a6181404fd',
-      startUser: null
-    },
-    parentTaskId: null,
-    children: null,
-    formId: null,
-    formName: null,
-    formConf: null,
-    formFields: null,
-    formVariables: null
+const statusIconMap = {
+  // 审批未开始
+  '-1': { color: '#e5e7ec', icon: Clock },
+  '0': { color: '#e5e7ec', icon: Clock },
+  // 审批中
+  '1': { color: '#448ef7', icon: Loading },
+  // 审批通过
+  '2': { color: '#00b32a', icon: Check },
+  // 审批不通过
+  '3': { color: '#f46b6c', icon: Close },
+  // 已取消
+  '4': { color: '#cccccc', icon: Delete },
+  // 回退
+  '5': { color: '#f46b6c', icon: Minus },
+  // 委派中
+  '6': { color: '#448ef7', icon: Loading },
+  // 审批通过中
+  '7': { color: '#00b32a', icon: Check }
+}
+
+/** 获得审批详情 */
+const getApprovalDetail = async () => {
+  const data = await ProcessInstanceApi.getApprovalDetail(
+    props.processInstanceId,
+    props.processDefinitionId
+  )
+  approveNodes.value = data.approveNodes
+}
+
+const getApprovalNodeIcon = (taskStatus: number, nodeType: NodeType) => {
+  if (taskStatus == TaskStatusEnum.NOT_START) {
+    return statusIconMap[taskStatus]?.icon
+  }
+
+  if (nodeType === NodeType.START_USER_NODE || nodeType === NodeType.USER_TASK_NODE) {
+    return statusIconMap[taskStatus]?.icon
+  }
+}
+
+const getApprovalNodeColor = (taskStatus: number) => {
+  return statusIconMap[taskStatus]?.color
+}
+
+const getApprovalNodeTime = (node: ProcessInstanceApi.ApprovalNodeInfo) => {
+  if (node.endTime) {
+    return `结束时间:${formatDate(node.endTime)}`
   }
-]
+  if (node.startTime) {
+    return `创建时间:${formatDate(node.startTime)}`
+  }
+}
+
+/** 重新刷新审批详情 */
+const refresh = () => {
+  getApprovalDetail()
+}
+
+defineExpose({ refresh })
+
+onMounted(async () => {
+  await getApprovalDetail()
+})
 </script>

+ 125 - 23
src/views/bpm/processInstance/detail/index.vue

@@ -56,29 +56,73 @@
           </el-form-item>
         </el-form>
         <div style="margin-bottom: 20px; margin-left: 10%; font-size: 14px">
-          <el-button type="success" @click="handleAudit(item, true)">
+          <!-- TODO @jason:建议搞个 if 来判断,替代现有的 !item.buttonsSetting || item.buttonsSetting[OpsButtonType.APPROVE]?.enable -->
+          <el-button
+            type="success"
+            v-if="!item.buttonsSetting || item.buttonsSetting[OperationButtonType.APPROVE]?.enable"
+            @click="handleAudit(item, true)"
+          >
             <Icon icon="ep:select" />
-            通过
+            <!-- TODO @jason:这个也是类似哈,搞个方法来生成名字 -->
+            {{
+              item.buttonsSetting?.[OperationButtonType.APPROVE]?.displayName ||
+              OPERATION_BUTTON_NAME.get(OperationButtonType.APPROVE)
+            }}
           </el-button>
-          <el-button type="danger" @click="handleAudit(item, false)">
+          <el-button
+            v-if="!item.buttonsSetting || item.buttonsSetting[OperationButtonType.REJECT]?.enable"
+            type="danger"
+            @click="handleAudit(item, false)"
+          >
             <Icon icon="ep:close" />
-            不通过
+            {{
+              item.buttonsSetting?.[OperationButtonType.REJECT].displayName ||
+              OPERATION_BUTTON_NAME.get(OperationButtonType.REJECT)
+            }}
           </el-button>
-          <el-button type="primary" @click="openTaskUpdateAssigneeForm(item.id)">
+          <el-button
+            v-if="!item.buttonsSetting || item.buttonsSetting[OperationButtonType.TRANSFER]?.enable"
+            type="primary"
+            @click="openTaskUpdateAssigneeForm(item.id)"
+          >
             <Icon icon="ep:edit" />
-            转办
+            {{
+              item.buttonsSetting?.[OperationButtonType.TRANSFER]?.displayName ||
+              OPERATION_BUTTON_NAME.get(OperationButtonType.TRANSFER)
+            }}
           </el-button>
-          <el-button type="primary" @click="handleDelegate(item)">
+          <el-button
+            v-if="!item.buttonsSetting || item.buttonsSetting[OperationButtonType.DELEGATE]?.enable"
+            type="primary"
+            @click="handleDelegate(item)"
+          >
             <Icon icon="ep:position" />
-            委派
+            {{
+              item.buttonsSetting?.[OperationButtonType.DELEGATE]?.displayName ||
+              OPERATION_BUTTON_NAME.get(OperationButtonType.DELEGATE)
+            }}
           </el-button>
-          <el-button type="primary" @click="handleSign(item)">
+          <el-button
+            v-if="!item.buttonsSetting || item.buttonsSetting[OperationButtonType.ADD_SIGN]?.enable"
+            type="primary"
+            @click="handleSign(item)"
+          >
             <Icon icon="ep:plus" />
-            加签
+            {{
+              item.buttonsSetting?.[OperationButtonType.ADD_SIGN]?.displayName ||
+              OPERATION_BUTTON_NAME.get(OperationButtonType.ADD_SIGN)
+            }}
           </el-button>
-          <el-button type="warning" @click="handleBack(item)">
+          <el-button
+            v-if="!item.buttonsSetting || item.buttonsSetting[OperationButtonType.RETURN]?.enable"
+            type="warning"
+            @click="handleBack(item)"
+          >
             <Icon icon="ep:back" />
-            回退
+            {{
+              item.buttonsSetting?.[OperationButtonType.RETURN]?.displayName ||
+              OPERATION_BUTTON_NAME.get(OperationButtonType.RETURN)
+            }}
           </el-button>
         </div>
       </el-col>
@@ -147,6 +191,10 @@ import TaskSignCreateForm from './dialog/TaskSignCreateForm.vue'
 import { registerComponent } from '@/utils/routerHelper'
 import { isEmpty } from '@/utils/is'
 import * as UserApi from '@/api/system/user'
+import {
+  OperationButtonType,
+  OPERATION_BUTTON_NAME
+} from '@/components/SimpleProcessDesignerV2/src/consts'
 
 defineOptions({ name: 'BpmProcessInstanceDetail' })
 
@@ -200,8 +248,14 @@ const handleAudit = async (task, pass) => {
   // 1.2 校验表单
   const elForm = unref(auditFormRef)
   if (!elForm) return
-  const valid = await elForm.validate()
+  let valid = await elForm.validate()
   if (!valid) return
+  // 校验申请表单(可编辑字段)
+  // TODO @jason:之前这里是 if (!fApi.value) return;针对业务表单的情况下,会导致没办法审核,可能要看下。我这里改了点,看看是不是还有别的地方兼容性
+  if (fApi.value) {
+    valid = await fApi.value.validate()
+    if (!valid) return
+  }
 
   // 2.1 提交审批
   const data = {
@@ -216,6 +270,11 @@ const handleAudit = async (task, pass) => {
       await formCreateApi.validate()
       data.variables = approveForms.value[index].value
     }
+    // 获取表单可编辑字段的值
+    if (fApi.value) {
+      data.variables = getWritableValueOfForm(task.fieldsPermission)
+    }
+
     await TaskApi.approveTask(data)
     message.success('审批通过成功')
   } else {
@@ -251,11 +310,11 @@ const handleSign = async (task: any) => {
 }
 
 /** 获得详情 */
-const getDetail = () => {
-  // 1. 获得流程实例相关
+const getDetail = async () => {
+  // 1. 获得流程任务列表(审批记录)。 需要先获取任务,表单的权限设置需要根据任务来设置
+  await getTaskList()
+  // 2. 获得流程实例相关
   getProcessInstance()
-  // 2. 获得流程任务列表(审批记录)
-  getTaskList()
 }
 
 /** 加载流程实例 */
@@ -273,16 +332,29 @@ const getProcessInstance = async () => {
     // 设置表单信息
     const processDefinition = data.processDefinition
     if (processDefinition.formType === 10) {
-      setConfAndFields2(
-        detailForm,
-        processDefinition.formConf,
-        processDefinition.formFields,
-        data.formVariables
-      )
+      if (detailForm.value.rule.length > 0) {
+        detailForm.value.value = data.formVariables
+      } else {
+        setConfAndFields2(
+          detailForm,
+          processDefinition.formConf,
+          processDefinition.formFields,
+          data.formVariables
+        )
+      }
       nextTick().then(() => {
         fApi.value?.btn.show(false)
         fApi.value?.resetBtn.show(false)
         fApi.value?.disabled(true)
+        // 设置表单权限。后续需要改造成。只处理一个运行中的任务
+        if (runningTasks.value.length > 0) {
+          const task = runningTasks.value.at(0)
+          if (task.fieldsPermission) {
+            Object.keys(task.fieldsPermission).forEach((item) => {
+              setFieldPermission(item, task.fieldsPermission[item])
+            })
+          }
+        }
       })
     } else {
       // 注意:data.processDefinition.formCustomViewPath 是组件的全路径,例如说:/crm/contract/detail/index.vue
@@ -353,6 +425,7 @@ const loadRunningTask = (tasks) => {
     if (!task.assigneeUser || task.assigneeUser.id !== userId) {
       return
     }
+
     // 2.3 添加到处理任务
     runningTasks.value.push({ ...task })
     auditForms.value.push({
@@ -371,6 +444,35 @@ const loadRunningTask = (tasks) => {
   })
 }
 
+/**
+ * 设置表单权限
+ */
+const setFieldPermission = (field: string, permission: string) => {
+  if (permission === '1') {
+    fApi.value?.disabled(true, field)
+  }
+  if (permission === '2') {
+    fApi.value?.disabled(false, field)
+  }
+  if (permission === '3') {
+    fApi.value?.hidden(true, field)
+  }
+}
+/**
+ * 获取可以编辑字段的值
+ */
+const getWritableValueOfForm = (fieldsPermission: Object) => {
+  const fieldsValue = {}
+  if (fieldsPermission && fApi.value) {
+    Object.keys(fieldsPermission).forEach((item) => {
+      if (fieldsPermission[item] === '2') {
+        fieldsValue[item] = fApi.value.getValue(item)
+      }
+    })
+  }
+  return fieldsValue
+}
+
 /** 初始化 */
 const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
 onMounted(async () => {

+ 176 - 85
src/views/bpm/processInstance/detail/index_new.vue

@@ -1,88 +1,106 @@
 <template>
   <ContentWrap :bodyStyle="{ padding: '10px 20px' }" class="position-relative">
-    <img
-      class="position-absolute right-20px"
-      width="150"
-      :src="auditIcons[processInstance.status]"
-      alt=""
-    />
-    <div class="text-#878c93">编号:{{ id }}</div>
-    <el-divider class="!my-8px" />
-    <div class="flex items-center gap-5 mb-10px">
-      <div class="text-26px font-bold mb-5px">{{ processInstance.name }}</div>
-      <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="processInstance.status" />
-    </div>
+    <div class="processInstance-wrap-main">
+      <el-scrollbar>
+        <img
+          class="position-absolute right-20px"
+          width="150"
+          :src="auditIcons[processInstance.status]"
+          alt=""
+        />
+        <div class="text-#878c93 h-15px">编号:{{ id }}</div>
+        <el-divider class="!my-8px" />
+        <div class="flex items-center gap-5 mb-10px h-40px">
+          <div class="text-26px font-bold mb-5px">{{ processInstance.name }}</div>
+          <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="processInstance.status" />
+        </div>
 
-    <div class="flex items-center gap-5 mb-10px text-13px">
-      <div class="bg-gray-100 h-35px rounded-3xl flex items-center p-8px gap-2 dark:color-gray-600">
-        <img class="rounded-full h-28px" src="@/assets/imgs/avatar.jpg" alt="" />
-        {{ processInstance?.startUser?.nickname }}
-      </div>
-      <div class="text-#878c93"> {{ formatDate(processInstance.startTime) }} 提交 </div>
-    </div>
+        <div class="flex items-center gap-5 mb-10px text-13px h-35px">
+          <div
+            class="bg-gray-100 h-35px rounded-3xl flex items-center p-8px gap-2 dark:color-gray-600"
+          >
+            <img class="rounded-full h-28px" src="@/assets/imgs/avatar.jpg" alt="" />
+            {{ processInstance?.startUser?.nickname }}
+          </div>
+          <div class="text-#878c93"> {{ formatDate(processInstance.startTime) }} 提交 </div>
+        </div>
 
-    <el-tabs>
-      <!-- 表单信息 -->
-      <el-tab-pane label="表单信息">
-        <el-row :gutter="10">
-          <el-col :span="18" class="!flex !flex-col formCol">
-            <!-- 表单信息 -->
-            <div v-loading="processInstanceLoading" class="form-box flex flex-col mb-30px flex-1">
-              <!-- 情况一:流程表单 -->
-              <el-col
-                v-if="processInstance?.processDefinition?.formType === 10"
-                :offset="6"
-                :span="16"
-              >
-                <form-create
-                  v-model="detailForm.value"
-                  v-model:api="fApi"
-                  :option="detailForm.option"
-                  :rule="detailForm.rule"
-                />
-              </el-col>
-              <!-- 情况二:业务表单 -->
-              <div v-if="processInstance?.processDefinition?.formType === 20">
-                <BusinessFormComponent :id="processInstance.businessKey" />
-              </div>
+        <el-tabs v-model="activeTab">
+          <!-- 表单信息 -->
+          <el-tab-pane label="审批详情" name="form">
+            <div class="form-scroll-area">
+              <el-scrollbar>
+                <el-row :gutter="10">
+                  <el-col :span="18" class="!flex !flex-col formCol">
+                    <!-- 表单信息 -->
+                    <div
+                      v-loading="processInstanceLoading"
+                      class="form-box flex flex-col mb-30px flex-1"
+                    >
+                      <!-- 情况一:流程表单 -->
+                      <el-col
+                        v-if="processInstance?.processDefinition?.formType === 10"
+                        :offset="6"
+                        :span="16"
+                      >
+                        <form-create
+                          v-model="detailForm.value"
+                          v-model:api="fApi"
+                          :option="detailForm.option"
+                          :rule="detailForm.rule"
+                        />
+                      </el-col>
+                      <!-- 情况二:业务表单 -->
+                      <div v-if="processInstance?.processDefinition?.formType === 20">
+                        <BusinessFormComponent :id="processInstance.businessKey" />
+                      </div>
+                    </div>
+                  </el-col>
+                  <el-col :span="6">
+                    <!-- 审批记录时间线 -->
+                    <ProcessInstanceTimeline ref="timelineRef" :process-instance-id="id" />
+                  </el-col>
+                </el-row>
+              </el-scrollbar>
             </div>
-
-            <!-- 操作栏按钮 -->
-            <ProcessInstanceOperationButton
-              ref="operationButtonRef"
-              :processInstance="processInstance"
-              :userOptions="userOptions"
-              @success="getDetail"
+          </el-tab-pane>
+          <!-- 流程图 -->
+          <el-tab-pane label="流程图" name="diagram">
+            <ProcessInstanceBpmnViewer
+              :id="`${id}`"
+              :bpmn-xml="bpmnXml"
+              :loading="processInstanceLoading"
+              :process-instance="processInstance"
+              :tasks="tasks"
             />
-          </el-col>
-          <el-col :span="6">
-            <!-- 审批记录时间线 -->
-            <ProcessInstanceTimeline :process-instance="processInstance" :tasks="tasks" />
-          </el-col>
-        </el-row>
-      </el-tab-pane>
-      <!-- 流程图 -->
-      <el-tab-pane label="流程图">
-        <ProcessInstanceBpmnViewer
-          :id="`${id}`"
-          :bpmn-xml="bpmnXml"
-          :loading="processInstanceLoading"
-          :process-instance="processInstance"
-          :tasks="tasks"
-        />
-      </el-tab-pane>
-      <!-- 流转记录 -->
-      <el-tab-pane label="流转记录">
-        <ProcessInstanceTaskList
-          :loading="tasksLoad"
-          :process-instance="processInstance"
-          :tasks="tasks"
-          @refresh="getTaskList"
-        />
-      </el-tab-pane>
-      <!-- 流转评论 -->
-      <el-tab-pane label="流转评论"> 流转评论 </el-tab-pane>
-    </el-tabs>
+          </el-tab-pane>
+          <!-- 流转记录 -->
+          <el-tab-pane label="流转记录" name="record">
+            <ProcessInstanceTaskList
+              :loading="tasksLoad"
+              :process-instance="processInstance"
+              :tasks="tasks"
+              @refresh="getTaskList"
+            />
+          </el-tab-pane>
+          <!-- 流转评论 TODO 待开发 -->
+          <el-tab-pane label="流转评论" name="comment"> 流转评论 </el-tab-pane>
+        </el-tabs>
+
+        <div
+          class="b-t-solid border-t-1px border-[var(--el-border-color)]"
+          v-if="activeTab === 'form'"
+        >
+          <!-- 操作栏按钮 -->
+          <ProcessInstanceOperationButton
+            ref="operationButtonRef"
+            :processInstance="processInstance"
+            :userOptions="userOptions"
+            @success="refresh"
+          />
+        </div>
+      </el-scrollbar>
+    </div>
   </ContentWrap>
 </template>
 <script lang="ts" setup>
@@ -99,18 +117,22 @@ import ProcessInstanceOperationButton from './ProcessInstanceOperationButton.vue
 import ProcessInstanceTimeline from './ProcessInstanceTimeline.vue'
 import { registerComponent } from '@/utils/routerHelper'
 import * as UserApi from '@/api/system/user'
+import { FieldPermissionType } from '@/components/SimpleProcessDesignerV2/src/consts'
 import audit1 from '@/assets/svgs/bpm/audit1.svg'
 import audit2 from '@/assets/svgs/bpm/audit2.svg'
 import audit3 from '@/assets/svgs/bpm/audit3.svg'
 
 defineOptions({ name: 'BpmProcessInstanceDetail' })
-
-const { query } = useRoute() // 查询参数
+const props = defineProps<{
+  id: string // 流程实例的编号
+  taskId?: string // 任务编号
+  activityId?: string //流程活动编号,用于抄送查看
+}>()
 const message = useMessage() // 消息弹窗
-const id = query.id as unknown as string // 流程实例的编号
 const processInstanceLoading = ref(false) // 流程实例的加载中
 const processInstance = ref<any>({}) // 流程实例
 const operationButtonRef = ref()
+const timelineRef = ref()
 const bpmnXml = ref('') // BPMN XML
 const tasksLoad = ref(true) // 任务的加载中
 const tasks = ref<any[]>([]) // 任务列表
@@ -141,7 +163,7 @@ const BusinessFormComponent = ref<any>(null) // 异步组件
 const getProcessInstance = async () => {
   try {
     processInstanceLoading.value = true
-    const data = await ProcessInstanceApi.getProcessInstance(id)
+    const data = await ProcessInstanceApi.getProcessInstance(props.id)
     if (!data) {
       message.error('查询不到流程信息!')
       return
@@ -151,6 +173,15 @@ const getProcessInstance = async () => {
     // 设置表单信息
     const processDefinition = data.processDefinition
     if (processDefinition.formType === 10) {
+      // 获取表单字段权限
+      let fieldsPermission = undefined
+      if (props.taskId || props.activityId) {
+        fieldsPermission = await ProcessInstanceApi.getFormFieldsPermission({
+          processInstanceId: props.id,
+          taskId: props.taskId,
+          activityId: props.activityId
+        })
+      }
       setConfAndFields2(
         detailForm,
         processDefinition.formConf,
@@ -161,6 +192,11 @@ const getProcessInstance = async () => {
         fApi.value?.btn.show(false)
         fApi.value?.resetBtn.show(false)
         fApi.value?.disabled(true)
+        if (fieldsPermission) {
+          Object.keys(fieldsPermission).forEach((item) => {
+            setFieldPermission(item, fieldsPermission[item])
+          })
+        }
       })
     } else {
       // 注意:data.processDefinition.formCustomViewPath 是组件的全路径,例如说:/crm/contract/detail/index.vue
@@ -174,15 +210,30 @@ const getProcessInstance = async () => {
   }
 }
 
+/**
+ * 设置表单权限
+ */
+const setFieldPermission = (field: string, permission: string) => {
+  if (permission === FieldPermissionType.READ) {
+    fApi.value?.disabled(true, field)
+  }
+  if (permission === FieldPermissionType.WRITE) {
+    fApi.value?.disabled(false, field)
+  }
+  if (permission === FieldPermissionType.NONE) {
+    fApi.value?.hidden(true, field)
+  }
+}
+
 /** 加载任务列表 */
 const getTaskList = async () => {
   try {
     // 获得未取消的任务
     tasksLoad.value = true
-    const data = await TaskApi.getTaskListByProcessInstanceId(id)
+    const data = await TaskApi.getTaskListByProcessInstanceId(props.id)
     tasks.value = []
     // 1.1 移除已取消的审批
-    data.forEach((task) => {
+    data.forEach((task: any) => {
       if (task.status !== 4) {
         tasks.value.push(task)
       }
@@ -209,6 +260,19 @@ const getTaskList = async () => {
   }
 }
 
+/**
+ * 操作成功后刷新
+ */
+const refresh = () => {
+  // 重新获取详情
+  getDetail()
+  // 刷新审批详情 Timeline
+  timelineRef.value?.refresh()
+}
+
+/** 当前的Tab */
+const activeTab = ref('form')
+
 /** 初始化 */
 const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
 onMounted(async () => {
@@ -219,6 +283,33 @@ onMounted(async () => {
 </script>
 
 <style lang="scss" scoped>
+$wrap-padding-height: 30px;
+$wrap-margin-height: 15px;
+$button-height: 51px;
+$process-header-height: 194px;
+
+.processInstance-wrap-main {
+  height: calc(
+    100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 45px
+  );
+  max-height: calc(
+    100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 45px
+  );
+  overflow: auto;
+
+  .form-scroll-area {
+    height: calc(
+      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 45px -
+        $process-header-height - 40px
+    );
+    max-height: calc(
+      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 45px -
+        $process-header-height - 40px
+    );
+    overflow: auto;
+  }
+}
+
 .form-box {
   :deep(.el-card) {
     border: none;

+ 4 - 4
src/views/bpm/processInstance/index.vue

@@ -19,10 +19,10 @@
           class="!w-240px"
         />
       </el-form-item>
-      <el-form-item label="所属流程" prop="processDefinitionId">
+      <el-form-item label="所属流程" prop="processDefinitionKey">
         <el-input
-          v-model="queryParams.processDefinitionId"
-          placeholder="请输入流程定义的编号"
+          v-model="queryParams.processDefinitionKey"
+          placeholder="请输入流程定义的标识"
           clearable
           @keyup.enter="handleQuery"
           class="!w-240px"
@@ -183,7 +183,7 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   name: '',
-  processDefinitionId: undefined,
+  processDefinitionKey: undefined,
   category: undefined,
   status: undefined,
   createTime: []

+ 4 - 0
src/views/bpm/processInstance/manager/index.vue

@@ -79,6 +79,10 @@
           class="!w-240px"
         />
       </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
     </el-form>
   </ContentWrap>
 

+ 9 - 24
src/views/bpm/simpleWorkflow/index.vue

@@ -1,28 +1,13 @@
 <template>
-  <div>
-    <section class="dingflow-design">
-      <div class="box-scale">
-        <nodeWrap v-model:nodeConfig="nodeConfig" />
-        <div class="end-node">
-          <div class="end-node-circle"></div>
-          <div class="end-node-text">流程结束</div>
-        </div>
-      </div>
-    </section>
-  </div>
+  <SimpleProcessDesigner :model-id="modelId" />
 </template>
-<script lang="ts" setup>
-import nodeWrap from '@/components/SimpleProcessDesigner/src/nodeWrap.vue'
-defineOptions({ name: 'SimpleWorkflowDesignEditor' })
-let nodeConfig = ref({
-  nodeName: '发起人',
-  type: 0,
-  id: 'root',
-  formPerms: {},
-  nodeUserList: [],
-  childNode: {}
+<script setup lang="ts">
+import { SimpleProcessDesigner } from '@/components/SimpleProcessDesignerV2/src/'
+
+defineOptions({
+  name: 'SimpleWorkflowDesignEditor'
 })
+const { query } = useRoute() // 路由的查询
+const modelId = query.modelId as string
 </script>
-<style>
-@import url('@/components/SimpleProcessDesigner/theme/workflow.css');
-</style>
+<style lang="scss" scoped></style>

+ 8 - 3
src/views/bpm/task/copy/index.vue

@@ -111,11 +111,16 @@ const getList = async () => {
 
 /** 处理审批按钮 */
 const handleAudit = (row: any) => {
+  const query = {
+    id: row.processInstanceId,
+    activityId: undefined
+  }
+  if (row.activityId) {
+    query.activityId = row.activityId
+  }
   push({
     name: 'BpmProcessInstanceDetail',
-    query: {
-      id: row.processInstanceId
-    }
+    query: query
   })
 }
 

+ 2 - 1
src/views/bpm/task/done/index.vue

@@ -158,7 +158,8 @@ const handleAudit = (row: any) => {
   push({
     name: 'BpmProcessInstanceDetail',
     query: {
-      id: row.processInstance.id
+      id: row.processInstance.id,
+      taskId: row.id
     }
   })
 }

+ 2 - 1
src/views/bpm/task/todo/index.vue

@@ -140,7 +140,8 @@ const handleAudit = (row: any) => {
   push({
     name: 'BpmProcessInstanceDetail',
     query: {
-      id: row.processInstance.id
+      id: row.processInstance.id,
+      taskId: row.id
     }
   })
 }

+ 47 - 11
src/views/infra/build/index.vue

@@ -1,16 +1,17 @@
 <template>
-  <ContentWrap>
-    <el-row>
-      <el-col>
-        <div class="float-right mb-2">
-          <el-button size="small" type="primary" @click="showJson">生成 JSON</el-button>
-          <el-button size="small" type="success" @click="showOption">生成 Options</el-button>
-          <el-button size="small" type="danger" @click="showTemplate">生成组件</el-button>
-        </div>
-      </el-col>
-    </el-row>
+  <ContentWrap :body-style="{ padding: '0px' }" class="!mb-0">
     <!-- 表单设计器 -->
-    <FcDesigner ref="designer" height="780px" />
+    <div
+      class="h-[calc(100vh-var(--top-tool-height)-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-2px)]"
+    >
+      <fc-designer class="my-designer" ref="designer" :config="designerConfig">
+        <template #handle>
+          <el-button size="small" type="primary" plain @click="showJson">生成JSON</el-button>
+          <el-button size="small" type="success" plain @click="showOption">生成Options</el-button>
+          <el-button size="small" type="danger" plain @click="showTemplate">生成组件</el-button>
+        </template>
+      </fc-designer>
+    </div>
   </ContentWrap>
 
   <!-- 弹窗:表单预览 -->
@@ -43,6 +44,31 @@ defineOptions({ name: 'InfraBuild' })
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息
 
+// 表单设计器配置
+const designerConfig = ref({
+  switchType: [], // 是否可以切换组件类型,或者可以相互切换的字段
+  autoActive: true, // 是否自动选中拖入的组件
+  useTemplate: false, // 是否生成vue2语法的模板组件
+  formOptions: {}, // 定义表单配置默认值
+  fieldReadonly: false, // 配置field是否可以编辑
+  hiddenDragMenu: false, // 隐藏拖拽操作按钮
+  hiddenDragBtn: false, // 隐藏拖拽按钮
+  hiddenMenu: [], // 隐藏部分菜单
+  hiddenItem: [], // 隐藏部分组件
+  hiddenItemConfig: {}, // 隐藏组件的部分配置项
+  disabledItemConfig: {}, // 禁用组件的部分配置项
+  showSaveBtn: false, // 是否显示保存按钮
+  showConfig: true, // 是否显示右侧的配置界面
+  showBaseForm: true, // 是否显示组件的基础配置表单
+  showControl: true, // 是否显示组件联动
+  showPropsForm: true, // 是否显示组件的属性配置表单
+  showEventForm: true, // 是否显示组件的事件配置表单
+  showValidateForm: true, // 是否显示组件的验证配置表单
+  showFormConfig: true, // 是否显示表单配置
+  showInputData: true, // 是否显示录入按钮
+  showDevice: true, // 是否显示多端适配选项
+  appendConfigData: [] // 定义渲染规则所需的formData
+})
 const designer = ref() // 表单设计器
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
@@ -140,3 +166,13 @@ onMounted(async () => {
   hljs.registerLanguage('json', json)
 })
 </script>
+
+<style>
+.my-designer {
+  ._fc-l,
+  ._fc-m,
+  ._fc-r {
+    border-top: none;
+  }
+}
+</style>

+ 4 - 2
src/views/infra/webSocket/index.vue

@@ -71,7 +71,7 @@
 <script lang="ts" setup>
 import { formatDate } from '@/utils/formatTime'
 import { useWebSocket } from '@vueuse/core'
-import { getAccessToken } from '@/utils/auth'
+import { getRefreshToken } from '@/utils/auth'
 import * as UserApi from '@/api/system/user'
 
 defineOptions({ name: 'InfraWebSocket' })
@@ -79,7 +79,9 @@ defineOptions({ name: 'InfraWebSocket' })
 const message = useMessage() // 消息弹窗
 
 const server = ref(
-  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') + '?token=' + getAccessToken()
+  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') +
+    '?token=' +
+    getRefreshToken() // 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:WebSocket 无法方便的刷新访问令牌
 ) // WebSocket 服务地址
 const getIsOpen = computed(() => status.value === 'OPEN') // WebSocket 连接是否打开
 const getTagColor = computed(() => (getIsOpen.value ? 'success' : 'red')) // WebSocket 连接的展示颜色

+ 156 - 0
src/views/iot/device/DeviceForm.vue

@@ -0,0 +1,156 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="产品" prop="productId">
+        <el-select
+          v-model="formData.productId"
+          placeholder="请选择产品"
+          :disabled="formType === 'update'"
+          clearable
+        >
+          <el-option
+            v-for="product in products"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="DeviceName" prop="deviceName">
+        <el-input
+          v-model="formData.deviceName"
+          placeholder="请输入 DeviceName"
+          :disabled="formType === 'update'"
+        />
+      </el-form-item>
+      <el-form-item label="备注名称" prop="nickname">
+        <el-input v-model="formData.nickname" placeholder="请输入备注名称" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DeviceApi, DeviceVO } from '@/api/iot/device'
+import { ProductApi } from '@/api/iot/product'
+
+/** IoT 设备 表单 */
+defineOptions({ name: 'IoTDeviceForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  productId: undefined,
+  deviceName: undefined,
+  nickname: undefined
+})
+const formRules = reactive({
+  productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
+  deviceName: [
+    {
+      pattern: /^[a-zA-Z0-9_.\-:@]{4,32}$/,
+      message:
+        '支持英文字母、数字、下划线(_)、中划线(-)、点号(.)、半角冒号(:)和特殊字符@,长度限制为 4~32 个字符',
+      trigger: 'blur'
+    }
+  ],
+  nickname: [
+    {
+      validator: (rule, value, callback) => {
+        if (value === undefined || value === null) {
+          callback()
+          return
+        }
+        const length = value.replace(/[\u4e00-\u9fa5\u3040-\u30ff]/g, 'aa').length
+        if (length < 4 || length > 64) {
+          callback(new Error('备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符'))
+        } else if (!/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/.test(value)) {
+          callback(new Error('备注名称只能包含中文、英文字母、日文、数字和下划线(_)'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await DeviceApi.getDevice(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as DeviceVO
+    if (formType.value === 'create') {
+      await DeviceApi.createDevice(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DeviceApi.updateDevice(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    productId: undefined,
+    deviceName: undefined,
+    nickname: undefined
+  }
+  formRef.value?.resetFields()
+}
+
+/** 查询字典下拉列表 */
+const products = ref()
+const getProducts = async () => {
+  products.value = await ProductApi.getSimpleProductList()
+}
+
+onMounted(() => {
+  getProducts()
+})
+</script>

+ 76 - 0
src/views/iot/device/detail/DeviceDetailsHeader.vue

@@ -0,0 +1,76 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ device.deviceName }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button
+          @click="openForm('update', device.id)"
+          v-hasPermi="['iot:device:update']"
+          v-if="product.status === 0"
+        >
+          编辑
+        </el-button>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="产品">
+        <el-link @click="goToProductDetail(product.id)">{{ product.name }}</el-link>
+      </el-descriptions-item>
+      <el-descriptions-item label="ProductKey">
+        {{ product.productKey }}
+        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <DeviceForm ref="formRef" @success="emit('refresh')" />
+</template>
+<script setup lang="ts">
+import { ref } from 'vue'
+import DeviceForm from '@/views/iot/device/DeviceForm.vue'
+import { ProductVO } from '@/api/iot/product'
+import { DeviceVO } from '@/api/iot/device'
+import { useRouter } from 'vue-router'
+
+const message = useMessage()
+const router = useRouter()
+
+// 操作修改
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
+const emit = defineEmits(['refresh'])
+
+/**
+ * 将文本复制到剪贴板
+ *
+ * @param text 需要复制的文本
+ */
+const copyToClipboard = (text: string) => {
+  // TODO @haohao:可以考虑用 await 异步转同步哈
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/**
+ * 跳转到产品详情页面
+ *
+ * @param productId 产品 ID
+ */
+const goToProductDetail = (productId: number) => {
+  router.push({ name: 'IoTProductDetail', params: { id: productId } })
+}
+</script>

+ 123 - 0
src/views/iot/device/detail/DeviceDetailsInfo.vue

@@ -0,0 +1,123 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-descriptions :column="3" title="设备信息">
+        <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
+        <el-descriptions-item label="ProductKey">
+          {{ product.productKey }}
+          <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+        </el-descriptions-item>
+        <el-descriptions-item label="设备类型">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="DeviceName">
+          {{ device.deviceName }}
+          <el-button @click="copyToClipboard(device.deviceName)">复制</el-button>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatDate(device.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="激活时间">
+          {{ formatDate(device.activeTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="最后上线时间">
+          {{ formatDate(device.lastOnlineTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="当前状态">
+          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.status" />
+        </el-descriptions-item>
+        <el-descriptions-item label="最后离线时间" :span="3">
+          {{ formatDate(device.lastOfflineTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="MQTT 连接参数">
+          <el-button type="primary" @click="openMqttParams">查看</el-button>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-collapse>
+
+    <!-- MQTT 连接参数弹框 -->
+    <Dialog
+      title="MQTT 连接参数"
+      v-model="mqttDialogVisible"
+      width="50%"
+      :before-close="handleCloseMqttDialog"
+    >
+      <el-form :model="mqttParams" label-width="120px">
+        <el-form-item label="clientId">
+          <el-input v-model="mqttParams.mqttClientId" readonly>
+            <template #append>
+              <el-button @click="copyToClipboard(mqttParams.mqttClientId)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="username">
+          <el-input v-model="mqttParams.mqttUsername" readonly>
+            <template #append>
+              <el-button @click="copyToClipboard(mqttParams.mqttUsername)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="passwd">
+          <el-input v-model="mqttParams.mqttPassword" readonly type="password">
+            <template #append>
+              <el-button @click="copyToClipboard(mqttParams.mqttPassword)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="mqttDialogVisible = false">关闭</el-button>
+      </template>
+    </Dialog>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { ref } from 'vue'
+import { DICT_TYPE } from '@/utils/dict'
+import { ProductVO } from '@/api/iot/product'
+import { formatDate } from '@/utils/formatTime'
+import { DeviceVO } from '@/api/iot/device'
+
+const message = useMessage() // 消息提示
+
+const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
+
+const emit = defineEmits(['refresh']) // 定义 Emits
+
+const activeNames = ref(['basicInfo']) // 展示的折叠面板
+const mqttDialogVisible = ref(false) // 定义 MQTT 弹框的可见性
+const mqttParams = ref({
+  mqttClientId: '',
+  mqttUsername: '',
+  mqttPassword: ''
+}) // 定义 MQTT 参数对象
+
+/** 复制到剪贴板方法 */
+const copyToClipboard = (text: string) => {
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/** 打开 MQTT 参数弹框的方法 */
+const openMqttParams = () => {
+  mqttParams.value = {
+    mqttClientId: device.mqttClientId || 'N/A',
+    mqttUsername: device.mqttUsername || 'N/A',
+    mqttPassword: device.mqttPassword || 'N/A'
+  }
+  mqttDialogVisible.value = true
+}
+
+/** 关闭 MQTT 弹框的方法 */
+const handleCloseMqttDialog = () => {
+  mqttDialogVisible.value = false
+}
+</script>

+ 66 - 0
src/views/iot/device/detail/index.vue

@@ -0,0 +1,66 @@
+<template>
+  <DeviceDetailsHeader
+    :loading="loading"
+    :product="product"
+    :device="device"
+    @refresh="getDeviceData(id)"
+  />
+  <el-col>
+    <el-tabs>
+      <el-tab-pane label="设备信息">
+        <DeviceDetailsInfo :product="product" :device="device" />
+      </el-tab-pane>
+      <el-tab-pane label="Topic 列表" />
+      <el-tab-pane label="物模型数据" />
+      <el-tab-pane label="子设备管理" />
+    </el-tabs>
+  </el-col>
+</template>
+<script lang="ts" setup>
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { DeviceApi, DeviceVO } from '@/api/iot/device'
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import DeviceDetailsHeader from '@/views/iot/device/detail/DeviceDetailsHeader.vue'
+import DeviceDetailsInfo from '@/views/iot/device/detail/DeviceDetailsInfo.vue'
+
+defineOptions({ name: 'IoTDeviceDetail' })
+
+const route = useRoute()
+const message = useMessage()
+const id = Number(route.params.id) // 编号
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 产品详情
+const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
+
+/** 获取设备详情 */
+const getDeviceData = async (id: number) => {
+  loading.value = true
+  try {
+    device.value = await DeviceApi.getDevice(id)
+    console.log(product.value)
+    await getProductData(device.value.productId)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 获取产品详情 */
+const getProductData = async (id: number) => {
+  product.value = await ProductApi.getProduct(id)
+  console.log(product.value)
+}
+
+/** 获取物模型 */
+
+/** 初始化 */
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter() // 路由
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getDeviceData(id)
+})
+</script>

+ 267 - 0
src/views/iot/device/index.vue

@@ -0,0 +1,267 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="产品" prop="productId">
+        <el-select
+          v-model="queryParams.productId"
+          placeholder="请选择产品"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="product in products"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="DeviceName" prop="deviceName">
+        <el-input
+          v-model="queryParams.deviceName"
+          placeholder="请输入 DeviceName"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="备注名称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入备注名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="设备类型" prop="deviceType">
+        <el-select
+          v-model="queryParams.deviceType"
+          placeholder="请选择设备类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="设备状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择设备状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon icon="ep:search" class="mr-5px" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" />
+          重置
+        </el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:device:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="DeviceName" align="center" prop="deviceName">
+        <template #default="scope">
+          <el-link @click="openDetail(scope.row.id)">{{ scope.row.deviceName }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="备注名称" align="center" prop="nickname" />
+      <el-table-column label="设备所属产品" align="center" prop="productId">
+        <template #default="scope">
+          {{ productMap[scope.row.productId] }}
+        </template>
+      </el-table-column>
+      <el-table-column label="设备类型" align="center" prop="deviceType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="设备状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="最后上线时间"
+        align="center"
+        prop="lastOnlineTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+            v-hasPermi="['iot:product:query']"
+          >
+            查看
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:device:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:device:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <DeviceForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { DeviceApi, DeviceVO } from '@/api/iot/device'
+import DeviceForm from './DeviceForm.vue'
+import { ProductApi } from '@/api/iot/product'
+
+/** IoT 设备 列表 */
+defineOptions({ name: 'IoTDevice' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<DeviceVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceName: undefined,
+  productId: undefined,
+  deviceType: undefined,
+  nickname: undefined,
+  status: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 产品标号和名称的映射 */
+const productMap = reactive({})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DeviceApi.getDevicePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+    // 获取产品ID列表
+    const productIds = [...new Set(data.list.map((device) => device.productId))]
+    // 获取产品名称
+    // TODO @haohao:最好后端拼接哈
+    const products = await Promise.all(productIds.map((id) => ProductApi.getProduct(id)))
+    products.forEach((product) => {
+      productMap[product.id] = product.name
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 打开详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'IoTDeviceDetail', params: { id } })
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DeviceApi.deleteDevice(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 查询字典下拉列表 */
+const products = ref()
+const getProducts = async () => {
+  products.value = await ProductApi.getSimpleProductList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+  getProducts()
+})
+</script>

+ 204 - 0
src/views/iot/product/ProductForm.vue

@@ -0,0 +1,204 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="产品名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入产品名称" />
+      </el-form-item>
+
+      <el-form-item label="设备类型" prop="deviceType">
+        <el-select
+          v-model="formData.deviceType"
+          placeholder="请选择设备类型"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item
+        v-if="formData.deviceType === 0 || formData.deviceType === 2"
+        label="联网方式"
+        prop="netType"
+      >
+        <el-select
+          v-model="formData.netType"
+          placeholder="请选择联网方式"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_NET_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item v-if="formData.deviceType === 1" label="接入网关协议" prop="protocolType">
+        <el-select
+          v-model="formData.protocolType"
+          placeholder="请选择接入网关协议"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="数据格式" prop="dataFormat">
+        <el-select
+          v-model="formData.dataFormat"
+          placeholder="请选择接数据格式"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_FORMAT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="数据校验级别" prop="validateType">
+        <el-radio-group v-model="formData.validateType" :disabled="formType === 'update'">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-form-item label="产品描述" prop="description">
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入产品描述" />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+defineOptions({ name: 'IoTProductForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formData = ref({
+  name: undefined,
+  id: undefined,
+  productKey: undefined,
+  protocolId: undefined,
+  categoryId: undefined,
+  description: undefined,
+  validateType: undefined,
+  status: undefined,
+  deviceType: undefined,
+  netType: undefined,
+  protocolType: undefined,
+  dataFormat: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
+  deviceType: [{ required: true, message: '设备类型不能为空', trigger: 'change' }],
+  netType: [
+    {
+      // TODO @haohao:0、1、/2 最好前端也枚举下;另外,这里的 required 可以直接设置为 true。然后表单那些 v-if。只要不存在,它自动就不校验了哈
+      required: formData.deviceType === 0 || formData.deviceType === 2,
+      message: '联网方式不能为空',
+      trigger: 'change'
+    }
+  ],
+  protocolType: [
+    { required: formData.deviceType === 1, message: '接入网关协议不能为空', trigger: 'change' }
+  ],
+  dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
+  validateType: [{ required: true, message: '数据校验级别不能为空', trigger: 'change' }]
+})
+const formRef = ref()
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ProductApi.getProduct(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open, close: () => (dialogVisible.value = false) })
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ProductVO
+    if (formType.value === 'create') {
+      await ProductApi.createProduct(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductApi.updateProduct(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false // 确保关闭弹框
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: undefined,
+    id: undefined,
+    productKey: undefined,
+    protocolId: undefined,
+    categoryId: undefined,
+    description: undefined,
+    validateType: undefined,
+    status: undefined,
+    deviceType: undefined,
+    netType: undefined,
+    protocolType: undefined,
+    dataFormat: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 103 - 0
src/views/iot/product/detail/ProductDetailsHeader.vue

@@ -0,0 +1,103 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ product.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button
+          @click="openForm('update', product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 0"
+        >
+          编辑
+        </el-button>
+        <el-button
+          type="primary"
+          @click="confirmPublish(product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 0"
+        >
+          发布
+        </el-button>
+        <el-button
+          type="danger"
+          @click="confirmUnpublish(product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 1"
+        >
+          撤销发布
+        </el-button>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="ProductKey">
+        {{ product.productKey }}
+        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="设备数">
+        {{ product.deviceCount }}
+        <el-button @click="goToManagement(product.id)">前往管理</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm ref="formRef" @success="emit('refresh')" />
+</template>
+<script setup lang="ts">
+import ProductForm from '@/views/iot/product/ProductForm.vue'
+import { ProductApi, ProductVO } from '@/api/iot/product'
+
+const message = useMessage()
+
+const { product } = defineProps<{ product: ProductVO }>() // 定义 Props
+
+/** 处理复制 */
+const copyToClipboard = (text: string) => {
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/** 路由跳转到设备管理 */
+const { push } = useRouter()
+const goToManagement = (productId: string) => {
+  push({ name: 'IoTDevice', query: { productId } })
+}
+
+/** 操作修改 */
+const emit = defineEmits(['refresh']) // 定义 Emits
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+const confirmPublish = async (id: number) => {
+  try {
+    await ProductApi.updateProductStatus(id, 1)
+    message.success('发布成功')
+    formRef.value.close() // 关闭弹框
+    emit('refresh')
+  } catch (error) {
+    message.error('发布失败')
+  }
+}
+const confirmUnpublish = async (id: number) => {
+  try {
+    await ProductApi.updateProductStatus(id, 0)
+    message.success('撤销发布成功')
+    formRef.value.close() // 关闭弹框
+    emit('refresh')
+  } catch (error) {
+    message.error('撤销发布失败')
+  }
+}
+</script>

+ 44 - 0
src/views/iot/product/detail/ProductDetailsInfo.vue

@@ -0,0 +1,44 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-descriptions :column="3" title="产品信息">
+        <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
+        <el-descriptions-item label="设备类型">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatDate(product.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="数据格式">
+          <dict-tag :type="DICT_TYPE.IOT_DATA_FORMAT" :value="product.dataFormat" />
+        </el-descriptions-item>
+        <el-descriptions-item label="数据校验级别">
+          <dict-tag :type="DICT_TYPE.IOT_VALIDATE_TYPE" :value="product.validateType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="产品状态">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
+        </el-descriptions-item>
+        <el-descriptions-item
+          label="联网方式"
+          v-if="product.deviceType === 0 || product.deviceType === 2"
+        >
+          <dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="接入网关协议" v-if="product.deviceType === 1">
+          <dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
+      </el-descriptions>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { ProductVO } from '@/api/iot/product'
+import { formatDate } from '@/utils/formatTime'
+
+const { product } = defineProps<{ product: ProductVO }>()
+
+// 展示的折叠面板
+const activeNames = ref(['basicInfo'])
+</script>

+ 243 - 0
src/views/iot/product/detail/ProductTopic.vue

@@ -0,0 +1,243 @@
+<template>
+  <ContentWrap>
+    <el-tabs>
+      <el-tab-pane label="基础通信 Topic">
+        <Table
+          :columns="columns1"
+          :data="data1"
+          :span-method="createSpanMethod(data1)"
+          align="left"
+          headerAlign="left"
+          border="true"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="物模型通信 Topic">
+        <Table
+          :columns="columns2"
+          :data="data2"
+          :span-method="createSpanMethod(data2)"
+          align="left"
+          headerAlign="left"
+          border="true"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+
+const props = defineProps<{ product: ProductVO }>()
+
+// 定义列
+const columns1 = reactive([
+  { label: '功能', field: 'function', width: 150 },
+  { label: 'Topic 类', field: 'topicClass', width: 800 },
+  { label: '操作权限', field: 'operationPermission', width: 100 },
+  { label: '描述', field: 'description' }
+])
+
+const columns2 = reactive([
+  { label: '功能', field: 'function', width: 150 },
+  { label: 'Topic 类', field: 'topicClass', width: 800 },
+  { label: '操作权限', field: 'operationPermission', width: 100 },
+  { label: '描述', field: 'description' }
+])
+
+// TODO @haohao:这个,有没可能写到一个枚举里,方便后续维护? /Users/yunai/Java/yudao-ui-admin-vue3/src/views/ai/utils/constants.ts
+const data1 = computed(() => {
+  if (!props.product || !props.product.productKey) return []
+  return [
+    {
+      function: 'OTA 升级',
+      topicClass: `/ota/device/inform/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '发布',
+      description: '设备上报固件升级信息'
+    },
+    {
+      function: 'OTA 升级',
+      topicClass: `/ota/device/upgrade/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '订阅',
+      description: '固件升级信息下行'
+    },
+    {
+      function: 'OTA 升级',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
+      operationPermission: '发布',
+      description: '设备上报固件升级进度'
+    },
+    {
+      function: 'OTA 升级',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
+      operationPermission: '发布',
+      description: '设备主动拉取固件升级信息'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update`,
+      operationPermission: '发布',
+      description: '设备上报标签数据'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update_reply`,
+      operationPermission: '订阅',
+      description: '云端响应标签上报'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete`,
+      operationPermission: '订阅',
+      description: '设备删除标签信息'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete_reply`,
+      operationPermission: '订阅',
+      description: '云端响应标签删除'
+    },
+    {
+      function: '时钟同步',
+      topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/request`,
+      operationPermission: '发布',
+      description: 'NTP 时钟同步请求'
+    },
+    {
+      function: '时钟同步',
+      topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/response`,
+      operationPermission: '订阅',
+      description: 'NTP 时钟同步响应'
+    },
+    {
+      function: '设备影子',
+      topicClass: `/shadow/update/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '发布',
+      description: '设备影子发布'
+    },
+    {
+      function: '设备影子',
+      topicClass: `/shadow/get/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '订阅',
+      description: '设备接收影子变更'
+    },
+    {
+      function: '配置更新',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/push`,
+      operationPermission: '订阅',
+      description: '云端主动下推配置信息'
+    },
+    {
+      function: '配置更新',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get`,
+      operationPermission: '发布',
+      description: '设备端查询配置信息'
+    },
+    {
+      function: '配置更新',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get_reply`,
+      operationPermission: '订阅',
+      description: '云端响应配置信息'
+    },
+    {
+      function: '广播',
+      topicClass: `/broadcast/${props.product.productKey}/\${identifier}`,
+      operationPermission: '订阅',
+      description: '广播 Topic,identifier 为用户自定义字符串'
+    }
+  ]
+})
+
+const data2 = computed(() => {
+  if (!props.product || !props.product.productKey) return []
+  return [
+    {
+      function: '属性上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post`,
+      operationPermission: '发布',
+      description: '设备属性上报'
+    },
+    {
+      function: '属性上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post_reply`,
+      operationPermission: '订阅',
+      description: '云端响应属性上报'
+    },
+    {
+      function: '属性设置',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/property/set`,
+      operationPermission: '订阅',
+      description: '设备属性设置'
+    },
+    {
+      function: '事件上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post`,
+      operationPermission: '发布',
+      description: '设备事件上报'
+    },
+    {
+      function: '事件上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post_reply`,
+      operationPermission: '订阅',
+      description: '云端响应事件上报'
+    },
+    {
+      function: '服务调用',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}`,
+      operationPermission: '订阅',
+      description: '设备服务调用'
+    },
+    {
+      function: '服务调用',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}_reply`,
+      operationPermission: '发布',
+      description: '设备端响应服务调用'
+    }
+  ]
+})
+
+// 通用的单元格合并方法生成器
+const createSpanMethod = (data: any[]) => {
+  // 预处理,计算每个功能的合并行数
+  const rowspanMap: Record<number, number> = {}
+  let currentFunction = ''
+  let startIndex = 0
+  let count = 0
+
+  data.forEach((item, index) => {
+    if (item.function !== currentFunction) {
+      if (count > 0) {
+        rowspanMap[startIndex] = count
+      }
+      currentFunction = item.function
+      startIndex = index
+      count = 1
+    } else {
+      count++
+    }
+  })
+
+  // 处理最后一组
+  if (count > 0) {
+    rowspanMap[startIndex] = count
+  }
+
+  // 返回 span 方法
+  return ({ row, column, rowIndex, columnIndex }: SpanMethodProps) => {
+    if (columnIndex === 0) {
+      // 仅对“功能”列进行合并
+      const rowspan = rowspanMap[rowIndex] || 0
+      if (rowspan > 0) {
+        return {
+          rowspan,
+          colspan: 1
+        }
+      } else {
+        return {
+          rowspan: 0,
+          colspan: 0
+        }
+      }
+    }
+  }
+}
+</script>

+ 154 - 0
src/views/iot/product/detail/ThinkModelFunction.vue

@@ -0,0 +1,154 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="功能类型" prop="name">
+        <el-select
+          v-model="queryParams.type"
+          placeholder="请选择功能类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:think-model-function:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 添加功能
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <ContentWrap>
+    <el-tabs>
+      <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+        <el-table-column label="功能类型" align="center" prop="type">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE" :value="scope.row.type" />
+          </template>
+        </el-table-column>
+        <el-table-column label="功能名称" align="center" prop="name" />
+        <el-table-column label="标识符" align="center" prop="identifier" />
+        <el-table-column label="操作" align="center">
+          <template #default="scope">
+            <el-button
+              link
+              type="primary"
+              @click="openForm('update', scope.row.id)"
+              v-hasPermi="[`iot:think-model-function:update`]"
+            >
+              编辑
+            </el-button>
+            <el-button
+              link
+              type="danger"
+              @click="handleDelete(scope.row.id)"
+              v-hasPermi="['iot:think-model-function:delete']"
+            >
+              删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </el-tabs>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <ThinkModelFunctionForm ref="formRef" :product="product" @success="getList" />
+</template>
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import ThinkModelFunctionForm from '@/views/iot/product/detail/ThinkModelFunctionForm.vue'
+
+const props = defineProps<{ product: ProductVO }>()
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ThinkModelFunctionVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  productId: -1
+})
+
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    queryParams.productId = props.product.id
+    const data = await ThinkModelFunctionApi.getThinkModelFunctionPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  queryParams.type = undefined
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ThinkModelFunctionApi.deleteThinkModelFunction(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 229 - 0
src/views/iot/product/detail/ThinkModelFunctionForm.vue

@@ -0,0 +1,229 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="功能类型" prop="type">
+        <el-radio-group v-model="formData.type">
+          <el-radio-button value="1"> 属性 </el-radio-button>
+          <el-radio-button value="2"> 服务 </el-radio-button>
+          <el-radio-button value="3"> 事件 </el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="功能名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入功能名称" />
+      </el-form-item>
+      <el-form-item label="标识符" prop="identifier">
+        <el-input
+          v-model="formData.identifier"
+          placeholder="请输入标识符"
+          :disabled="formType === 'update'"
+        />
+      </el-form-item>
+      <el-form-item label="数据类型" prop="type">
+        <el-select
+          v-model="formData.property.dataType.type"
+          placeholder="请选择数据类型"
+          :disabled="formType === 'update'"
+        >
+          <el-option key="int" label="int32 (整数型)" value="int" />
+          <el-option key="float" label="float (单精度浮点型)" value="float" />
+          <el-option key="double" label="double (双精度浮点型)" value="double" />
+          <!--          <el-option key="text" label="text (文本型)" value="text" />-->
+          <!--          <el-option key="date" label="date (日期型)" value="date" />-->
+          <!--          <el-option key="bool" label="bool (布尔型)" value="bool" />-->
+          <!--          <el-option key="enum" label="enum (枚举型)" value="enum" />-->
+          <!--          <el-option key="struct" label="struct (结构体)" value="struct" />-->
+          <!--          <el-option key="array" label="array (数组)" value="array" />-->
+        </el-select>
+      </el-form-item>
+      <el-form-item label="取值范围" prop="max">
+        <el-input v-model="formData.property.dataType.specs.min" placeholder="请输入最小值" />
+        <span class="mx-2">~</span>
+        <el-input v-model="formData.property.dataType.specs.max" placeholder="请输入最大值" />
+      </el-form-item>
+      <el-form-item label="步长" prop="step">
+        <el-input v-model="formData.property.dataType.specs.step" placeholder="请输入步长" />
+      </el-form-item>
+      <el-form-item label="单位" prop="unit">
+        <el-input v-model="formData.property.dataType.specs.unit" placeholder="请输入单位" />
+      </el-form-item>
+      <el-form-item label="读写类型" prop="accessMode">
+        <el-radio-group v-model="formData.property.accessMode">
+          <el-radio label="rw">读写</el-radio>
+          <el-radio label="r">只读</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="属性描述" prop="property.description">
+        <el-input
+          type="textarea"
+          v-model="formData.property.description"
+          placeholder="请输入属性描述"
+        />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
+
+const props = defineProps<{ product: ProductVO }>()
+
+defineOptions({ name: 'ThinkModelFunctionForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formData = ref({
+  id: undefined,
+  productId: undefined,
+  productKey: undefined,
+  identifier: undefined,
+  name: undefined,
+  description: undefined,
+  type: '1',
+  property: {
+    identifier: undefined,
+    name: undefined,
+    accessMode: 'rw',
+    required: true,
+    dataType: {
+      type: undefined,
+      specs: {
+        min: undefined,
+        max: undefined,
+        step: undefined,
+        unit: undefined
+      }
+    },
+    description: undefined // 添加这一行
+  }
+})
+const formRules = reactive({
+  name: [
+    { required: true, message: '功能名称不能为空', trigger: 'blur' },
+    {
+      pattern: /^[\u4e00-\u9fa5a-zA-Z0-9][\u4e00-\u9fa5a-zA-Z0-9\-_/\.]{0,29}$/,
+      message:
+        '支持中文、大小写字母、日文、数字、短划线、下划线、斜杠和小数点,必须以中文、英文或数字开头,不超过 30 个字符',
+      trigger: 'blur'
+    }
+  ],
+  type: [{ required: true, message: '功能类型不能为空', trigger: 'blur' }],
+  identifier: [
+    { required: true, message: '标识符不能为空', trigger: 'blur' },
+    {
+      pattern: /^[a-zA-Z0-9_]{1,50}$/,
+      message: '支持大小写字母、数字和下划线,不超过 50 个字符',
+      trigger: 'blur'
+    },
+    {
+      validator: (rule, value, callback) => {
+        const reservedKeywords = ['set', 'get', 'post', 'property', 'event', 'time', 'value']
+        if (reservedKeywords.includes(value)) {
+          callback(
+            new Error(
+              'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义'
+            )
+          )
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ],
+  property: {
+    dataType: {
+      type: [{ required: true, message: '数据类型不能为空', trigger: 'blur' }]
+    },
+    accessMode: [{ required: true, message: '读写类型不能为空', trigger: 'blur' }]
+  }
+})
+const formRef = ref()
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ThinkModelFunctionApi.getThinkModelFunction(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open, close: () => (dialogVisible.value = false) })
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ThinkModelFunctionVO
+    data.productId = props.product.id
+    data.productKey = props.product.productKey
+    if (formType.value === 'create') {
+      await ThinkModelFunctionApi.createThinkModelFunction(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ThinkModelFunctionApi.updateThinkModelFunction(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false // 确保关闭弹框
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    productId: undefined,
+    productKey: undefined,
+    identifier: undefined,
+    name: undefined,
+    description: undefined,
+    type: '1', // todo @HAOHAO:看看枚举下
+    property: {
+      identifier: undefined,
+      name: undefined,
+      accessMode: 'rw',
+      required: true,
+      dataType: {
+        type: undefined,
+        specs: {
+          min: undefined,
+          max: undefined,
+          step: undefined,
+          unit: undefined
+        }
+      },
+      description: undefined // 确保重置 description 字段
+    }
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 80 - 0
src/views/iot/product/detail/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <ProductDetailsHeader :loading="loading" :product="product" @refresh="() => getProductData(id)" />
+  <el-col>
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="产品信息" name="info">
+        <ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="Topic 类列表" name="topic">
+        <ProductTopic v-if="activeTab === 'topic'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="功能定义" name="function">
+        <ThinkModelFunction v-if="activeTab === 'function'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="消息解析" name="message" />
+      <el-tab-pane label="服务端订阅" name="subscription" />
+    </el-tabs>
+  </el-col>
+</template>
+<script lang="ts" setup>
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import { DeviceApi } from '@/api/iot/device'
+import ProductDetailsHeader from '@/views/iot/product/detail/ProductDetailsHeader.vue'
+import ProductDetailsInfo from '@/views/iot/product/detail/ProductDetailsInfo.vue'
+import ProductTopic from '@/views/iot/product/detail/ProductTopic.vue'
+import ThinkModelFunction from '@/views/iot/product/detail/ThinkModelFunction.vue'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useRouter } from 'vue-router'
+
+defineOptions({ name: 'IoTProductDetail' })
+
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter()
+
+const route = useRoute()
+const message = useMessage()
+const id = Number(route.params.id) // 编号
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 详情
+const activeTab = ref('info') // 默认激活的标签页
+
+/** 获取详情 */
+const getProductData = async (id: number) => {
+  loading.value = true
+  try {
+    product.value = await ProductApi.getProduct(id)
+    console.log('Product data:', product.value)
+  } finally {
+    loading.value = false
+  }
+}
+
+// 查询设备数量
+const getDeviceCount = async (productId: number) => {
+  try {
+    const count = await DeviceApi.getDeviceCount(productId)
+    console.log('Device count response:', count)
+    return count
+  } catch (error) {
+    console.error('Error fetching device count:', error)
+    return 0
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getProductData(id)
+  // 查询设备数量
+  if (product.value.id) {
+    product.value.deviceCount = await getDeviceCount(product.value.id)
+    console.log('Device count:', product.value.deviceCount)
+  } else {
+    console.error('Product ID is undefined')
+  }
+})
+</script>

+ 191 - 0
src/views/iot/product/index.vue

@@ -0,0 +1,191 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="产品名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入产品名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="ProductKey" prop="productKey">
+        <el-input
+          v-model="queryParams.productKey"
+          placeholder="请输入产品标识"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:product:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="产品名称" align="center" prop="name">
+        <template #default="scope">
+          <el-link @click="openDetail(scope.row.id)">{{ scope.row.name }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="ProductKey" align="center" prop="productKey" />
+      <el-table-column label="设备类型" align="center" prop="deviceType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="产品状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+            v-hasPermi="['iot:product:query']"
+          >
+            查看
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:product:delete']"
+            :disabled="scope.row.status === 1"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import ProductForm from './ProductForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+
+/** iot 产品 列表 */
+defineOptions({ name: 'IoTProduct' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ProductVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  createTime: [],
+  productKey: undefined,
+  protocolId: undefined,
+  categoryId: undefined,
+  description: undefined,
+  validateType: undefined,
+  status: undefined,
+  deviceType: undefined,
+  netType: undefined,
+  protocolType: undefined,
+  dataFormat: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductApi.getProductPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 打开详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'IoTProductDetail', params: { id } })
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ProductApi.deleteProduct(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 1 - 1
src/views/mall/promotion/coupon/components/CouponSelect.vue

@@ -129,7 +129,7 @@ const emit = defineEmits<{
   (e: 'change', v: CouponTemplateApi.CouponTemplateVO[]): void
 }>()
 const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('选择优惠') // 弹窗的标题
+const dialogTitle = ref('选择优惠') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数

+ 1 - 1
src/views/mall/promotion/coupon/template/CouponTemplateForm.vue

@@ -115,7 +115,7 @@
         <el-radio-group v-model="formData.takeType">
           <el-radio :key="1" :value="1">直接领取</el-radio>
           <el-radio :key="2" :value="2">指定发放</el-radio>
-          <el-radio :key="2" :value="3">新人</el-radio>
+          <el-radio :key="2" :value="3">新人</el-radio>
         </el-radio-group>
       </el-form-item>
       <el-form-item v-if="formData.takeType === 1" label="发放数量" prop="totalCount">

+ 1 - 1
src/views/mall/promotion/discountActivity/DiscountActivityForm.vue

@@ -190,7 +190,7 @@ const submitForm = async () => {
     const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
     products.forEach((item: DiscountActivityApi.DiscountProductVO) => {
       item.discountPercent = convertToInteger(item.discountPercent)
-      item.discountPrice = convertToInteger(yuanToFen(item.discountPrice))
+      item.discountPrice = convertToInteger(item.discountPrice)
     })
     const data = cloneDeep(formRef.value.formModel) as DiscountActivityApi.DiscountActivityVO
     data.products = products

+ 0 - 11
src/views/mall/promotion/discountActivity/discountActivity.data.ts

@@ -70,17 +70,6 @@ const crudSchemas = reactive<CrudSchema[]>([
       width: 120
     }
   },
-  {
-    label: '优惠类型',
-    field: 'discountType',
-    dictType: DICT_TYPE.PROMOTION_DISCOUNT_TYPE,
-    dictClass: 'number',
-    isSearch: true,
-    form: {
-      component: 'Radio',
-      value: 1
-    }
-  },
   {
     label: '活动商品',
     field: 'spuId',

+ 6 - 4
src/views/mall/promotion/kefu/components/KeFuConversationList.vue

@@ -22,13 +22,15 @@
         <div class="ml-10px w-100%">
           <div class="flex justify-between items-center w-100%">
             <span class="username">{{ item.userNickname }}</span>
-            <span class="color-[var(--left-menu-text-color)]" style="font-size: 13px;">
+            <span class="color-[var(--left-menu-text-color)]" style="font-size: 13px">
               {{ formatPast(item.lastMessageTime, 'YYYY-MM-DD') }}
             </span>
           </div>
           <!-- 最后聊天内容 -->
           <div
-            v-dompurify-html="getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)"
+            v-dompurify-html="
+              getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)
+            "
             class="last-message flex items-center color-[var(--left-menu-text-color)]"
           >
           </div>
@@ -205,7 +207,7 @@ watch(showRightMenu, (val) => {
 
   .active {
     border-left: 5px #3271ff solid;
-    background-color: var(--left-menu-bg-active-color);
+    background-color: var(--login-bg-color);
   }
 
   .pinned {
@@ -215,7 +217,7 @@ watch(showRightMenu, (val) => {
   .right-menu-ul {
     position: absolute;
     background-color: var(--app-content-bg-color);
-    padding: 10px;
+    padding: 5px;
     margin: 0;
     list-style-type: none; /* 移除默认的项目符号 */
     border-radius: 12px;

+ 15 - 3
src/views/mall/promotion/kefu/components/message/OrderItem.vue

@@ -1,8 +1,13 @@
 <template>
-  <div v-if="isObject(getMessageContent)" @click="openDetail(getMessageContent.id)" style="cursor: pointer;">
+  <div v-if="isObject(getMessageContent)">
     <div :key="getMessageContent.id" class="order-list-card-box mt-14px">
       <div class="order-card-header flex items-center justify-between p-x-5px">
-        <div class="order-no">订单号:{{ getMessageContent.no }}</div>
+        <div class="order-no">
+          订单号:
+          <span style="cursor: pointer" @click="openDetail(getMessageContent.id)">
+            {{ getMessageContent.no }}
+          </span>
+        </div>
         <div :class="formatOrderColor(getMessageContent)" class="order-state font-16">
           {{ formatOrderStatus(getMessageContent) }}
         </div>
@@ -113,8 +118,15 @@ function formatOrderStatus(order: any) {
     height: 28px;
 
     .order-no {
-      font-size: 10px;
+      font-size: 12px;
       font-weight: 500;
+
+      span {
+        &:hover {
+          text-decoration: underline;
+          color: var(--left-menu-bg-active-color);
+        }
+      }
     }
   }
 

+ 4 - 2
src/views/mall/promotion/kefu/index.vue

@@ -25,7 +25,7 @@
 import { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory } from './components'
 import { WebSocketMessageTypeConstants } from './components/tools/constants'
 import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
-import { getAccessToken } from '@/utils/auth'
+import { getRefreshToken } from '@/utils/auth'
 import { useWebSocket } from '@vueuse/core'
 
 defineOptions({ name: 'KeFu' })
@@ -34,7 +34,9 @@ const message = useMessage() // 消息弹窗
 
 // ======================= WebSocket start =======================
 const server = ref(
-  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') + '?token=' + getAccessToken()
+  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') +
+    '?token=' +
+    getRefreshToken() // 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:WebSocket 无法方便的刷新访问令牌
 ) // WebSocket 服务地址
 
 /** 发起 WebSocket 连接 */

+ 2 - 2
src/views/mall/promotion/rewardActivity/components/RewardRuleCouponSelect.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-button class="ml-10px" type="text" @click="selectCoupon">添加优惠</el-button>
+  <el-button class="ml-10px" type="text" @click="selectCoupon">添加优惠</el-button>
 
   <div
     v-for="(item, index) in list"
@@ -57,7 +57,7 @@ const emits = defineEmits<{
 const rewardRule = useVModel(props, 'modelValue', emits) // 赠送规则
 const list = ref<GiveCouponVO[]>([]) // 选择的优惠券列表
 
-/** 选择赠送的优惠类型拓展 */
+/** 选择赠送的优惠类型拓展 */
 interface GiveCouponVO extends CouponTemplateApi.CouponTemplateVO {
   giveCount?: number
 }

+ 2 - 2
src/views/member/user/index.vue

@@ -140,7 +140,7 @@
                 'member:user:update',
                 'member:user:update-level',
                 'member:user:update-point',
-                'member:user:update-balance'
+                'pay:wallet:update-balance'
               ]"
               @command="(command) => handleCommand(command, scope.row)"
             >
@@ -169,7 +169,7 @@
                     修改积分
                   </el-dropdown-item>
                   <el-dropdown-item
-                    v-if="checkPermi(['member:user:update-balance'])"
+                    v-if="checkPermi(['pay:wallet:update-balance'])"
                     command="handleUpdateBlance"
                   >
                     修改余额

+ 3 - 2
src/views/report/jmreport/index.vue

@@ -6,9 +6,10 @@
   </ContentWrap>
 </template>
 <script lang="ts" setup>
-import { getAccessToken } from '@/utils/auth'
+import { getRefreshToken } from '@/utils/auth'
 
 defineOptions({ name: 'JimuReport' })
 
-const src = ref(import.meta.env.VITE_BASE_URL + '/jmreport/list?token=' + getAccessToken())
+// 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:积木报表无法方便的刷新访问令牌
+const src = ref(import.meta.env.VITE_BASE_URL + '/jmreport/list?token=' + getRefreshToken())
 </script>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác