Преглед изворни кода

feat: [bpm] 同意增加选择下一个审批人和签名操作

jason пре 3 месеци
родитељ
комит
8aa8b04354

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

@@ -134,3 +134,8 @@ export function getProcessInstanceManagerPage(params: PageParam) {
 export function cancelProcessInstanceByAdmin(id: string, reason: string) {
   return http.delete<boolean>('/bpm/process-instance/cancel-by-admin', { id, reason })
 }
+
+/** 获取下一个节点审批人 */
+export function getNextApproveNodes(params) {
+  return http.get<ApprovalNodeInfo[]>('/bpm/process-instance/get-next-approval-nodes', params)
+}

+ 7 - 1
src/api/bpm/task/index.ts

@@ -31,6 +31,7 @@ export interface Task {
   processInstanceId?: string // 流程实例 ID
   processInstance: ProcessInstance
   reasonRequire?: boolean // 是否填写审批意见
+  signEnable?: boolean // 是否需要签名
   buttonsSetting?: Record<number, OperationButtonSetting> // 按钮设置
   children?: Task[] // 由加签生成,包含多层子任务
 }
@@ -46,7 +47,12 @@ export function getTaskDonePage(params: PageParam) {
 }
 
 /** 审批通过 */
-export function approveTask(data: { id: string, reason: string }) {
+export function approveTask(data: {
+  id: string
+  reason: string
+  signPicUrl?: string // 签名图片 URL
+  nextAssignees?: Record<string, number[]> // 下一个节点审批人
+}) {
   return http.put<boolean>('/bpm/task/approve', data)
 }
 

+ 0 - 1
src/pages-bpm/oa/leave/create/index.vue

@@ -61,7 +61,6 @@
           v-if="activityNodes.length > 0"
           :activity-nodes="activityNodes"
           :show-status-icon="false"
-          :enable-approve-user-select="true"
           @select-user-confirm="selectUserConfirm"
         />
 

+ 220 - 18
src/pages-bpm/processInstance/detail/audit/index.vue

@@ -1,6 +1,5 @@
 <template>
   <view class="yd-page-container">
-    <!-- TODO @jason:还有一些细节,在审批通过没搞完!1)签名;2)选择审批人;3)其它等等 -->
     <!-- 顶部导航栏 -->
     <wd-navbar
       :title="isApprove ? '审批同意' : '审批拒绝'"
@@ -12,6 +11,40 @@
     <view class="p-24rpx">
       <wd-form ref="formRef" :model="formData" :rules="formRules">
         <wd-cell-group border>
+          <!-- 下一个节点的审批人 -->
+          <view v-if="isApprove && nextAssigneesActivityNode.length > 0" class="p-24rpx">
+            <view class="mb-16rpx flex items-center">
+              <text class="mr-8rpx text-[#f56c6c]">*</text>
+              <text class="text-28rpx text-[#333]">下一个节点的审批人</text>
+            </view>
+            <ProcessInstanceTimeline
+              :activity-nodes="nextAssigneesActivityNode"
+              :show-status-icon="false"
+              :enable-approve-user-select="true"
+              @select-user-confirm="selectNextAssigneesConfirm"
+            />
+          </view>
+
+          <!-- 签名 -->
+          <view v-if="isApprove && taskInfo?.signEnable" class="border-b border-[#eee] p-24rpx">
+            <view class="mb-16rpx flex items-center">
+              <text class="mr-8rpx text-[#f56c6c]">*</text>
+              <text class="text-28rpx text-[#333]">签名</text>
+            </view>
+            <view class="flex items-center gap-16rpx">
+              <wd-button type="primary" size="small" @click="openSignatureModal">
+                {{ formData.signPicUrl ? '重新签名' : '点击签名' }}
+              </wd-button>
+              <image
+                v-if="formData.signPicUrl"
+                :src="formData.signPicUrl"
+                class="h-80rpx w-192rpx"
+                mode="aspectFit"
+                @click="previewSignature"
+              />
+            </view>
+          </view>
+
           <!-- 审批意见 -->
           <wd-textarea
             v-model="formData.reason"
@@ -39,15 +72,39 @@
         </wd-button>
       </view>
     </view>
+
+    <!-- 签名弹窗 -->
+    <wd-popup v-model="showSignatureModal" position="bottom" custom-style="height: 60vh;">
+      <view class="h-full flex flex-col">
+        <view class="flex items-center justify-between border-b border-[#eee] p-24rpx">
+          <text class="text-32rpx text-[#333] font-bold">手写签名</text>
+          <wd-icon name="close" size="40rpx" @click="showSignatureModal = false" />
+        </view>
+        <view class="flex-1 p-24rpx">
+          <wd-signature
+            :height="300"
+            :export-scale="2"
+            background-color="#ffffff"
+            @confirm="handleSignatureConfirm"
+            @clear="handleSignatureClear"
+          />
+        </view>
+      </view>
+    </wd-popup>
   </view>
 </template>
 
 <script lang="ts" setup>
 import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
+import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
+import type { Task } from '@/api/bpm/task'
 import { computed, onMounted, reactive, ref } from 'vue'
 import { useToast } from 'wot-design-uni'
+import { getApprovalDetail, getNextApproveNodes } from '@/api/bpm/processInstance'
 import { approveTask, rejectTask } from '@/api/bpm/task'
-import { navigateBackPlus } from '@/utils'
+import ProcessInstanceTimeline from '@/pages-bpm/processInstance/detail/components/time-line.vue'
+import { getEnvBaseUrl, navigateBackPlus } from '@/utils'
+import { BpmCandidateStrategyEnum } from '@/utils/constants'
 
 const props = defineProps<{
   processInstanceId?: string
@@ -61,49 +118,185 @@ definePage({
     navigationStyle: 'custom',
   },
 })
-
 const taskId = computed(() => props.taskId || '')
 const processInstanceId = computed(() => props.processInstanceId)
-const isPass = computed(() => props.pass !== 'false') // true: 同意, false: 拒绝
+const isApprove = computed(() => props.pass !== 'false') // true: 同意, false: 拒绝
 const toast = useToast()
 const formLoading = ref(false)
+const taskInfo = ref<Task | null>(null) // 任务信息
+
+const nextAssigneesActivityNode = ref<ApprovalNodeInfo[]>([]) // 下一个节点审批人列表
+const approveUserSelectTasks = ref<ApprovalNodeInfo[]>([]) // 需要选择审批人的节点列表
+const approveUserSelectAssignees = ref<Record<string, number[]>>({}) // 审批人选择的审批人数据
+
+// 签名相关
+const showSignatureModal = ref(false)
+
 const formData = reactive({
   reason: '',
+  signPicUrl: '', // 签名图片 URL
 })
 
-const formRules = {
-  reason: [
-    { required: true, message: '审批意见不能为空' },
-  ],
-}
+const formRules = computed(() => {
+  let rules = {}
+  if (taskInfo.value?.reasonRequire) {
+    rules = {
+      reason: [
+        { required: true, message: '审批意见不能为空' },
+      ],
+    }
+  }
+  return rules
+})
 
 const formRef = ref<FormInstance>()
 
-/** 是否为同意操作 */
-const isApprove = computed(() => isPass.value)
-
 /** 返回上一页 */
 function handleBack() {
   navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
 }
 
+/** 加载任务信息 */
+async function loadTaskInfo() {
+  const data = await getApprovalDetail({
+    processInstanceId: processInstanceId.value,
+    taskId: taskId.value,
+  })
+  taskInfo.value = data?.todoTask || null
+}
+
+/** 加载下一个节点审批人 */
+async function loadNextApproveNodes() {
+  if (!isApprove.value) {
+    return
+  }
+  const params = {
+    processInstanceId: processInstanceId.value,
+    taskId: taskId.value,
+  }
+  const data = await getNextApproveNodes(params)
+  if (data && data.length > 0) {
+    nextAssigneesActivityNode.value = data
+    // 获取审批人自选的任务
+    approveUserSelectTasks.value = data.filter(
+      (node: ApprovalNodeInfo) =>
+        BpmCandidateStrategyEnum.APPROVE_USER_SELECT === node.candidateStrategy,
+    ) || []
+  }
+}
+
+/** 选择下一个节点审批人确认 */
+function selectNextAssigneesConfirm(activityId: string, userList: any[]) {
+  approveUserSelectAssignees.value[activityId] = userList.map(user => user.id)
+}
+
+/** 打开签名弹窗 */
+function openSignatureModal() {
+  showSignatureModal.value = true
+}
+
+/** 签名确认 */
+async function handleSignatureConfirm(result: { tempFilePath: string, base64: string }) {
+  toast.loading('上传中...')
+  try {
+    // 上传签名图片
+    const url = await uploadSignatureFile(result.tempFilePath)
+    formData.signPicUrl = url
+    showSignatureModal.value = false
+    toast.success('签名成功')
+  } catch (err) {
+    console.error('上传失败:', err)
+    toast.show('上传失败')
+  }
+}
+
+/** 上传签名文件 */
+function uploadSignatureFile(tempFilePath: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: `${getEnvBaseUrl()}/infra/file/upload`,
+      filePath: tempFilePath,
+      name: 'file',
+      success: (uploadFileRes) => {
+        try {
+          const data = JSON.parse(uploadFileRes.data)
+          if (data.code === 0 && data.data) {
+            resolve(data.data)
+          } else {
+            reject(new Error(data.msg || '上传失败'))
+          }
+        } catch (err) {
+          reject(err)
+        }
+      },
+      fail: (err) => {
+        console.error('上传失败:', err)
+        reject(err)
+      },
+    })
+  })
+}
+
+/** 签名清除 */
+function handleSignatureClear() {
+  formData.signPicUrl = ''
+}
+
+/** 预览签名 */
+function previewSignature() {
+  if (formData.signPicUrl) {
+    uni.previewImage({
+      urls: [formData.signPicUrl],
+      current: formData.signPicUrl,
+    })
+  }
+}
+
 /** 提交审批 */
 async function handleSubmit() {
   if (formLoading.value) {
     return
   }
+
   const { valid } = await formRef.value!.validate()
   if (!valid) {
     return
   }
+  // 验证签名
+  if (isApprove.value && taskInfo.value?.signEnable && !formData.signPicUrl) {
+    toast.show('请先进行签名')
+    return
+  }
+
+  // 验证审批人选择
+  if (isApprove.value && approveUserSelectTasks.value.length > 0) {
+    for (const task of approveUserSelectTasks.value) {
+      if (!approveUserSelectAssignees.value[task.id] || approveUserSelectAssignees.value[task.id].length === 0) {
+        toast.show(`请选择「${task.name}」的审批人`)
+        return
+      }
+    }
+  }
 
   formLoading.value = true
   try {
-    const api = isApprove.value ? approveTask : rejectTask
-    await api({
-      id: taskId.value as string,
-      reason: formData.reason,
-    })
+    if (isApprove.value) {
+      // 审批通过
+      await approveTask({
+        id: taskId.value as string,
+        reason: formData.reason,
+        signPicUrl: formData.signPicUrl || undefined,
+        nextAssignees: Object.keys(approveUserSelectAssignees.value).length > 0
+          ? approveUserSelectAssignees.value
+          : undefined,
+      })
+    } else {
+      // 审批拒绝
+      await rejectTask({
+        id: taskId.value as string,
+        reason: formData.reason,
+      })
+    }
     toast.success('审批成功')
     setTimeout(() => {
       uni.redirectTo({
@@ -116,10 +309,19 @@ async function handleSubmit() {
 }
 
 /** 页面加载时 */
-onMounted(() => {
+onMounted(async () => {
   /** 初始化校验 */
   if (!props.taskId || !props.processInstanceId) {
     toast.show('参数错误')
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    // 加载任务信息和下一个节点审批人
+    await loadTaskInfo()
+    await loadNextApproveNodes()
+  } finally {
+    toast.close()
   }
 })
 </script>

+ 3 - 8
src/pages-bpm/processInstance/detail/components/operation-button.vue

@@ -89,15 +89,10 @@ const operationIconsMap: Record<number, string> = {
 }
 
 const userStore = useUserStore()
-// TODO @jason:字段注释,使用尾注释哈;
-/** 左侧操作按钮 【最多两个】{转办, 委派, 退回, 加签, 抄送等} */
-const leftOperations = ref<LeftOperationType[]>([])
-
-/** 右侧操作按钮【最多两个】{通过,拒绝, 取消} */
-const rightOperationTypes = []
+const leftOperations = ref<LeftOperationType[]>([]) //  左侧操作按钮 【最多两个】{转办, 委派, 退回, 加签, 抄送等}
+const rightOperationTypes = [] // 右侧操作按钮【最多两个】{通过,拒绝, 取消}
 const rightOperations = ref<RightOperationType[]>([])
-/** 更多操作 */
-const moreOperations = ref<MoreOperationType[]>([])
+const moreOperations = ref<MoreOperationType[]>([]) // 更多操作
 const runningTask = ref<Task>()
 const processInstance = ref<ProcessInstance>()
 const reasonRequire = ref<boolean>(false)

+ 6 - 7
src/pages-bpm/processInstance/detail/reassign/index.vue

@@ -37,8 +37,8 @@
           <wd-button
             type="primary"
             block
-            :loading="submitting"
-            :disabled="submitting"
+            :loading="formLoading"
+            :disabled="formLoading"
             @click="handleSubmit"
           >
             {{ isDelegate ? '委派' : '转办' }}
@@ -75,7 +75,7 @@ const processInstanceId = computed(() => props.processInstanceId)
 const operationType = computed(() => props.type || 'transfer') // 默认转办
 const isDelegate = computed(() => operationType.value === 'delegate')
 const toast = useToast()
-const submitting = ref(false)
+const formLoading = ref(false)
 const formData = reactive({
   userId: undefined as number | undefined,
   reason: '',
@@ -97,7 +97,7 @@ function handleBack() {
 
 /** 提交操作 */
 async function handleSubmit() {
-  if (submitting.value) {
+  if (formLoading.value) {
     return
   }
   const { valid } = await formRef.value!.validate()
@@ -105,8 +105,7 @@ async function handleSubmit() {
     return
   }
 
-  // TODO @jason:submitting 改成 formLoading 哇?统一代码风格哈;
-  submitting.value = true
+  formLoading.value = true
   try {
     const data = {
       id: taskId.value as string,
@@ -130,7 +129,7 @@ async function handleSubmit() {
       })
     }, 500)
   } finally {
-    submitting.value = false
+    formLoading.value = false
   }
 }
 

+ 6 - 7
src/pages-bpm/processInstance/detail/return/index.vue

@@ -39,8 +39,8 @@
           <wd-button
             type="primary"
             block
-            :loading="submitting"
-            :disabled="submitting"
+            :loading="formLoading"
+            :disabled="formLoading"
             @click="handleSubmit"
           >
             退回
@@ -73,7 +73,7 @@ definePage({
 const taskId = computed(() => props.taskId)
 const processInstanceId = computed(() => props.processInstanceId)
 const toast = useToast()
-const submitting = ref(false)
+const formLoading = ref(false)
 const activityOptions = ref<any[]>([])
 const formData = reactive({
   targetActivityId: '',
@@ -102,15 +102,14 @@ async function loadReturnTaskList() {
 
 /** 提交操作 */
 async function handleSubmit() {
-  if (submitting.value) {
+  if (formLoading.value) {
     return
   }
   const { valid } = await formRef.value!.validate()
   if (!valid) {
     return
   }
-  // TODO @jason:submitting 改成 formLoading 哇?统一代码风格哈;
-  submitting.value = true
+  formLoading.value = true
   try {
     await returnTask({
       id: taskId.value as string,
@@ -125,7 +124,7 @@ async function handleSubmit() {
       })
     }, 500)
   } finally {
-    submitting.value = false
+    formLoading.value = false
   }
 }