feat(bpm): 支持审批任务附件上传与展示

- 审批通过、审批拒绝弹窗新增附件/图片上传
- 审批提交时携带 attachments 字段
- 审批完成或关闭弹窗后清理附件表单状态
- 审批流时间线支持展示审批附件
- 图片附件支持预览,非图片附件支持链接打开
- 统一附件上传目录、文件类型白名单和 5MB 大小限制
- ApprovalTaskInfo 增加 attachments 字段
master
YunaiV 2026-06-14 02:48:10 +08:00
parent 61c71b9a0e
commit 44136d310b
3 changed files with 105 additions and 7 deletions

View File

@ -36,6 +36,7 @@ export type ApprovalTaskInfo = {
assigneeUser: User
status: number
reason: string
attachments?: string[]
signPicUrl: string
}

View File

@ -44,6 +44,16 @@
:rows="4"
/>
</el-form-item>
<el-form-item label="上传附件/图片" prop="attachments">
<UploadFile
v-model="approveReasonForm.attachments"
:limit="10"
:file-type="APPROVAL_ATTACHMENT_FILE_TYPES"
:file-size="APPROVAL_ATTACHMENT_FILE_SIZE"
directory="bpm/task-attachment"
:is-show-tip="false"
/>
</el-form-item>
<el-form-item
label="下一个节点的审批人"
prop="nextAssignees"
@ -118,6 +128,16 @@
:rows="4"
/>
</el-form-item>
<el-form-item label="上传附件/图片" prop="attachments">
<UploadFile
v-model="rejectReasonForm.attachments"
:limit="10"
:file-type="APPROVAL_ATTACHMENT_FILE_TYPES"
:file-size="APPROVAL_ATTACHMENT_FILE_SIZE"
directory="bpm/task-attachment"
:is-show-tip="false"
/>
</el-form-item>
<el-form-item>
<el-button
:disabled="formLoading"
@ -530,6 +550,7 @@ import { until, useDebounceFn } from '@vueuse/core'
import SignDialog from './SignDialog.vue'
import ProcessInstanceTimeline from '../detail/ProcessInstanceTimeline.vue'
import { isEmpty } from '@/utils/is'
import { UploadFile } from '@/components/UploadFile'
defineOptions({ name: 'ProcessInstanceBtnContainer' })
@ -561,6 +582,23 @@ const popOverVisible = ref({
deleteSign: false
}) //
const returnList = ref([] as any) // 退
const APPROVAL_ATTACHMENT_FILE_TYPES = [
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'pdf',
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp'
]
const APPROVAL_ATTACHMENT_FILE_SIZE = 5
// ========== ==========
const runningTask = ref<any>() //
@ -580,6 +618,7 @@ let pendingNextNodesTask: Promise<unknown> | null = null // 跟踪 onChange 触
const approveReasonForm = reactive({
reason: '',
signPicUrl: '',
attachments: [] as string[],
nextAssignees: {}
})
const approveReasonRule = computed(() => {
@ -599,7 +638,8 @@ const approveReasonRule = computed(() => {
//
const rejectFormRef = ref<FormInstance>()
const rejectReasonForm = reactive({
reason: ''
reason: '',
attachments: [] as string[]
})
const rejectReasonRule = computed(() => {
return {
@ -736,6 +776,12 @@ const closePopover = (type: string, formRef: FormInstance | undefined) => {
if (formRef) {
formRef.resetFields()
}
if (type === 'approve') {
approveReasonForm.attachments = []
}
if (type === 'reject') {
rejectReasonForm.attachments = []
}
popOverVisible.value[type] = false
nextAssigneesActivityNode.value = []
// Timeline
@ -837,6 +883,7 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
const data = {
id: runningTask.value.id,
reason: approveReasonForm.reason,
attachments: approveReasonForm.attachments,
variables, // ,
nextAssignees: approveReasonForm.nextAssignees //
} as any
@ -861,7 +908,8 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
//
const data = {
id: runningTask.value.id,
reason: rejectReasonForm.reason
reason: rejectReasonForm.reason,
attachments: rejectReasonForm.attachments
}
await TaskApi.rejectTask(data)
popOverVisible.value.reject = false
@ -869,6 +917,8 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
}
//
formRef.resetFields()
approveReasonForm.attachments = []
rejectReasonForm.attachments = []
//
reload()
} finally {

View File

@ -129,14 +129,33 @@
</div>
<teleport defer :to="`#activity-task-${activity.id}-${index}`">
<div
v-if="
task.reason &&
[NodeType.USER_TASK_NODE, NodeType.END_EVENT_NODE].includes(activity.nodeType)
"
v-if="shouldShowReasonAndAttachment(task, activity.nodeType)"
class="text-#a5a5a5 text-13px mt-1 w-full bg-#f8f8fa p2 rounded-md"
>
<!-- TODO lesan这里如果是办理需要是办理意见 -->
审批意见{{ task.reason }}
<div v-if="task.reason">{{ task.reason }}</div>
<div v-if="task.attachments?.length" class="mt-2 flex flex-wrap gap-2">
<template v-for="attachment in task.attachments" :key="attachment">
<el-image
v-if="isImageAttachment(attachment)"
class="h-40px w-40px rounded"
:src="attachment"
:preview-src-list="[attachment]"
fit="cover"
preview-teleported
/>
<el-link
v-else
:href="attachment"
:underline="false"
target="_blank"
type="primary"
>
<Icon class="mr-1" icon="ep:document" />
{{ getAttachmentName(attachment) }}
</el-link>
</template>
</div>
</div>
<div
v-if="task.signPicUrl && activity.nodeType === NodeType.USER_TASK_NODE"
@ -316,6 +335,34 @@ const getApprovalNodeTime = (node: ProcessInstanceApi.ApprovalNodeInfo) => {
}
}
/** 是否展示审批意见和附件 */
const shouldShowReasonAndAttachment = (
task: ProcessInstanceApi.ApprovalTaskInfo,
nodeType: NodeType
) => {
return (
Boolean(task.reason || task.attachments?.length) &&
[NodeType.START_USER_NODE, NodeType.USER_TASK_NODE, NodeType.END_EVENT_NODE].includes(nodeType)
)
}
/** 获取附件名 */
const getAttachmentName = (url: string) => {
const cleanUrl = url.split(/[?#]/)[0]
const fileName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1)
try {
return decodeURIComponent(fileName)
} catch {
return fileName
}
}
/** 是否图片附件 */
const isImageAttachment = (url: string) => {
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase()
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '')
}
//
const userSelectFormRef = ref()
const handleSelectUser = (activityId, selectedList) => {