Browse Source

!41 feat: [bpm] 新增流程详情 timeline
Merge pull request !41 from Jason/master

芋道源码 3 months ago
parent
commit
9b9ac54409

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

@@ -19,3 +19,8 @@ export interface ProcessDefinition {
 export function getProcessDefinitionList(params?: { suspensionState?: number }) {
   return http.get<ProcessDefinition[]>('/bpm/process-definition/list', params)
 }
+
+/** 获取流程定义详情 */
+export function getProcessDefinition(id?: string, key?: string) {
+  return http.get<ProcessDefinition>('/bpm/process-definition/get', { id, key })
+}

+ 35 - 2
src/api/bpm/processInstance/index.ts

@@ -1,7 +1,10 @@
 import type { Task } from '@/api/bpm/task'
 import type { PageParam, PageResult } from '@/http/types'
+import type {
+  BpmCandidateStrategyEnum,
+  BpmNodeTypeEnum,
+} from '@/utils/constants'
 import { http } from '@/http/http'
-
 /** 流程实例用户信息 */
 export interface User {
   id: number
@@ -48,9 +51,34 @@ export interface ProcessInstance {
 export interface ApprovalDetail {
   processInstance: ProcessInstance
   processDefinition: ProcessDefinition
+  activityNodes: ApprovalNodeInfo[]
   todoTask: Task
 }
 
+/** 审批详情的节点信息 */
+export interface ApprovalNodeInfo {
+  candidateStrategy?: BpmCandidateStrategyEnum
+  candidateUsers?: User[]
+  endTime?: Date
+  id: string
+  name: string
+  nodeType: BpmNodeTypeEnum
+  startTime?: Date
+  status: number
+  processInstanceId?: string
+  tasks: ApprovalTaskInfo[]
+}
+
+/** 审批详情的节点的任务 */
+export interface ApprovalTaskInfo {
+  id: number
+  assigneeUser: User
+  ownerUser: User
+  reason: string
+  signPicUrl: string
+  status: number
+}
+
 /** 抄送流程实例 */
 export interface ProcessInstanceCopy {
   id: string
@@ -80,7 +108,7 @@ export function getProcessInstance(id: string) {
 }
 
 /** 获取审批详情 */
-export function getApprovalDetail(params: { processInstanceId: string, activityId?: string, taskId?: string }) {
+export function getApprovalDetail(params: { processDefinitionId?: string, processInstanceId?: string, activityId?: string, taskId?: string, processVariablesStr?: string }) {
   return http.get<ApprovalDetail>('/bpm/process-instance/get-approval-detail', params)
 }
 
@@ -106,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)
 }
 

+ 33 - 0
src/components/system-select/user-picker.vue

@@ -1,5 +1,24 @@
 <template>
   <wd-select-picker
+    v-if="useDefaultSlot"
+    v-model="selectedId"
+    :label="label"
+    :label-width="label ? '180rpx' : '0'"
+    :columns="userList"
+    value-key="id"
+    label-key="nickname"
+    :type="type"
+    :prop="prop"
+    use-default-slot
+    filterable
+    :placeholder="placeholder"
+    @confirm="handleConfirm"
+  >
+    <slot />
+  </wd-select-picker>
+
+  <wd-select-picker
+    v-else
     v-model="selectedId"
     :label="label"
     :label-width="label ? '180rpx' : '0'"
@@ -25,15 +44,18 @@ const props = withDefaults(defineProps<{
   label?: string
   placeholder?: string
   prop?: string
+  useDefaultSlot?: boolean
 }>(), {
   type: 'checkbox',
   label: '',
   placeholder: '请选择',
   prop: '',
+  useDefaultSlot: false,
 })
 
 const emit = defineEmits<{
   (e: 'update:modelValue', value: number | number[] | undefined): void
+  (e: 'confirm', users: User[]): void
 }>()
 
 const userList = ref<User[]>([])
@@ -74,6 +96,17 @@ async function loadUserList() {
 /** 选择确认 */
 function handleConfirm({ value }: { value: any }) {
   emit('update:modelValue', value)
+
+  // 发出包含完整用户对象的 confirm 事件
+  if (Array.isArray(value)) {
+    const selectedUsers = userList.value.filter(user => value.includes(user.id))
+    emit('confirm', selectedUsers)
+  } else if (value) {
+    const selectedUser = userList.value.find(user => user.id === value)
+    emit('confirm', selectedUser ? [selectedUser] : [])
+  } else {
+    emit('confirm', [])
+  }
 }
 
 /** 初始化 */

+ 197 - 47
src/pages-bpm/oa/leave/create/index.vue

@@ -1,57 +1,75 @@
 <template>
-  <view class="yd-page-container">
+  <view class="yd-page-container pb-[76rpx]">
     <!-- 顶部导航栏 -->
     <wd-navbar
       title="发起请假"
       left-arrow placeholder safe-area-inset-top fixed
       @click-left="handleBack"
     />
+    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+      <!-- 表单内容 -->
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border title="请假信息">
+          <wd-picker
+            v-model="formData.type"
+            :columns="getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
+            label="请假类型"
+            label-width="200rpx"
+            prop="type"
+            :rules="[{ required: true, message: '请选择请假类型' }]"
+            placeholder="请选择请假类型"
+          />
+          <wd-datetime-picker
+            v-model="formData.startTime"
+            label="开始时间"
+            label-width="200rpx"
+            prop="startTime"
+            :rules="[{ required: true, message: '请选择开始时间' }]"
+            placeholder="请选择开始时间"
+          />
+          <wd-datetime-picker
+            v-model="formData.endTime"
+            label="结束时间"
+            label-width="200rpx"
+            prop="endTime"
+            :rules="[{ required: true, message: '请选择结束时间' }]"
+            placeholder="请选择结束时间"
+          />
+          <wd-textarea
+            v-model="formData.reason"
+            label="请假原因"
+            label-width="200rpx"
+            prop="reason"
+            :rules="[{ required: true, message: '请输入请假原因' }]"
+            placeholder="请输入请假原因"
+            :maxlength="200"
+            show-word-limit
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+    <!-- 流程预览卡片 -->
+    <view class="mx-24rpx mb-120rpx mt-24rpx rounded-16rpx bg-white">
+      <view class="p-24rpx">
+        <view class="mb-16rpx flex items-center justify-between">
+          <text class="text-28rpx text-[#333] font-bold">流程预览</text>
+          <wd-loading v-if="processTimeLineLoading" size="32rpx" />
+        </view>
 
-    <!-- 表单内容 -->
-    <wd-form ref="formRef" :model="formData" :rules="formRules">
-      <wd-cell-group border title="请假信息">
-        <wd-picker
-          v-model="formData.type"
-          :columns="getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
-          label="请假类型"
-          label-width="200rpx"
-          prop="type"
-          :rules="[{ required: true, message: '请选择请假类型' }]"
-          placeholder="请选择请假类型"
-        />
-        <wd-datetime-picker
-          v-model="formData.startTime"
-          label="开始时间"
-          label-width="200rpx"
-          prop="startTime"
-          :rules="[{ required: true, message: '请选择开始时间' }]"
-          placeholder="请选择开始时间"
-        />
-        <wd-datetime-picker
-          v-model="formData.endTime"
-          label="结束时间"
-          label-width="200rpx"
-          prop="endTime"
-          :rules="[{ required: true, message: '请选择结束时间' }]"
-          placeholder="请选择结束时间"
+        <!-- 流程时间线 -->
+        <ProcessInstanceTimeline
+          v-if="activityNodes.length > 0"
+          :activity-nodes="activityNodes"
+          :show-status-icon="false"
+          @select-user-confirm="selectUserConfirm"
         />
-        <wd-textarea
-          v-model="formData.reason"
-          label="请假原因"
-          label-width="200rpx"
-          prop="reason"
-          :rules="[{ required: true, message: '请输入请假原因' }]"
-          placeholder="请输入请假原因"
-          :maxlength="200"
-          show-word-limit
-        />
-      </wd-cell-group>
-    </wd-form>
 
-    <!-- TODO:@jason:流程预览卡片 -->
-    <!-- 原始 vben 版本有流程节点预览和发起人选择审批人功能 -->
-    <!-- 参考:yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/oa/leave/create.vue 第 40-50 行 -->
-    <!-- uniapp 端暂不实现流程节点预览,因为需要复杂的 ProcessInstanceTimeline 组件 -->
+        <!-- 无流程数据提示 -->
+        <view v-else-if="!processTimeLineLoading" class="py-40rpx text-center">
+          <text class="text-24rpx text-[#999]">暂无流程预览数据</text>
+        </view>
+      </view>
+    </view>
 
     <!-- 底部提交按钮 -->
     <view class="yd-detail-footer">
@@ -67,12 +85,16 @@
 <script lang="ts" setup>
 import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
 import type { Leave } from '@/api/bpm/oa/leave'
-import { ref } from 'vue'
+import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
+import { computed, onMounted, ref, watch } from 'vue'
 import { useMessage, useToast } from 'wot-design-uni'
+import { getProcessDefinition } from '@/api/bpm/definition'
 import { createLeave } from '@/api/bpm/oa/leave'
+import { getApprovalDetail } from '@/api/bpm/processInstance'
 import { getIntDictOptions } from '@/hooks/useDict'
+import ProcessInstanceTimeline from '@/pages-bpm/processInstance/detail/components/time-line.vue'
 import { navigateBackPlus } from '@/utils'
-import { DICT_TYPE } from '@/utils/constants'
+import { BpmCandidateStrategyEnum, BpmNodeIdEnum, DICT_TYPE } from '@/utils/constants'
 
 definePage({
   style: {
@@ -84,6 +106,16 @@ definePage({
 const toast = useToast()
 const message = useMessage()
 const formLoading = ref(false)
+const processTimeLineLoading = ref(false) // 流程预览加载状态
+
+// 流程相关数据
+const processDefineKey = 'oa_leave' // 流程定义 Key
+const processDefinitionId = ref('')
+const activityNodes = ref<ApprovalNodeInfo[]>([]) // 审批节点信息
+const startUserSelectTasks = ref<any[]>([]) // 发起人需要选择审批人的用户任务列表
+const startUserSelectAssignees = ref<any>({}) // 发起人选择审批人的数据
+const tempStartUserSelectAssignees = ref<any>({}) // 临时保存的审批人数据
+
 const formData = ref<Partial<Leave>>({
   type: undefined,
   startTime: undefined,
@@ -98,6 +130,16 @@ const formRules = {
 }
 const formRef = ref<FormInstance>()
 
+// 计算请假天数
+const leaveDays = computed(() => {
+  if (!formData.value.startTime || !formData.value.endTime) {
+    return 0
+  }
+  const start = new Date(formData.value.startTime)
+  const end = new Date(formData.value.endTime)
+  return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
+})
+
 /** 返回上一页 */
 function handleBack() {
   message.confirm({
@@ -110,6 +152,58 @@ function handleBack() {
   })
 }
 
+/** 获取流程审批详情 */
+async function getProcessApprovalDetail() {
+  if (!processDefinitionId.value) {
+    return
+  }
+
+  processTimeLineLoading.value = true
+  try {
+    const data = await getApprovalDetail({
+      processDefinitionId: processDefinitionId.value,
+      activityId: BpmNodeIdEnum.START_USER_NODE_ID,
+      processVariablesStr: JSON.stringify({
+        day: leaveDays.value,
+      }),
+    })
+
+    if (!data) {
+      toast.show('查询不到审批详情信息!')
+      return
+    }
+
+    // 获取审批节点,显示 Timeline 的数据
+    activityNodes.value = data.activityNodes || []
+
+    // 获取发起人自选的任务
+    startUserSelectTasks.value = data.activityNodes?.filter(
+      (node: ApprovalNodeInfo) =>
+        BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
+    ) || []
+
+    // 恢复之前的选择审批人
+    if (startUserSelectTasks.value.length > 0) {
+      for (const node of startUserSelectTasks.value) {
+        startUserSelectAssignees.value[node.id]
+          = tempStartUserSelectAssignees.value[node.id]
+            && tempStartUserSelectAssignees.value[node.id].length > 0
+            ? tempStartUserSelectAssignees.value[node.id]
+            : []
+      }
+    }
+  } catch (error) {
+    console.error('获取流程审批详情失败:', error)
+  } finally {
+    processTimeLineLoading.value = false
+  }
+}
+
+/** 选择审批人确认 */
+function selectUserConfirm(id: string, userList: any[]) {
+  startUserSelectAssignees.value[id] = userList?.map((item: any) => item.id) || []
+}
+
 /** 提交表单 */
 async function handleSubmit() {
   const { valid } = await formRef.value!.validate()
@@ -121,9 +215,28 @@ async function handleSubmit() {
     return
   }
 
+  // 校验指定审批人
+  if (startUserSelectTasks.value.length > 0) {
+    for (const userTask of startUserSelectTasks.value) {
+      if (
+        Array.isArray(startUserSelectAssignees.value[userTask.id])
+        && startUserSelectAssignees.value[userTask.id].length === 0
+      ) {
+        toast.show(`请选择${userTask.name}的审批人`)
+        return
+      }
+    }
+  }
+
   formLoading.value = true
   try {
-    await createLeave(formData.value)
+    const submitData = { ...formData.value }
+    // 设置指定审批人
+    if (startUserSelectTasks.value.length > 0) {
+      submitData.startUserSelectAssignees = startUserSelectAssignees.value
+    }
+
+    await createLeave(submitData)
     uni.showToast({ title: '提交成功', icon: 'success' })
     setTimeout(() => {
       navigateBackPlus('/pages-bpm/oa/leave/index')
@@ -132,4 +245,41 @@ async function handleSubmit() {
     formLoading.value = false
   }
 }
+
+// 监听表单数据变化,重新预测流程节点
+watch(
+  () => [formData.value.startTime, formData.value.endTime, formData.value.type],
+  (newValue, oldValue) => {
+    if (!oldValue || !oldValue.some(v => v !== undefined)) {
+      return
+    }
+    if (newValue && newValue.some(v => v !== undefined)) {
+      // 记录之前的节点审批人
+      tempStartUserSelectAssignees.value = { ...startUserSelectAssignees.value }
+      startUserSelectAssignees.value = {}
+      // 加载最新的审批详情,主要用于节点预测
+      getProcessApprovalDetail()
+    }
+  },
+  { deep: true },
+)
+
+// 组件初始化
+onMounted(async () => {
+  try {
+    // 获取流程定义详情
+    const processDefinitionDetail = await getProcessDefinition(undefined, processDefineKey)
+    if (!processDefinitionDetail) {
+      toast.show('OA 请假的流程模型未配置,请检查!')
+      return
+    }
+    processDefinitionId.value = processDefinitionDetail.id
+
+    // 获取流程审批详情
+    await getProcessApprovalDetail()
+  } catch (error) {
+    console.error('初始化流程失败:', error)
+    toast.show('初始化流程失败,请稍后重试')
+  }
+})
 </script>

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

+ 394 - 0
src/pages-bpm/processInstance/detail/components/time-line.vue

@@ -0,0 +1,394 @@
+<template>
+  <!-- 遍历每个审批节点 -->
+  <view
+    v-for="(activity, index) in activityNodes"
+    :key="activity.id || index"
+    class="relative pb-24rpx pl-80rpx"
+  >
+    <!-- 时间线圆点 -->
+    <view
+      class="absolute left-12rpx top-8rpx h-52rpx w-52rpx flex items-center justify-center rounded-full bg-blue-500"
+    >
+      <!-- 节点类型图标 -->
+      <wd-icon
+        :name="getApprovalNodeTypeIcon(activity.nodeType)"
+        size="32rpx"
+        color="white"
+      />
+    </view>
+
+    <!-- 状态小图标 -->
+    <view
+      v-if="showStatusIcon"
+      class="absolute left-48rpx top-44rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
+      :style="{ backgroundColor: getApprovalNodeColor(activity.status) }"
+    >
+      <wd-icon
+        :name="getApprovalNodeIcon(activity.status, activity.nodeType)"
+        size="12rpx"
+        color="white"
+      />
+    </view>
+
+    <!-- 连接线 -->
+    <view
+      v-if="index < activityNodes.length - 1"
+      class="absolute bottom-0 left-38rpx top-64rpx w-2rpx bg-[#e5e5e5]"
+    />
+
+    <!-- 节点内容 -->
+    <view class="ml-8rpx">
+      <!-- 第一行:节点名称、时间 -->
+      <view class="mb-8rpx flex items-center justify-between">
+        <view class="flex items-center">
+          <text class="text-28rpx text-[#333] font-bold">{{ activity.name }}</text>
+          <text v-if="activity.status === BpmTaskStatusEnum.SKIP" class="ml-8rpx text-24rpx text-[#999]">
+            【跳过】
+          </text>
+        </view>
+        <text
+          v-if="activity.status !== BpmTaskStatusEnum.NOT_START && getApprovalNodeTime(activity)"
+          class="text-22rpx text-[#999]"
+        >
+          {{ getApprovalNodeTime(activity) }}
+        </text>
+      </view>
+
+      <!-- 子流程节点 -->
+      <view v-if="activity.nodeType === BpmNodeTypeEnum.CHILD_PROCESS_NODE" class="mb-16rpx">
+        <wd-button
+          type="primary"
+          plain
+          size="small"
+          :disabled="!activity.processInstanceId"
+          @click="handleChildProcess(activity)"
+        >
+          查看子流程
+        </wd-button>
+      </view>
+
+      <!-- 需要自定义选择审批人 -->
+      <view v-if="shouldShowCustomUserSelect(activity)" class="mb-16rpx">
+        <view class="flex flex-wrap items-center">
+          <!-- 添加用户按钮 -->
+          <UserPicker
+            :model-value="getSelectedUserIds(activity.id)"
+            type="checkbox"
+            use-default-slot
+            @confirm="(users) => handleCustomUserSelectConfirm(activity.id, users)"
+          >
+            <view
+              class="mb-8rpx mr-16rpx h-48rpx w-48rpx flex items-center justify-center border-indigo-500 rounded-lg border-solid"
+            >
+              <wd-icon name="user-add" size="32rpx" color="blue" />
+            </view>
+          </UserPicker>
+          <!-- 已选择的用户 -->
+          <view
+            v-for="(user, userIndex) in customApproveUsers[activity.id]"
+            :key="user.id || userIndex"
+            class="mb-8rpx mr-16rpx flex items-center rounded-32rpx bg-[#f5f5f5] pr-16rpx"
+          >
+            <view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
+              {{ user.nickname?.[0] || '?' }}
+            </view>
+            <text class="text-24rpx text-[#333]">{{ user.nickname }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 审批人员列表 -->
+      <view v-else class="mb-16rpx">
+        <!-- 情况一:遍历每个审批节点下的【进行中】task 任务 -->
+        <view v-if="activity.tasks && activity.tasks.length > 0">
+          <view
+            v-for="(task, taskIndex) in activity.tasks"
+            :key="taskIndex"
+            class="mb-16rpx"
+          >
+            <!-- 审批人信息 -->
+            <view v-if="task.assigneeUser || task.ownerUser" class="mb-8rpx flex items-center">
+              <!-- TODO @jason 用户头像显示 -->
+              <view class="relative mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
+                {{ (task.assigneeUser?.nickname || task.ownerUser?.nickname)?.[0] || '?' }}
+
+                <!-- 任务状态小图标 -->
+                <view
+                  v-if="showStatusIcon && shouldShowTaskStatusIcon(task.status)"
+                  class="absolute right--4rpx top-36rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
+                  :style="{ backgroundColor: getApprovalNodeColor(task.status) }"
+                >
+                  <wd-icon
+                    :name="getApprovalNodeIcon(task.status, activity.nodeType)"
+                    size="12rpx"
+                    color="white"
+                  />
+                </view>
+              </view>
+
+              <view class="flex-1">
+                <view class="flex items-center justify-between">
+                  <view class="flex items-center">
+                    <text class="text-26rpx text-[#333]">
+                      {{ task.assigneeUser?.nickname || task.ownerUser?.nickname }}
+                    </text>
+                    <text
+                      v-if="task.assigneeUser?.deptName || task.ownerUser?.deptName"
+                      class="ml-8rpx text-22rpx text-[#999]"
+                    >
+                      {{ task.assigneeUser?.deptName || task.ownerUser?.deptName }}
+                    </text>
+                  </view>
+                </view>
+                <view class="mt-4rpx flex items-center">
+                  <text :class="getStatusTextClass(task.status)" class="text-24rpx">
+                    {{ getStatusText(task.status) }}
+                  </text>
+                </view>
+              </view>
+            </view>
+
+            <!-- 审批意见 -->
+            <view
+              v-if="shouldShowApprovalReason(task, activity.nodeType)"
+              class="mt-8rpx rounded-8rpx bg-[#f5f5f5] p-16rpx"
+            >
+              <text class="text-24rpx text-[#666]">审批意见:{{ task.reason }}</text>
+            </view>
+
+            <!-- 签名 -->
+            <view
+              v-if="task.signPicUrl && activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE"
+              class="mt-8rpx flex items-center rounded-8rpx bg-[#f5f5f5] p-16rpx"
+            >
+              <text class="text-24rpx text-[#666]">签名:</text>
+              <image
+                :src="task.signPicUrl"
+                class="ml-8rpx h-96rpx w-288rpx"
+                mode="aspectFit"
+                @click="previewImage(task.signPicUrl)"
+              />
+            </view>
+          </view>
+        </view>
+
+        <!-- 情况二:遍历每个审批节点下的【候选的】task 任务 -->
+        <view v-if="activity.candidateUsers && activity.candidateUsers.length > 0">
+          <view
+            v-for="(user, userIndex) in activity.candidateUsers"
+            :key="userIndex"
+            class="mb-8rpx flex items-center"
+          >
+            <view class="relative mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
+              {{ user.nickname?.[0] || '?' }}
+
+              <!-- 候选状态图标 -->
+              <view
+                v-if="showStatusIcon"
+                class="absolute right--4rpx top-36rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
+                :style="{ backgroundColor: getApprovalNodeColor(BpmTaskStatusEnum.NOT_START) }"
+              >
+                <wd-icon name="time" size="12rpx" color="white" />
+              </view>
+            </view>
+
+            <view class="flex-1">
+              <text class="text-26rpx text-[#333]">{{ user.nickname }}</text>
+              <text v-if="user.deptName" class="ml-8rpx text-22rpx text-[#999]">
+                {{ user.deptName }}
+              </text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
+import { ref } from 'vue'
+import UserPicker from '@/components/system-select/user-picker.vue'
+import { BpmCandidateStrategyEnum, BpmNodeTypeEnum, BpmTaskStatusEnum } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = withDefaults(
+  defineProps<{
+    activityNodes: ApprovalNodeInfo[]
+    enableApproveUserSelect?: boolean
+    showStatusIcon?: boolean
+  }>(),
+  {
+    showStatusIcon: true,
+    enableApproveUserSelect: false,
+  },
+)
+
+const emit = defineEmits<{
+  selectUserConfirm: [activityId: string, userList: any[]]
+}>()
+
+// 状态图标映射
+const statusIconMap: Record<string, { color: string, icon: string }> = {
+  '-2': { color: '#909398', icon: 'skip-forward' }, // 跳过
+  '-1': { color: '#909398', icon: 'time' }, // 审批未开始
+  '0': { color: '#f59e0b', icon: 'refresh1' }, // 待审批
+  '1': { color: '#f59e0b', icon: 'refresh1' }, // 审批中
+  '2': { color: '#00b32a', icon: 'check' }, // 审批通过
+  '3': { color: '#f46b6c', icon: 'close' }, // 审批不通过
+  '4': { color: '#cccccc', icon: 'delete' }, // 已取消
+  '5': { color: '#f46b6c', icon: 'arrow-left' }, // 退回
+  '6': { color: '#448ef7', icon: 'time' }, // 委派中
+  '7': { color: '#00b32a', icon: 'check' }, // 审批通过中
+}
+
+// 节点类型图标映射 TODO 图标重新选一下
+const nodeTypeSvgMap: Record<number, { color: string, icon: string }> = {
+  [BpmNodeTypeEnum.END_EVENT_NODE]: { color: '#909398', icon: 'poweroff' },
+  [BpmNodeTypeEnum.START_USER_NODE]: { color: '#909398', icon: 'user' },
+  [BpmNodeTypeEnum.USER_TASK_NODE]: { color: '#ff943e', icon: 'user-talk' },
+  [BpmNodeTypeEnum.TRANSACTOR_NODE]: { color: '#ff943e', icon: 'edit' },
+  [BpmNodeTypeEnum.COPY_TASK_NODE]: { color: '#3296fb', icon: 'copy' },
+  [BpmNodeTypeEnum.CONDITION_NODE]: { color: '#14bb83', icon: 'branch' },
+  [BpmNodeTypeEnum.PARALLEL_BRANCH_NODE]: { color: '#14bb83', icon: 'branch' },
+  [BpmNodeTypeEnum.CHILD_PROCESS_NODE]: { color: '#14bb83', icon: 'cluster' },
+}
+
+const onlyStatusIconShow = [BpmTaskStatusEnum.NOT_START, BpmTaskStatusEnum.RUNNING, BpmTaskStatusEnum.WAIT] // 只有状态是 -1、0、1 才展示头像右小角状态小 icon
+
+// 响应式数据
+const customApproveUsers = ref<Record<string, any[]>>({})
+const showUserPicker = ref(false)
+const selectedUserIds = ref<number[]>([])
+const selectedActivityNodeId = ref<string>()
+
+/** 获取审批节点类型图标 */
+function getApprovalNodeTypeIcon(nodeType: number) {
+  return nodeTypeSvgMap[nodeType]?.icon || 'time'
+}
+
+/** 获取审批节点图标 */
+function getApprovalNodeIcon(taskStatus: number, nodeType: number) {
+  if (taskStatus === BpmTaskStatusEnum.NOT_START) {
+    return statusIconMap[taskStatus]?.icon || 'time'
+  }
+  return statusIconMap[taskStatus]?.icon || 'time'
+}
+
+/** 获取审批节点颜色 */
+function getApprovalNodeColor(taskStatus: number) {
+  return statusIconMap[taskStatus]?.color || '#909398'
+}
+
+/** 获取审批节点时间 */
+function getApprovalNodeTime(node: ApprovalNodeInfo) {
+  if (node.nodeType === BpmNodeTypeEnum.START_USER_NODE && node.startTime) {
+    return formatDateTime(node.startTime)
+  }
+  if (node.endTime) {
+    return formatDateTime(node.endTime)
+  }
+  if (node.startTime) {
+    return formatDateTime(node.startTime)
+  }
+  return ''
+}
+
+/** 是否显示任务状态图标 */
+function shouldShowTaskStatusIcon(status: number) {
+  return onlyStatusIconShow.includes(status)
+}
+
+/** 判断是否需要显示自定义选择审批人 */
+function shouldShowCustomUserSelect(activity: ApprovalNodeInfo) {
+  return (
+    (!activity.tasks || activity.tasks.length === 0)
+    && ((BpmCandidateStrategyEnum.START_USER_SELECT === activity.candidateStrategy
+      && (!activity.candidateUsers || activity.candidateUsers.length === 0))
+    || (props.enableApproveUserSelect
+      && BpmCandidateStrategyEnum.APPROVE_USER_SELECT === activity.candidateStrategy))
+  )
+}
+
+/** 判断是否需要显示审批意见 */
+function shouldShowApprovalReason(task: any, nodeType: number) {
+  return (
+    task.reason
+    && [BpmNodeTypeEnum.END_EVENT_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(nodeType)
+  )
+}
+
+/** 获取状态文本样式类 */
+function getStatusTextClass(status: number) {
+  const colorMap: Record<number, string> = {
+    [BpmTaskStatusEnum.RUNNING]: 'text-[#ff943e]',
+    [BpmTaskStatusEnum.APPROVE]: 'text-[#00b32a]',
+    [BpmTaskStatusEnum.REJECT]: 'text-[#f46b6c]',
+    [BpmTaskStatusEnum.CANCEL]: 'text-[#cccccc]',
+    [BpmTaskStatusEnum.RETURN]: 'text-[#f46b6c]',
+  }
+  return colorMap[status] || 'text-[#666]'
+}
+
+/** 获取状态文本 */
+function getStatusText(status: number) {
+  const textMap: Record<number, string> = {
+    [BpmTaskStatusEnum.NOT_START]: '未开始',
+    [BpmTaskStatusEnum.RUNNING]: '待审批',
+    [BpmTaskStatusEnum.APPROVE]: '已通过',
+    [BpmTaskStatusEnum.REJECT]: '已拒绝',
+    [BpmTaskStatusEnum.CANCEL]: '已取消',
+    [BpmTaskStatusEnum.RETURN]: '已退回',
+    [BpmTaskStatusEnum.SKIP]: '已跳过',
+  }
+  return textMap[status] || '未知'
+}
+
+/** 用户选择确认 */
+function handleCustomUserSelectConfirm(activityId: string, users: any[]) {
+  customApproveUsers.value[activityId] = users || []
+  emit('selectUserConfirm', activityId, users)
+}
+
+/** 获取选中的用户ID数组 */
+function getSelectedUserIds(activityId: string): number[] {
+  const users = customApproveUsers.value[activityId] || []
+  return users.map(user => user.id).filter(id => id !== undefined)
+}
+
+/** 跳转子流程 */
+function handleChildProcess(activity: ApprovalNodeInfo) {
+  if (!activity.processInstanceId) {
+    return
+  }
+  uni.navigateTo({
+    url: `/pages-bpm/processInstance/detail/index?id=${activity.processInstanceId}`,
+  })
+}
+
+/** 预览图片 */
+function previewImage(url: string) {
+  uni.previewImage({
+    urls: [url],
+    current: url,
+  })
+}
+
+/** 设置自定义审批人 */
+function setCustomApproveUsers(activityId: string, users: any[]) {
+  customApproveUsers.value[activityId] = users || []
+}
+
+/** 批量设置多个节点的自定义审批人 */
+function batchSetCustomApproveUsers(data: Record<string, any[]>) {
+  Object.keys(data).forEach((activityId) => {
+    customApproveUsers.value[activityId] = data[activityId] || []
+  })
+}
+
+// 暴露方法给父组件
+defineExpose({
+  setCustomApproveUsers,
+  batchSetCustomApproveUsers,
+})
+</script>

+ 37 - 145
src/pages-bpm/processInstance/detail/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <view class="yd-page-container">
+  <view class="yd-page-container pb-[80rpx]">
     <!-- 顶部导航栏 -->
     <wd-navbar
       title="审批详情"
@@ -8,14 +8,18 @@
     />
 
     <!-- 区域:流程信息(基本信息) -->
-    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+    <view class="relative mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+      <!-- 审批状态图标(盖章效果) -->
+      <image
+        v-if="processInstance?.status !== undefined"
+        :src="getStatusIcon(processInstance?.status)"
+        class="absolute right-20rpx top-20rpx z-10 h-144rpx w-144rpx"
+        mode="aspectFit"
+      />
       <view class="p-24rpx">
-        <!-- 标题和状态 -->
-        <view class="mb-16rpx flex items-center justify-between">
+        <!-- 标题 -->
+        <view class="mb-16rpx pr-160rpx">
           <text class="text-32rpx text-[#333] font-bold">{{ processInstance?.name }}</text>
-          <wd-tag :type="getStatusType(processInstance?.status)">
-            {{ getStatusText(processInstance?.status) }}
-          </wd-tag>
         </view>
         <!-- 发起人信息 -->
         <view class="flex items-center">
@@ -39,71 +43,37 @@
     <!-- 区域:审批详情(表单) -->
     <FormDetail :process-definition="processDefinition" :process-instance="processInstance" />
 
-    <!-- 区域:审批记录 TODO @jason:抽成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/processInstance/detail/modules/task-list.vue -->
-    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+    <!-- 区域:审批进度 -->
+    <view class="mx-24rpx mt-24rpx rounded-16rpx bg-white">
       <view class="p-24rpx">
-        <view class="mb-16rpx flex items-center justify-between">
-          <text class="text-28rpx text-[#333] font-bold">审批记录</text>
-          <!-- TODO @AI:去掉 orderAsc,不要 toggleOrder -->
-          <wd-icon :name="orderAsc ? 'arrow-up' : 'arrow-down'" size="32rpx" @click="toggleOrder" />
-        </view>
-        <!-- 任务列表 -->
-        <view v-for="(task, index) in sortedTasks" :key="task.id || index" class="relative pb-24rpx pl-40rpx">
-          <!-- 时间线 -->
-          <view
-            class="absolute left-12rpx top-8rpx h-16rpx w-16rpx rounded-full"
-            :class="getTaskDotClass(task)"
-          />
-          <view v-if="index < sortedTasks.length - 1" class="absolute bottom-0 left-18rpx top-28rpx w-2rpx bg-[#e5e5e5]" />
-          <!-- 任务内容 -->
-          <view>
-            <text class="text-28rpx text-[#333] font-bold">{{ task.name }}</text>
-            <view v-if="task.assigneeUser" class="mt-8rpx flex items-center">
-              <view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
-                {{ task.assigneeUser?.nickname?.[0] || '?' }}
-              </view>
-              <view class="flex-1">
-                <view class="flex items-center justify-between">
-                  <view class="flex items-center">
-                    <text class="text-26rpx text-[#333]">{{ task.assigneeUser?.nickname }}</text>
-                    <text v-if="task.assigneeUser?.deptName" class="ml-8rpx text-22rpx text-[#999]">
-                      {{ task.assigneeUser?.deptName }}
-                    </text>
-                  </view>
-                  <text v-if="task.endTime" class="text-22rpx text-[#999]">{{ formatPast(task.endTime) }}</text>
-                </view>
-                <view class="mt-4rpx flex items-center">
-                  <text :class="getStatusTextClass(task.status)" class="text-24rpx">
-                    {{ getStatusText(task.status) }}
-                  </text>
-                  <text v-if="task.reason" class="ml-8rpx text-24rpx text-[#666]">{{ task.reason }}</text>
-                </view>
-              </view>
-            </view>
-          </view>
+        <view class="mb-16rpx flex">
+          <text class="text-28rpx text-[#333] font-bold">审批进度</text>
         </view>
+        <!-- 流程时间线 -->
+        <ProcessInstanceTimeline :activity-nodes="activityNodes" />
       </view>
     </view>
 
     <!-- TODO 待开发:区域:流程评论 -->
 
     <!-- 区域:底部操作栏 -->
-    <ProcessInstanceOperationButton ref="operationButtonRef" :process-instance="processInstance" />
+    <ProcessInstanceOperationButton ref="operationButtonRef" />
   </view>
 </template>
 
 <script lang="ts" setup>
-import type { ProcessDefinition, ProcessInstance } from '@/api/bpm/processInstance'
+import type { ApprovalNodeInfo, ProcessDefinition, ProcessInstance } from '@/api/bpm/processInstance'
 import type { Task } from '@/api/bpm/task'
-import { computed, onMounted, ref } from 'vue'
+import { onMounted, ref } from 'vue'
 import { useToast } from 'wot-design-uni'
 import { getApprovalDetail } from '@/api/bpm/processInstance'
 import { getTaskListByProcessInstanceId } from '@/api/bpm/task'
-import { useUserStore } from '@/store'
 import { navigateBackPlus } from '@/utils'
-import { formatDateTime, formatPast } from '@/utils/date'
+import { BpmProcessInstanceStatus } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
 import FormDetail from './components/form-detail.vue'
 import ProcessInstanceOperationButton from './components/operation-button.vue'
+import ProcessInstanceTimeline from './components/time-line.vue'
 
 const props = defineProps<{
   id: string // 流程实例的编号
@@ -121,107 +91,26 @@ const toast = useToast()
 const processInstance = ref<ProcessInstance>()
 const processDefinition = ref<ProcessDefinition>()
 const tasks = ref<Task[]>([])
-const orderAsc = ref(true)
 
-const operationButtonRef = ref() // 操作按钮组件 ref
+const activityNodes = ref<ApprovalNodeInfo[]>([]) // 审批节点信息
 
-/** 排序后的任务列表 */
-const sortedTasks = computed(() => {
-  const list = [...tasks.value].filter(t => t.status !== 4) // 过滤已取消
-  // TODO @jason:这里有红色报错,看看 fix 下哇?或者这块排序逻辑去掉,貌似没啥用。
-  list.sort((a, b) => {
-    if (a.endTime && b.endTime) {
-      return orderAsc.value ? a.endTime - b.endTime : b.endTime - a.endTime
-    }
-    if (a.endTime) {
-      return orderAsc.value ? -1 : 1
-    }
-    if (b.endTime) {
-      return orderAsc.value ? 1 : -1
-    }
-    return orderAsc.value ? a.createTime - b.createTime : b.createTime - a.createTime
-  })
-  return list
-})
+const operationButtonRef = ref() // 操作按钮组件 ref
 
 /** 返回上一页 */
 function handleBack() {
   navigateBackPlus('/pages/bpm/index')
 }
 
-/** 切换排序 */
-function toggleOrder() {
-  orderAsc.value = !orderAsc.value
-}
-
-/** 获取状态文本 */
-// TODO @jason:要有标签,和 vben 一样,盖章
-// TODO @jason:通过字典
-function getStatusText(status?: number) {
-  const map: Record<number, string> = {
-    0: '待审批',
-    1: '审批中',
-    2: '审批通过',
-    3: '审批不通过',
-    4: '已取消',
-    5: '已退回',
-    6: '委派中',
-    7: '审批通过中',
-  }
-  return map[status ?? 0] || '待审批'
-}
-
-/** 获取状态标签类型 */
-function getStatusType(status?: number): 'default' | 'primary' | 'success' | 'warning' | 'danger' {
-  if ([1, 6, 7].includes(status ?? 0)) {
-    return 'primary'
-  }
-  if (status === 2) {
-    return 'success'
-  }
-  if (status === 3) {
-    return 'danger'
-  }
-  if (status === 4 || status === 5) {
-    return 'warning'
-  }
-  return 'default'
-}
-
-/** 获取任务圆点样式 */
-// TODO @jason:看看又要对齐 vben
-function getTaskDotClass(task: Task) {
-  if ([1, 6, 7].includes(task.status)) {
-    return 'bg-[#1890ff]'
-  }
-  if (task.status === 2) {
-    return 'bg-[#52c41a]'
-  }
-  if (task.status === 3) {
-    return 'bg-[#ff4d4f]'
-  }
-  if (task.status === 5) {
-    return 'bg-[#faad14]'
-  }
-  return 'bg-[#d9d9d9]'
-}
-
-/** 获取状态文本样式 */
-// TODO @jason:看看又要对齐 vben
-function getStatusTextClass(status: number) {
-  if ([1, 6, 7].includes(status)) {
-    return 'text-[#1890ff]'
-  }
-  if (status === 2) {
-    return 'text-[#52c41a]'
+/** 获取状态图标 */
+function getStatusIcon(status?: number): string {
+  // 状态映射: 1-审批中, 2-审批通过, 3-审批不通过, 4-已取消. -1 未开始不会出现
+  const iconMap: Record<number, string> = {
+    [BpmProcessInstanceStatus.RUNNING]: '/static/my-icons/bpm/bpm-running.svg', // 待审批
+    [BpmProcessInstanceStatus.APPROVE]: '/static/my-icons/bpm/bpm-approve.svg', // 审批通过
+    [BpmProcessInstanceStatus.REJECT]: '/static/my-icons/bpm/bpm-reject.svg', // 审批不通过
+    [BpmProcessInstanceStatus.CANCEL]: '/static/my-icons/bpm/bpm-cancel.svg', // 已取消
   }
-  if (status === 3) {
-    return 'text-[#ff4d4f]'
-  }
-  if (status === 5) {
-    return 'text-[#faad14]'
-  }
-  return 'text-[#999]'
+  return iconMap[status ?? 1]
 }
 
 /** 加载流程实例 */
@@ -236,6 +125,9 @@ async function loadProcessInstance() {
   }
   processInstance.value = data.processInstance
   processDefinition.value = data.processDefinition
+  // 获取审批节点,显示 Timeline 的数据
+  activityNodes.value = data.activityNodes
+
   operationButtonRef.value?.init(data.processInstance, data.todoTask)
 }
 

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

File diff suppressed because it is too large
+ 1 - 0
src/static/my-icons/bpm/bpm-approve.svg


File diff suppressed because it is too large
+ 1 - 0
src/static/my-icons/bpm/bpm-cancel.svg


File diff suppressed because it is too large
+ 1 - 0
src/static/my-icons/bpm/bpm-reject.svg


File diff suppressed because it is too large
+ 1 - 0
src/static/my-icons/bpm/bpm-running.svg