ソースを参照

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

YunaiV 7 ヶ月 前
コミット
efbc51659b

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

@@ -106,6 +106,11 @@ export const copyTask = async (data: any) => {
   return await request.put({ url: '/bpm/task/copy', data })
 }
 
+// 撤回
+export const withdrawTask = async (taskId: string) => {
+  return await request.put({ url: '/bpm/task/withdraw', params: { taskId } })
+}
+
 // 获取我的待办任务
 export const myTodoTask = async (processInstanceId: string) => {
   return await request.get({ url: '/bpm/task/my-todo?processInstanceId=' + processInstanceId })

+ 3 - 1
src/api/system/mail/log/index.ts

@@ -4,7 +4,9 @@ export interface MailLogVO {
   id: number
   userId: number
   userType: number
-  toMail: string
+  toMails: string[]
+  ccMails?: string[]
+  bccMails?: string[]
   accountId: number
   fromMail: string
   templateId: number

+ 7 - 2
src/api/system/mail/template/index.ts

@@ -14,7 +14,9 @@ export interface MailTemplateVO {
 }
 
 export interface MailSendReqVO {
-  mail: string
+  toMails: string[]
+  ccMails?: string[]
+  bccMails?: string[]
   templateCode: string
   templateParams: Map<String, Object>
 }
@@ -46,7 +48,10 @@ export const deleteMailTemplate = async (id: number) => {
 
 // 批量删除邮件模版
 export const deleteMailTemplateList = async (ids: number[]) => {
-  return await request.delete({ url: '/system/mail-template/delete-list', params: { ids: ids.join(',') } })
+  return await request.delete({
+    url: '/system/mail-template/delete-list',
+    params: { ids: ids.join(',') }
+  })
 }
 
 // 发送邮件

+ 1 - 1
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -17,7 +17,7 @@
             :group="{ name: 'component', pull: 'clone', put: false }"
             :clone="handleCloneComponent"
             :animation="200"
-            :force-fallback="true"
+            :force-fallback="false"
           >
             <template #item="{ element }">
               <div>

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

@@ -73,7 +73,7 @@
           <draggable
             v-model="pageComponents"
             :animation="200"
-            :force-fallback="true"
+            :force-fallback="false"
             class="page-prop-area drag-area"
             filter=".component-toolbar"
             ghost-class="draggable-ghost"

+ 1 - 1
src/components/Draggable/index.vue

@@ -2,7 +2,7 @@
   <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
   <VueDraggable
     :list="formData"
-    :force-fallback="true"
+    :force-fallback="false"
     :animation="200"
     handle=".drag-icon"
     class="m-t-8px"

+ 9 - 2
src/components/SimpleProcessDesignerV2/src/nodes-config/TriggerNodeConfig.vue

@@ -236,7 +236,7 @@
       <el-divider />
       <div>
         <el-button type="primary" @click="saveConfig">确 定</el-button>
-        <el-button @click="closeDrawer">取 消</el-button>
+        <el-button @click="cancelConfig">取 消</el-button>
       </div>
     </template>
   </el-drawer>
@@ -467,6 +467,13 @@ const saveConfig = async () => {
   return true
 }
 
+/** 取消配置 */
+const cancelConfig = () => {
+  // 恢复原来的配置
+  currentNode.value.triggerSetting = originalSetting
+  closeDrawer()
+}
+
 /** 获取节点展示内容 */
 const getShowText = (): string => {
   let showText = ''
@@ -498,7 +505,7 @@ const getShowText = (): string => {
 /** 显示触发器节点配置, 由父组件传过来 */
 const showTriggerNodeConfig = (node: SimpleFlowNode) => {
   nodeName.value = node.name
-  originalSetting = node.triggerSetting ? JSON.parse(JSON.stringify(node.triggerSetting)) : {}
+  originalSetting = cloneDeep(node.triggerSetting)
   if (node.triggerSetting) {
     configForm.value = {
       type: node.triggerSetting.type,

+ 8 - 3
src/components/Verifition/src/Verify.vue

@@ -36,14 +36,15 @@
  * Verify 验证码组件
  * @description 分发验证码使用
  * */
-import { VerifyPoints, VerifySlide } from './Verify'
+import {VerifyPictureWord, VerifyPoints, VerifySlide} from './Verify'
 import { computed, ref, toRefs, watchEffect } from 'vue'
 
 export default {
   name: 'Vue3Verify',
   components: {
     VerifySlide,
-    VerifyPoints
+    VerifyPoints,
+    VerifyPictureWord
   },
   props: {
     captchaType: {
@@ -118,6 +119,10 @@ export default {
     }
     watchEffect(() => {
       switch (captchaType.value) {
+        case 'pictureWord':
+          verifyType.value = '3'
+          componentType.value = 'VerifyPictureWord'
+          break
         case 'blockPuzzle':
           verifyType.value = '2'
           componentType.value = 'VerifySlide'
@@ -438,4 +443,4 @@ export default {
   content: ' ';
   inset: 0;
 }
-</style>
+</style>

+ 196 - 0
src/components/Verifition/src/Verify/VerifyPictureWord.vue

@@ -0,0 +1,196 @@
+<template>
+  <div style="position: relative">
+    <div class="verify-img-out">
+      <div
+        :style="{
+          width: setSize.imgWidth,
+          height: setSize.imgHeight,
+          'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
+          'margin-bottom': vSpace + 'px'
+        }"
+        class="verify-img-panel"
+      >
+        <div v-show="showRefresh" class="verify-refresh" style="z-index: 3" @click="refresh">
+          <i class="iconfont icon-refresh"></i>
+        </div>
+        <img
+          @click="refresh"
+          ref="canvas"
+          :src="'data:image/png;base64,' + verificationCodeImg"
+          alt=""
+          style="display: block; width: 100%; height: 100%"
+        />
+      </div>
+    </div>
+    <div
+      :style="{
+        width: setSize.imgWidth,
+        color: barAreaColor,
+        'border-color': barAreaBorderColor
+        // 'line-height': barSize.height
+      }"
+      class="verify-bar-area"
+    >
+      <div class="verify-msg">{{ text }}</div>
+      <div
+        :style="{
+          'line-height': barSize.height
+        }"
+      >
+        <input class="verify-input" type="text" v-model="userCode" />
+      </div>
+      <button type="button" class="verify-btn" @click="submit" :disabled="checking">{{
+        t('captcha.verify')
+      }}</button>
+    </div>
+  </div>
+</template>
+<script setup type="text/babel">
+/**
+ * VerifyPictureWord
+ * @description 输入文字
+ * */
+import { resetSize } from '../utils/util'
+import { aesEncrypt } from '../utils/ase'
+import { getCode, reqCheck } from '@/api/login'
+import { getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs } from 'vue'
+
+const props = defineProps({
+  // 弹出式 pop,固定 fixed
+  mode: {
+    type: String,
+    default: 'fixed'
+  },
+  captchaType: {
+    type: String
+  },
+  // 间隔
+  vSpace: {
+    type: Number,
+    default: 5
+  },
+  imgSize: {
+    type: Object,
+    default() {
+      return {
+        width: '310px',
+        height: '155px'
+      }
+    }
+  },
+  barSize: {
+    type: Object,
+    default() {
+      return {
+        width: '310px',
+        height: '40px'
+      }
+    }
+  }
+})
+
+const { t } = useI18n()
+const { mode, captchaType } = toRefs(props)
+const { proxy } = getCurrentInstance()
+let secretKey = ref(''), // 后端返回的ase加密秘钥
+  userCode = ref(''), // 用户输入的验证码 暂存至pointJson,无需加密
+  verificationCodeImg = ref(''), // 后端获取到的背景图片
+  backToken = ref(''), // 后端返回的token值
+  setSize = reactive({
+    imgHeight: 0,
+    imgWidth: 0,
+    barHeight: 0,
+    barWidth: 0
+  }),
+  text = ref(''),
+  barAreaColor = ref('#000'),
+  barAreaBorderColor = ref('#ddd'),
+  showRefresh = ref(true),
+  // bindingClick = ref(true)
+  checking = ref(false)
+
+const init = () => {
+  // 加载页面
+  getPicture()
+  nextTick(() => {
+    let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy)
+    setSize.imgHeight = imgHeight
+    setSize.imgWidth = imgWidth
+    setSize.barHeight = barHeight
+    setSize.barWidth = barWidth
+    proxy.$parent.$emit('ready', proxy)
+  })
+}
+onMounted(() => {
+  // 禁止拖拽
+  init()
+  proxy.$el.onselectstart = function () {
+    return false
+  }
+})
+const canvas = ref(null)
+
+const submit = () => {
+  checking.value = true
+  // 发送后端请求
+  const captchaVerification = secretKey.value
+    ? aesEncrypt(backToken.value + '---' + userCode.value, secretKey.value)
+    : backToken.value + '---' + userCode.value
+  let data = {
+    captchaType: captchaType.value,
+    pointJson: userCode.value,
+    token: backToken.value
+  }
+  reqCheck(data).then((res) => {
+    if (res.repCode === '0000') {
+      barAreaColor.value = '#4cae4c'
+      barAreaBorderColor.value = '#5cb85c'
+      text.value = t('captcha.success')
+      // bindingClick.value = false
+      if (mode.value === 'pop') {
+        setTimeout(() => {
+          proxy.$parent.clickShow = false
+          refresh()
+        }, 1500)
+      }
+      proxy.$parent.$emit('success', { captchaVerification })
+    } else {
+      proxy.$parent.$emit('error', proxy)
+      barAreaColor.value = '#d9534f'
+      barAreaBorderColor.value = '#d9534f'
+      text.value = t('captcha.fail')
+      setTimeout(() => {
+        refresh()
+      }, 700)
+    }
+    checking.value = false
+  })
+}
+
+const refresh = async function () {
+  barAreaColor.value = '#000'
+  barAreaBorderColor.value = '#ddd'
+  checking.value = false
+
+  userCode.value = ''
+
+  await getPicture()
+  showRefresh.value = true
+}
+
+// 请求背景图片和验证图片
+const getPicture = async () => {
+  let data = {
+    captchaType: captchaType.value
+  }
+  const res = await getCode(data)
+  if (res.repCode === '0000') {
+    verificationCodeImg.value = res.repData.originalImageBase64
+    backToken.value = res.repData.token
+    secretKey.value = res.repData.secretKey
+    text.value = t('captcha.code')
+  } else {
+    text.value = res.repMsg
+  }
+}
+</script>

+ 2 - 1
src/components/Verifition/src/Verify/index.ts

@@ -1,4 +1,5 @@
 import VerifySlide from './VerifySlide.vue'
 import VerifyPoints from './VerifyPoints.vue'
+import VerifyPictureWord from './VerifyPictureWord.vue'
 
-export { VerifySlide, VerifyPoints }
+export { VerifySlide, VerifyPoints, VerifyPictureWord }

+ 3 - 1
src/locales/en.ts

@@ -146,9 +146,11 @@ export default {
     invalidTenantName:"Invalid Tenant Name"
   },
   captcha: {
+    verify: 'Verify',
     verification: 'Please complete security verification',
     slide: 'Swipe right to complete verification',
     point: 'Please click',
+    code: 'Please enter the verification code',
     success: 'Verification succeeded',
     fail: 'verification failed'
   },
@@ -457,4 +459,4 @@ export default {
     btn_zoom_out: 'Zoom out',
     preview: 'Preivew'
   }
-}
+}

+ 3 - 1
src/locales/zh-CN.ts

@@ -147,9 +147,11 @@ export default {
     invalidTenantName: '无效的租户名称'
   },
   captcha: {
+    verify: '验证',
     verification: '请完成安全验证',
     slide: '向右滑动完成验证',
     point: '请依次点击',
+    code: '请输入验证码',
     success: '验证成功',
     fail: '验证失败'
   },
@@ -453,4 +455,4 @@ export default {
     preview: '预览'
   },
   'OAuth 2.0': 'OAuth 2.0' // 避免菜单名是 OAuth 2.0 时,一直 warn 报错
-}
+}

+ 5 - 148
src/utils/formCreate.ts

@@ -2,6 +2,7 @@
  * 针对 https://github.com/xaboy/form-create-designer 封装的工具类
  */
 import { isRef } from 'vue'
+import formCreate from '@form-create/element-ui'
 
 // 编码表单 Conf
 export const encodeConf = (designerRef: object) => {
@@ -24,7 +25,7 @@ export const encodeFields = (designerRef: object) => {
 export const decodeFields = (fields: string[]) => {
   const rule: object[] = []
   fields.forEach((item) => {
-    rule.push(JSON.parse(item))
+    rule.push(formCreate.parseJson(item))
   })
   return rule
 }
@@ -32,7 +33,7 @@ export const decodeFields = (fields: string[]) => {
 // 设置表单的 Conf 和 Fields,适用 FcDesigner 场景
 export const setConfAndFields = (designerRef: object, conf: string, fields: string) => {
   // @ts-ignore
-  designerRef.value.setOption(JSON.parse(conf))
+  designerRef.value.setOption(formCreate.parseJson(conf))
   // @ts-ignore
   designerRef.value.setRule(decodeFields(fields))
 }
@@ -49,154 +50,10 @@ export const setConfAndFields2 = (
     detailPreview = detailPreview.value
   }
 
-  // 修复所有函数类型(解决设计器保存后函数变成字符串的问题)。例如说:
-  // https://t.zsxq.com/rADff
-  // https://t.zsxq.com/ZfbGt
-  // https://t.zsxq.com/mHOoj
-  // https://t.zsxq.com/BSylB
-  const option = JSON.parse(conf)
-  const rule = decodeFields(fields)
-  // 🔧 修复所有函数类型 - 解决设计器保存后函数变成字符串的问题
-  const fixFunctions = (obj: any) => {
-    if (obj && typeof obj === 'object') {
-      Object.keys(obj).forEach((key) => {
-        // 检查是否是函数相关的属性
-        if (isFunctionProperty(key)) {
-          // 如果不是函数类型,重新构建为函数
-          if (typeof obj[key] !== 'function') {
-            obj[key] = createDefaultFunction(key)
-          }
-        } else if (typeof obj[key] === 'object' && obj[key] !== null) {
-          // 递归处理嵌套对象
-          fixFunctions(obj[key])
-        }
-      })
-    }
-  }
-  // 判断是否是函数属性
-  const isFunctionProperty = (key: string): boolean => {
-    const functionKeys = [
-      'beforeFetch', // 请求前处理
-      'afterFetch', // 请求后处理
-      'onSubmit', // 表单提交
-      'onReset', // 表单重置
-      'onChange', // 值变化
-      'onInput', // 输入事件
-      'onClick', // 点击事件
-      'onFocus', // 获取焦点
-      'onBlur', // 失去焦点
-      'onMounted', // 组件挂载
-      'onCreated', // 组件创建
-      'onReload', // 重新加载
-      'remoteMethod', // 远程搜索方法
-      'parseFunc', // 解析函数
-      'validator', // 验证器
-      'asyncValidator', // 异步验证器
-      'formatter', // 格式化函数
-      'parser', // 解析函数
-      'beforeUpload', // 上传前处理
-      'onSuccess', // 成功回调
-      'onError', // 错误回调
-      'onProgress', // 进度回调
-      'onPreview', // 预览回调
-      'onRemove', // 移除回调
-      'onExceed', // 超出限制回调
-      'filterMethod', // 过滤方法
-      'sortMethod', // 排序方法
-      'loadData', // 加载数据
-      'renderContent', // 渲染内容
-      'render' // 渲染函数
-    ]
-    // 检查是否以函数相关前缀开头
-    const functionPrefixes = ['on', 'before', 'after', 'handle']
-    return functionKeys.includes(key) || functionPrefixes.some((prefix) => key.startsWith(prefix))
-  }
-  // 根据函数名创建默认函数
-  const createDefaultFunction = (key: string): Function => {
-    switch (key) {
-      case 'beforeFetch':
-        return (config: any) => {
-          // 添加 Token 认证头。例如说:
-          // https://t.zsxq.com/hK3FO
-          const token = localStorage.getItem('token')
-          if (token) {
-            config.headers = {
-              ...config.headers,
-              Authorization: 'Bearer ' + token
-            }
-          }
-          // 添加通用请求头
-          config.headers = {
-            ...config.headers,
-            'Content-Type': 'application/json',
-            'X-Requested-With': 'XMLHttpRequest'
-          }
-          // 添加时间戳防止缓存
-          config.params = {
-            ...config.params,
-            _t: Date.now()
-          }
-          return config
-        }
-      case 'afterFetch':
-        return (data: any) => {
-          return data
-        }
-      case 'onSubmit':
-        return (_formData: any) => {
-          return true
-        }
-      case 'onReset':
-        return () => {
-          return true
-        }
-      case 'onChange':
-        return (_value: any, _oldValue: any) => {}
-      case 'remoteMethod':
-        return (query: string) => {
-          console.log('remoteMethod被调用:', query)
-        }
-      case 'parseFunc':
-        return (data: any) => {
-          // 默认解析逻辑:如果是数组直接返回,否则尝试获取list属性
-          if (Array.isArray(data)) {
-            return data
-          }
-          return data?.list || data?.data || []
-        }
-      case 'validator':
-        return (_rule: any, _value: any, callback: Function) => {
-          callback()
-        }
-      case 'beforeUpload':
-        return (_file: any) => {
-          return true
-        }
-      default:
-        // 通用默认函数
-        return (...args: any[]) => {
-          // 对于事件处理函数,返回true表示继续执行
-          if (key.startsWith('on') || key.startsWith('handle')) {
-            return true
-          }
-          // 对于其他函数,返回第一个参数(通常是数据传递)
-          return args[0]
-        }
-    }
-  }
-  // 修复 option 中的所有函数
-  fixFunctions(option)
-  // 修复 rule 中的所有函数(包括组件的 props)
-  if (Array.isArray(rule)) {
-    rule.forEach((item: any) => {
-      fixFunctions(item)
-    })
-  }
-
   // @ts-ignore
-  detailPreview.option = option
+  detailPreview.option = formCreate.parseJson(conf)
   // @ts-ignore
-  detailPreview.rule = rule
+  detailPreview.rule = decodeFields(fields)
 
   if (value) {
     // @ts-ignore

+ 1 - 1
src/views/Login/SocialLogin.vue

@@ -185,7 +185,7 @@ const { push } = useRouter()
 const permissionStore = usePermissionStore()
 const loginLoading = ref(false)
 const verify = ref()
-const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
 
 const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
 

+ 1 - 1
src/views/Login/components/ForgetPasswordForm.vue

@@ -143,7 +143,7 @@ const iconCircleCheck = useIcon({ icon: 'ep:circle-check' })
 const { validForm } = useFormValid(formSmsResetPassword)
 const { handleBackLogin, getLoginState, setLoginState } = useLoginState()
 const getShow = computed(() => unref(getLoginState) === LoginStateEnum.RESET_PASSWORD)
-const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
 
 const validatePass2 = (_rule, value, callback) => {
   if (value === '') {

+ 2 - 5
src/views/Login/components/LoginForm.vue

@@ -47,10 +47,7 @@
           />
         </el-form-item>
       </el-col>
-      <el-col
-        :span="24"
-        class="px-10px mt-[-20px] mb-[-20px]"
-      >
+      <el-col :span="24" class="px-10px mt-[-20px] mb-[-20px]">
         <el-form-item>
           <el-row justify="space-between" style="width: 100%">
             <el-col :span="6">
@@ -177,7 +174,7 @@ const permissionStore = usePermissionStore()
 const redirect = ref<string>('')
 const loginLoading = ref(false)
 const verify = ref()
-const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
 
 const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
 

+ 1 - 1
src/views/Login/components/RegisterForm.vue

@@ -120,7 +120,7 @@ const permissionStore = usePermissionStore()
 const redirect = ref<string>('')
 const loginLoading = ref(false)
 const verify = ref()
-const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
 
 const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER)
 

+ 27 - 34
src/views/bpm/model/form/ExtraSettings.vue

@@ -11,6 +11,17 @@
         </div>
       </div>
     </el-form-item>
+    <el-form-item class="mb-20px">
+      <template #label>
+        <el-text size="large" tag="b">审批人权限</el-text>
+      </template>
+      <div class="flex flex-col">
+        <el-checkbox v-model="modelData.allowWithdrawTask" label="允许审批人撤回任务" />
+        <div class="ml-22px">
+          <el-text type="info"> 审批人可撤回正在审批节点的前一节点 </el-text>
+        </div>
+      </div>
+    </el-form-item>
     <el-form-item v-if="modelData.processIdRule" class="mb-20px">
       <template #label>
         <el-text size="large" tag="b">流程编码</el-text>
@@ -232,34 +243,6 @@ import { ProcessVariableEnum } from '@/components/SimpleProcessDesignerV2/src/co
 import HttpRequestSetting from '@/components/SimpleProcessDesignerV2/src/nodes-config/components/HttpRequestSetting.vue'
 
 const modelData = defineModel<any>()
-const formFields = ref<string[]>([])
-   
-const props = defineProps({
-  // 流程表单 ID
-  modelFormId: {
-    type: Number,
-    required: false,
-    default: undefined,
-  }
-})
-
-
-// 监听 modelFormId 变化
-watch(
-  () => props.modelFormId,
-  async (newVal) => {
-    if (newVal) {
-      const form = await FormApi.getForm(newVal);
-      formFields.value = form?.fields;
-    } else {
-      // 如果 modelFormId 为空,清空表单字段
-      formFields.value = [];
-    }
-  },
-  { immediate: true },
-);
-// 暴露给子组件使用
-provide('formFields', formFields)
 
 /** 自定义 ID 流程编码 */
 const timeOptions = ref([
@@ -374,10 +357,10 @@ const handleTaskAfterTriggerEnableChange = (val: boolean | string | number) => {
   }
 }
 
-/** 表单选项 */
-const formField = ref<Array<{ field: string; title: string }>>([])
+/** 已解析表单字段 */
+const formFields = ref<Array<{ field: string; title: string }>>([])
 const formFieldOptions4Title = computed(() => {
-  let cloneFormField = formField.value.map((item) => {
+  let cloneFormField = formFields.value.map((item) => {
     return {
       label: item.title,
       value: item.field
@@ -399,7 +382,7 @@ const formFieldOptions4Title = computed(() => {
   return cloneFormField
 })
 const formFieldOptions4Summary = computed(() => {
-  return formField.value.map((item) => {
+  return formFields.value.map((item) => {
     return {
       label: item.title,
       value: item.field
@@ -407,6 +390,11 @@ const formFieldOptions4Summary = computed(() => {
   })
 })
 
+/** 未解析的表单字段 */
+const unParsedFormFields = ref<string[]>([])
+/** 暴露给子组件 HttpRequestSetting 使用 */
+provide('formFields', unParsedFormFields)
+
 /** 兼容以前未配置更多设置的流程 */
 const initData = () => {
   if (!modelData.value.processIdRule) {
@@ -445,6 +433,9 @@ const initData = () => {
   if (modelData.value.taskAfterTriggerSetting) {
     taskAfterTriggerEnable.value = true
   }
+  if (modelData.value.allowWithdrawTask) {
+    modelData.value.allowWithdrawTask = false
+  }
 }
 defineExpose({ initData })
 
@@ -456,13 +447,15 @@ watch(
       const data = await FormApi.getForm(newFormId)
       const result: Array<{ field: string; title: string }> = []
       if (data.fields) {
+        unParsedFormFields.value = data.fields
         data.fields.forEach((fieldStr: string) => {
           parseFormFields(JSON.parse(fieldStr), result)
         })
       }
-      formField.value = result
+      formFields.value = result
     } else {
-      formField.value = []
+      formFields.value = []
+      unParsedFormFields.value = []
     }
   },
   { immediate: true }

+ 3 - 5
src/views/bpm/model/form/index.vue

@@ -77,10 +77,7 @@
 
         <!-- 第四步:更多设置 -->
         <div v-show="currentStep === 3" class="mx-auto w-700px">
-          <ExtraSettings
-            ref="extraSettingsRef"   
-            v-model="formData" 
-            :model-form-id="formData.formId"/>
+          <ExtraSettings ref="extraSettingsRef" v-model="formData" />
         </div>
       </div>
     </div>
@@ -176,7 +173,8 @@ const formData: any = ref({
   summarySetting: {
     enable: false,
     summary: []
-  }
+  },
+  allowWithdrawTask: false
 })
 
 // 流程数据

+ 23 - 0
src/views/bpm/processInstance/detail/ProcessInstanceOperationButton.vue

@@ -51,8 +51,10 @@
           >
             <div class="ml-10px -mt-15px -mb-35px">
               <ProcessInstanceTimeline
+                ref="nextAssigneesTimelineRef"
                 :activity-nodes="nextAssigneesActivityNode"
                 :show-status-icon="false"
+                :enable-approve-user-select="true"
                 @select-user-confirm="selectNextAssigneesConfirm"
               />
             </div>
@@ -571,6 +573,7 @@ const approveFormRef = ref<FormInstance>()
 const signRef = ref()
 const approveSignFormRef = ref()
 const nextAssigneesActivityNode = ref<ProcessInstanceApi.ApprovalNodeInfo[]>([]) // 下一个审批节点信息
+const nextAssigneesTimelineRef = ref() // 下一个节点审批人时间线组件的引用
 const approveReasonForm = reactive({
   reason: '',
   signPicUrl: '',
@@ -717,6 +720,10 @@ const closePopover = (type: string, formRef: FormInstance | undefined) => {
   }
   popOverVisible.value[type] = false
   nextAssigneesActivityNode.value = []
+  // 清理 Timeline 组件中的自定义审批人数据
+  if (nextAssigneesTimelineRef.value) {
+    nextAssigneesTimelineRef.value.batchSetCustomApproveUsers({})
+  }
 }
 
 /** 流程通过时,根据表单变量查询新的流程节点,判断下一个节点类型是否为自选审批人 */
@@ -729,6 +736,7 @@ const initNextAssigneesFormField = async () => {
     processVariablesStr: JSON.stringify(variables)
   })
   if (data && data.length > 0) {
+    const customApproveUsersData: Record<string, any[]> = {} // 用于收集需要设置到 Timeline 组件的自定义审批人数据
     data.forEach((node: any) => {
       if (
         // 情况一:当前节点没有审批人,并且是发起人自选
@@ -740,7 +748,18 @@ const initNextAssigneesFormField = async () => {
       ) {
         nextAssigneesActivityNode.value.push(node)
       }
+
+      // 如果节点有 candidateUsers,设置到 customApproveUsers 中
+      if (node.candidateUsers && node.candidateUsers.length > 0) {
+        customApproveUsersData[node.id] = node.candidateUsers
+      }
     })
+
+    // 将 candidateUsers 设置到 Timeline 组件中
+    await nextTick() // 等待下一个 tick,确保 Timeline 组件已经渲染
+    if (nextAssigneesTimelineRef.value && Object.keys(customApproveUsersData).length > 0) {
+      nextAssigneesTimelineRef.value.batchSetCustomApproveUsers(customApproveUsersData)
+    }
   }
 }
 
@@ -803,6 +822,10 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
       await TaskApi.approveTask(data)
       popOverVisible.value.approve = false
       nextAssigneesActivityNode.value = []
+      // 清理 Timeline 组件中的自定义审批人数据
+      if (nextAssigneesTimelineRef.value) {
+        nextAssigneesTimelineRef.value.batchSetCustomApproveUsers({})
+      }
       message.success('审批通过成功')
     } else {
       // 审批不通过数据

+ 26 - 9
src/views/bpm/processInstance/detail/ProcessInstanceTimeline.vue

@@ -15,7 +15,7 @@
         >
           <img class="w-full h-full" :src="getApprovalNodeImg(activity.nodeType)" alt="" />
           <div
-            v-if="showStatusIcon"
+            v-if="props.showStatusIcon"
             class="position-absolute top-17px left-17px rounded-full flex items-center p-1px border-2 border-white border-solid"
             :style="{ backgroundColor: getApprovalNodeColor(activity.status) }"
           >
@@ -55,13 +55,13 @@
           class="flex flex-wrap gap2 items-center"
           v-if="
             isEmpty(activity.tasks) &&
-            isEmpty(activity.candidateUsers) &&
-            (CandidateStrategy.START_USER_SELECT === activity.candidateStrategy ||
-              CandidateStrategy.APPROVE_USER_SELECT === activity.candidateStrategy)
+            ((CandidateStrategy.START_USER_SELECT === activity.candidateStrategy &&
+              isEmpty(activity.candidateUsers)) ||
+              (props.enableApproveUserSelect &&
+                CandidateStrategy.APPROVE_USER_SELECT === activity.candidateStrategy))
           "
         >
           <!--  && activity.nodeType === NodeType.USER_TASK_NODE -->
-
           <el-tooltip content="添加用户" placement="left">
             <el-button
               class="!px-6px"
@@ -119,7 +119,7 @@
                 </template>
                 <!-- 信息:任务 ICON -->
                 <div
-                  v-if="showStatusIcon && onlyStatusIconShow.includes(task.status)"
+                  v-if="props.showStatusIcon && onlyStatusIconShow.includes(task.status)"
                   class="position-absolute top-19px left-23px rounded-full flex items-center p-1px border-2 border-white border-solid"
                   :style="{ backgroundColor: statusIconMap2[task.status]?.color }"
                 >
@@ -165,7 +165,7 @@
 
             <!-- 信息:任务 ICON -->
             <div
-              v-if="showStatusIcon"
+              v-if="props.showStatusIcon"
               class="position-absolute top-20px left-24px rounded-full flex items-center p-1px border-2 border-white border-solid"
               :style="{ backgroundColor: statusIconMap2['-1']?.color }"
             >
@@ -198,13 +198,15 @@ import transactorSvg from '@/assets/svgs/bpm/transactor.svg'
 import childProcessSvg from '@/assets/svgs/bpm/child-process.svg'
 
 defineOptions({ name: 'BpmProcessInstanceTimeline' })
-withDefaults(
+const props = withDefaults(
   defineProps<{
     activityNodes: ProcessInstanceApi.ApprovalNodeInfo[] // 审批节点信息
     showStatusIcon?: boolean // 是否显示头像右下角状态图标
+    enableApproveUserSelect?: boolean // 是否开启审批人自选功能
   }>(),
   {
-    showStatusIcon: true // 默认值为 true
+    showStatusIcon: true, // 默认值为 true
+    enableApproveUserSelect: false // 默认值为 false
   }
 )
 const { push } = useRouter() // 路由
@@ -341,4 +343,19 @@ const handleChildProcess = (activity: any) => {
     }
   })
 }
+
+/** 设置自定义审批人 */
+const setCustomApproveUsers = (activityId: string, users: any[]) => {
+  customApproveUsers.value[activityId] = users || []
+}
+
+/** 批量设置多个节点的自定义审批人 */
+const batchSetCustomApproveUsers = (data: Record<string, any[]>) => {
+  Object.keys(data).forEach((activityId) => {
+    customApproveUsers.value[activityId] = data[activityId] || []
+  })
+}
+
+// 暴露方法给父组件
+defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers })
 </script>

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

@@ -184,8 +184,9 @@
         :show-overflow-tooltip="true"
       />
       <el-table-column align="center" label="任务编号" prop="id" :show-overflow-tooltip="true" />
-      <el-table-column align="center" label="操作" fixed="right" width="80">
+      <el-table-column align="center" label="操作" fixed="right" width="130">
         <template #default="scope">
+          <el-button link type="warning" @click="handleWithdraw(scope.row)">撤回</el-button>
           <el-button link type="primary" @click="handleAudit(scope.row)">历史</el-button>
         </template>
       </el-table-column>
@@ -209,6 +210,7 @@ import * as DefinitionApi from '@/api/bpm/definition'
 defineOptions({ name: 'BpmDoneTask' })
 
 const { push } = useRouter() // 路由
+const message = useMessage()
 
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
@@ -262,6 +264,14 @@ const handleAudit = (row: any) => {
   })
 }
 
+/** 测回按钮 */
+const handleWithdraw = (row: any) => {
+  TaskApi.withdrawTask(row.id).then(() => {
+    message.success('撤回成功')
+    getList()
+  })
+}
+
 /** 初始化 **/
 onMounted(async () => {
   await getList()

+ 25 - 3
src/views/system/mail/log/MailLogDetail.vue

@@ -13,12 +13,34 @@
       <el-descriptions-item label="模版发送人名称">
         {{ detailData.templateNickname }}
       </el-descriptions-item>
-      <el-descriptions-item label="用户信息">
-        {{ detailData.toMail }}
+      <el-descriptions-item label="接收用户">
         <span v-if="detailData.userType && detailData.userId">
           <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" />
           ({{ detailData.userId }})
         </span>
+        <span v-else>无</span>
+      </el-descriptions-item>
+      <el-descriptions-item label="接收信息">
+        <div>
+          <div v-if="detailData.toMails && detailData.toMails.length > 0">
+            收件:
+            <span v-for="(mail, index) in detailData.toMails" :key="mail">
+              {{ mail }}<span v-if="index < detailData.toMails.length - 1">、</span>
+            </span>
+          </div>
+          <div v-if="detailData.ccMails && detailData.ccMails.length > 0">
+            抄送:
+            <span v-for="(mail, index) in detailData.ccMails" :key="mail">
+              {{ mail }}<span v-if="index < detailData.ccMails.length - 1">、</span>
+            </span>
+          </div>
+          <div v-if="detailData.bccMails && detailData.bccMails.length > 0">
+            密送:
+            <span v-for="(mail, index) in detailData.bccMails" :key="mail">
+              {{ mail }}<span v-if="index < detailData.bccMails.length - 1">、</span>
+            </span>
+          </div>
+        </div>
       </el-descriptions-item>
       <el-descriptions-item label="邮件标题">
         {{ detailData.templateTitle }}
@@ -58,7 +80,7 @@ defineOptions({ name: 'SystemMailLogDetail' })
 const dialogVisible = ref(false) // 弹窗的是否展示
 const detailLoading = ref(false) // 表单的加载中
 const detailData = ref() // 详情数据
-const accountList = ref([]) // 邮箱账号列表
+const accountList = ref<MailAccountApi.MailAccountVO[]>([]) // 邮箱账号列表
 
 /** 打开弹窗 */
 const open = async (data: MailLogApi.MailLogVO) => {

+ 33 - 9
src/views/system/mail/log/index.vue

@@ -119,12 +119,36 @@
         width="180"
         :formatter="dateFormatter"
       />
-      <el-table-column label="接收邮箱" align="center" prop="toMail" width="200">
+      <el-table-column label="接收用户" align="center" width="150">
         <template #default="scope">
-          <div>{{ scope.row.toMail }}</div>
           <div v-if="scope.row.userType && scope.row.userId">
             <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" />
-            {{ '(' + scope.row.userId + ')' }}
+            <div>{{ '(' + scope.row.userId + ')' }}</div>
+          </div>
+          <div v-else>-</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="接收信息" align="center" width="300">
+        <template #default="scope">
+          <div class="text-left">
+            <div v-if="scope.row.toMails && scope.row.toMails.length > 0">
+              收件:
+              <span v-for="(mail, index) in scope.row.toMails" :key="mail">
+                {{ mail }}<span v-if="index < scope.row.toMails.length - 1">、</span>
+              </span>
+            </div>
+            <div v-if="scope.row.ccMails && scope.row.ccMails.length > 0">
+              抄送:
+              <span v-for="(mail, index) in scope.row.ccMails" :key="mail">
+                {{ mail }}<span v-if="index < scope.row.ccMails.length - 1">、</span>
+              </span>
+            </div>
+            <div v-if="scope.row.bccMails && scope.row.bccMails.length > 0">
+              密送:
+              <span v-for="(mail, index) in scope.row.bccMails" :key="mail">
+                {{ mail }}<span v-if="index < scope.row.bccMails.length - 1">、</span>
+              </span>
+            </div>
           </div>
         </template>
       </el-table-column>
@@ -185,15 +209,15 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   toMail: '',
-  accountId: null,
-  templateId: null,
-  sendStatus: null,
-  userId: null,
-  userType: null,
+  accountId: undefined,
+  templateId: undefined,
+  sendStatus: undefined,
+  userId: undefined,
+  userType: undefined,
   sendTime: []
 })
 const exportLoading = ref(false) // 导出的加载中
-const accountList = ref([]) // 邮箱账号列表
+const accountList = ref<MailAccountApi.MailAccountVO[]>([]) // 邮箱账号列表
 
 /** 查询列表 */
 const getList = async () => {

+ 26 - 5
src/views/system/mail/template/MailTemplateSendForm.vue

@@ -10,8 +10,26 @@
       <el-form-item label="模板内容" prop="content">
         <Editor :model-value="formData.content" height="150px" readonly />
       </el-form-item>
-      <el-form-item label="收件邮箱" prop="mail">
-        <el-input v-model="formData.mail" placeholder="请输入收件邮箱" />
+      <el-form-item label="收件邮箱" prop="toMails">
+        <el-input-tag
+          v-model="formData.toMails"
+          placeholder="请输入收件邮箱,多个邮箱用回车分隔"
+          class="!w-full"
+        />
+      </el-form-item>
+      <el-form-item label="抄送邮箱" prop="ccMails">
+        <el-input-tag
+          v-model="formData.ccMails"
+          placeholder="请输入抄送邮箱,多个邮箱用回车分隔"
+          class="!w-full"
+        />
+      </el-form-item>
+      <el-form-item label="密送邮箱" prop="bccMails">
+        <el-input-tag
+          v-model="formData.bccMails"
+          placeholder="请输入密送邮箱,多个邮箱用回车分隔"
+          class="!w-full"
+        />
       </el-form-item>
       <el-form-item
         v-for="param in formData.params"
@@ -43,12 +61,13 @@ const formLoading = ref(false) // 表单的加载中:1)修改时的数据加
 const formData = ref({
   content: '',
   params: {},
-  mail: '',
+  toMails: [],
+  ccMails: [],
+  bccMails: [],
   templateCode: '',
   templateParams: new Map()
 })
 const formRules = reactive({
-  mail: [{ required: true, message: '邮箱不能为空', trigger: 'blur' }],
   templateCode: [{ required: true, message: '模版编号不能为空', trigger: 'blur' }],
   templateParams: {}
 })
@@ -105,7 +124,9 @@ const resetForm = () => {
   formData.value = {
     content: '',
     params: {},
-    mail: '',
+    toMails: [],
+    ccMails: [],
+    bccMails: [],
     templateCode: '',
     templateParams: new Map()
   }