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

- 审批通过、审批拒绝弹窗新增附件/图片上传
- 审批提交时携带 attachments 字段
- 审批完成或关闭弹窗后清理附件表单状态
- 审批流时间线支持展示审批附件
- 图片附件支持预览,非图片附件支持链接打开
- 统一附件上传目录、文件类型白名单和 5MB 大小限制
- ApprovalTaskInfo 增加 attachments 字段
pull/884/head
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 assigneeUser: User
status: number status: number
reason: string reason: string
attachments?: string[]
signPicUrl: string signPicUrl: string
} }

View File

@ -44,6 +44,16 @@
:rows="4" :rows="4"
/> />
</el-form-item> </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 <el-form-item
label="下一个节点的审批人" label="下一个节点的审批人"
prop="nextAssignees" prop="nextAssignees"
@ -118,6 +128,16 @@
:rows="4" :rows="4"
/> />
</el-form-item> </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-form-item>
<el-button <el-button
:disabled="formLoading" :disabled="formLoading"
@ -530,6 +550,7 @@ import { until, useDebounceFn } from '@vueuse/core'
import SignDialog from './SignDialog.vue' import SignDialog from './SignDialog.vue'
import ProcessInstanceTimeline from '../detail/ProcessInstanceTimeline.vue' import ProcessInstanceTimeline from '../detail/ProcessInstanceTimeline.vue'
import { isEmpty } from '@/utils/is' import { isEmpty } from '@/utils/is'
import { UploadFile } from '@/components/UploadFile'
defineOptions({ name: 'ProcessInstanceBtnContainer' }) defineOptions({ name: 'ProcessInstanceBtnContainer' })
@ -561,6 +582,23 @@ const popOverVisible = ref({
deleteSign: false deleteSign: false
}) // }) //
const returnList = ref([] as any) // 退 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>() // const runningTask = ref<any>() //
@ -580,6 +618,7 @@ let pendingNextNodesTask: Promise<unknown> | null = null // 跟踪 onChange 触
const approveReasonForm = reactive({ const approveReasonForm = reactive({
reason: '', reason: '',
signPicUrl: '', signPicUrl: '',
attachments: [] as string[],
nextAssignees: {} nextAssignees: {}
}) })
const approveReasonRule = computed(() => { const approveReasonRule = computed(() => {
@ -599,7 +638,8 @@ const approveReasonRule = computed(() => {
// //
const rejectFormRef = ref<FormInstance>() const rejectFormRef = ref<FormInstance>()
const rejectReasonForm = reactive({ const rejectReasonForm = reactive({
reason: '' reason: '',
attachments: [] as string[]
}) })
const rejectReasonRule = computed(() => { const rejectReasonRule = computed(() => {
return { return {
@ -736,6 +776,12 @@ const closePopover = (type: string, formRef: FormInstance | undefined) => {
if (formRef) { if (formRef) {
formRef.resetFields() formRef.resetFields()
} }
if (type === 'approve') {
approveReasonForm.attachments = []
}
if (type === 'reject') {
rejectReasonForm.attachments = []
}
popOverVisible.value[type] = false popOverVisible.value[type] = false
nextAssigneesActivityNode.value = [] nextAssigneesActivityNode.value = []
// Timeline // Timeline
@ -837,6 +883,7 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
const data = { const data = {
id: runningTask.value.id, id: runningTask.value.id,
reason: approveReasonForm.reason, reason: approveReasonForm.reason,
attachments: approveReasonForm.attachments,
variables, // , variables, // ,
nextAssignees: approveReasonForm.nextAssignees // nextAssignees: approveReasonForm.nextAssignees //
} as any } as any
@ -861,7 +908,8 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
// //
const data = { const data = {
id: runningTask.value.id, id: runningTask.value.id,
reason: rejectReasonForm.reason reason: rejectReasonForm.reason,
attachments: rejectReasonForm.attachments
} }
await TaskApi.rejectTask(data) await TaskApi.rejectTask(data)
popOverVisible.value.reject = false popOverVisible.value.reject = false
@ -869,6 +917,8 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
} }
// //
formRef.resetFields() formRef.resetFields()
approveReasonForm.attachments = []
rejectReasonForm.attachments = []
// //
reload() reload()
} finally { } finally {

View File

@ -129,14 +129,33 @@
</div> </div>
<teleport defer :to="`#activity-task-${activity.id}-${index}`"> <teleport defer :to="`#activity-task-${activity.id}-${index}`">
<div <div
v-if=" v-if="shouldShowReasonAndAttachment(task, activity.nodeType)"
task.reason &&
[NodeType.USER_TASK_NODE, NodeType.END_EVENT_NODE].includes(activity.nodeType)
"
class="text-#a5a5a5 text-13px mt-1 w-full bg-#f8f8fa p2 rounded-md" class="text-#a5a5a5 text-13px mt-1 w-full bg-#f8f8fa p2 rounded-md"
> >
<!-- TODO lesan这里如果是办理需要是办理意见 --> <!-- 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>
<div <div
v-if="task.signPicUrl && activity.nodeType === NodeType.USER_TASK_NODE" 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 userSelectFormRef = ref()
const handleSelectUser = (activityId, selectedList) => { const handleSelectUser = (activityId, selectedList) => {