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

- web-antd、web-ele 审批通过和拒绝弹窗补齐附件上传约束
  - 限制支持常用文档和图片格式
  - 限制单文件最大 5MB、最多上传 10 个
  - 统一上传目录为 bpm/task-attachment
  - 开启上传说明展示
- web-antdv-next 补齐 BPM 审批附件完整能力
  - ApprovalTaskInfo 增加 attachments 字段
  - 审批通过、审批拒绝表单支持上传附件
  - 提交 approveTask/rejectTask 时携带 attachments
  - 弹窗关闭或提交成功后重置附件表单数据
  - 支持图片附件预览,非图片附件新窗口打开
- 三端时间线支持展示审批附件
  - 审批意见和附件统一展示在任务节点下
  - 图片附件展示缩略图并支持预览
  - 普通附件展示文件名并支持点击打开
  - 兼容带 query/hash 的附件 URL 文件名解析和图片类型识别
pull/367/head
YunaiV 2026-06-14 02:48:33 +08:00
parent 2a45d2d324
commit 7222e320e2
7 changed files with 250 additions and 14 deletions

View File

@ -95,6 +95,24 @@ const popOverVisible: any = 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 APPROVAL_ATTACHMENT_DIRECTORY = 'bpm/task-attachment';
/** 创建流程表达式 */
function openSignatureModal() {
@ -772,7 +790,9 @@ const imagePreviewUrl = ref('');
/** 判断文件是否为图片类型 */
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url);
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(
url.split(/[?#]/)[0] || '',
);
}
/** 处理文件预览 */
@ -892,8 +912,12 @@ defineExpose({ loadTodoTask });
<FormItem label="上传附件/图片" name="attachments">
<FileUpload
v-model:value="approveReasonForm.attachments"
:accept="APPROVAL_ATTACHMENT_FILE_TYPES"
:directory="APPROVAL_ATTACHMENT_DIRECTORY"
:max-number="10"
:max-size="APPROVAL_ATTACHMENT_FILE_SIZE"
:multiple="true"
:show-description="true"
:show-download-icon="false"
help-text="支持多文件/图片上传"
@preview="handleFilePreview"
@ -959,8 +983,12 @@ defineExpose({ loadTodoTask });
<FormItem label="上传附件/图片" name="attachments">
<FileUpload
v-model:value="rejectReasonForm.attachments"
:accept="APPROVAL_ATTACHMENT_FILE_TYPES"
:directory="APPROVAL_ATTACHMENT_DIRECTORY"
:max-number="10"
:max-size="APPROVAL_ATTACHMENT_FILE_SIZE"
:multiple="true"
:show-description="true"
help-text="支持多文件/图片上传"
@preview="handleFilePreview"
/>

View File

@ -197,7 +197,7 @@ function shouldShowCustomUserSelect(
/** 判断是否需要显示审批意见和附件 */
function shouldShowReasonAndAttachment(task: any, nodeType: BpmNodeTypeEnum) {
return (
(task.reason || task.attachments?.length > 0) &&
Boolean(task.reason || task.attachments?.length > 0) &&
[BpmNodeTypeEnum.START_USER_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
nodeType,
)
@ -205,11 +205,17 @@ function shouldShowReasonAndAttachment(task: any, nodeType: BpmNodeTypeEnum) {
}
function getAttachmentName(url: string) {
return decodeURIComponent(url.slice(url.lastIndexOf('/') + 1));
const cleanUrl = url.split(/[?#]/)[0] || '';
const fileName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1);
try {
return decodeURIComponent(fileName);
} catch {
return fileName;
}
}
function isImageAttachment(url: string) {
const ext = url.split('.').pop()?.toLowerCase();
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase();
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '');
}

View File

@ -79,6 +79,7 @@ export namespace BpmProcessInstanceApi {
export interface ApprovalTaskInfo {
id: number;
assigneeUser: User;
attachments?: string[];
ownerUser: User;
reason: string;
signPicUrl: string;

View File

@ -55,6 +55,7 @@ import {
transferTask,
} from '#/api/bpm/task';
import { setConfAndFields2 } from '#/components/form-create';
import { FileUpload } from '#/components/upload';
import { $t } from '#/locales';
import Signature from './signature.vue';
@ -94,6 +95,24 @@ const popOverVisible: any = 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 APPROVAL_ATTACHMENT_DIRECTORY = 'bpm/task-attachment';
/** 创建流程表达式 */
function openSignatureModal() {
@ -120,6 +139,7 @@ const approveReasonForm: any = reactive({
reason: '',
signPicUrl: '',
nextAssignees: {},
attachments: [],
});
const approveReasonRule: Record<string, any> = computed(() => {
return {
@ -142,6 +162,7 @@ const approveReasonRule: Record<string, any> = computed(() => {
const rejectFormRef = ref<FormInstance>();
const rejectReasonForm = reactive({
reason: '',
attachments: [],
}); //
const rejectReasonRule: any = computed(() => {
return {
@ -290,6 +311,14 @@ function closePopover(type: string, formRef: any | FormInstance) {
if (formRef) {
formRef.resetFields();
}
if (type === 'approve') {
approveReasonForm.reason = '';
approveReasonForm.attachments = [];
approveReasonForm.signPicUrl = '';
} else if (type === 'reject') {
rejectReasonForm.reason = '';
rejectReasonForm.attachments = [];
}
if (popOverVisible.value[type]) popOverVisible.value[type] = false;
nextAssigneesActivityNode.value = [];
// Timeline
@ -401,6 +430,7 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
const data = {
id: runningTask.value.id,
reason: approveReasonForm.reason,
attachments: approveReasonForm.attachments,
variables, // ,
nextAssignees: approveReasonForm.nextAssignees, //
} as any;
@ -414,6 +444,9 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
await formCreateApi.validate();
}
await approveTask(data);
approveReasonForm.reason = '';
approveReasonForm.attachments = [];
approveReasonForm.signPicUrl = '';
popOverVisible.value.approve = false;
nextAssigneesActivityNode.value = [];
// Timeline
@ -426,8 +459,11 @@ async function handleAudit(pass: boolean, formRef: FormInstance | undefined) {
const data = {
id: runningTask.value.id,
reason: rejectReasonForm.reason,
attachments: rejectReasonForm.attachments,
};
await rejectTask(data);
rejectReasonForm.reason = '';
rejectReasonForm.attachments = [];
popOverVisible.value.reject = false;
message.success('审批不通过成功');
}
@ -748,6 +784,36 @@ function handleSignFinish(url: string) {
approveFormRef.value?.validateFields(['signPicUrl']);
}
/** 附件图片预览 */
const imagePreviewVisible = ref(false);
const imagePreviewUrl = ref('');
/** 判断文件是否为图片类型 */
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(
url.split(/[?#]/)[0] || '',
);
}
/** 处理文件预览 */
function handleFilePreview(file: any) {
if (!file?.url && !file?.response) {
message.warning('文件地址不存在,无法预览');
return;
}
const url = file.url || file?.response?.url || file?.response;
if (!url) {
message.warning('文件地址不存在,无法预览');
return;
}
if (isImageUrl(url)) {
imagePreviewUrl.value = url;
imagePreviewVisible.value = true;
} else {
window.open(url, '_blank');
}
}
/** 处理弹窗可见性 */
function handlePopoverVisible(visible: boolean) {
if (!visible) {
@ -843,6 +909,19 @@ defineExpose({ loadTodoTask });
:rows="4"
/>
</FormItem>
<FormItem label="上传附件/图片" name="attachments">
<FileUpload
v-model:value="approveReasonForm.attachments"
:accept="APPROVAL_ATTACHMENT_FILE_TYPES"
:directory="APPROVAL_ATTACHMENT_DIRECTORY"
:max-number="10"
:max-size="APPROVAL_ATTACHMENT_FILE_SIZE"
:multiple="true"
:show-description="true"
help-text="支持多文件/图片上传"
@preview="handleFilePreview"
/>
</FormItem>
<FormItem>
<Space>
<Button
@ -900,6 +979,19 @@ defineExpose({ loadTodoTask });
:rows="4"
/>
</FormItem>
<FormItem label="上传附件/图片" name="attachments">
<FileUpload
v-model:value="rejectReasonForm.attachments"
:accept="APPROVAL_ATTACHMENT_FILE_TYPES"
:directory="APPROVAL_ATTACHMENT_DIRECTORY"
:max-number="10"
:max-size="APPROVAL_ATTACHMENT_FILE_SIZE"
:multiple="true"
:show-description="true"
help-text="支持多文件/图片上传"
@preview="handleFilePreview"
/>
</FormItem>
<FormItem>
<Button
:disabled="formLoading"
@ -1444,4 +1536,17 @@ defineExpose({ loadTodoTask });
<!-- 签名弹窗 -->
<SignatureModal @success="handleSignFinish" />
<!-- 图片预览隐藏的 Image 组件仅用于附件预览弹窗 -->
<div style="display: none">
<Image
:preview="{
visible: imagePreviewVisible,
onVisibleChange: (visible: boolean) => {
imagePreviewVisible = visible;
},
}"
:src="imagePreviewUrl"
/>
</div>
</template>

View File

@ -201,16 +201,31 @@ function shouldShowCustomUserSelect(
);
}
/** 判断是否需要显示审批意见 */
function shouldShowApprovalReason(task: any, nodeType: BpmNodeTypeEnum) {
/** 判断是否需要显示审批意见和附件 */
function shouldShowReasonAndAttachment(task: any, nodeType: BpmNodeTypeEnum) {
return (
task.reason &&
Boolean(task.reason || task.attachments?.length > 0) &&
[BpmNodeTypeEnum.START_USER_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
nodeType,
)
);
}
function getAttachmentName(url: string) {
const cleanUrl = url.split(/[?#]/)[0] || '';
const fileName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1);
try {
return decodeURIComponent(fileName);
} catch {
return fileName;
}
}
function isImageAttachment(url: string) {
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase();
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '');
}
/** 用户选择弹窗关闭 */
function handleUserSelectClosed() {
selectedUsers.value = [];
@ -413,13 +428,60 @@ defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers });
</div>
</div>
<!-- 审批意见和签名 -->
<!-- 审批意见,附件和签名 -->
<teleport defer :to="`#activity-task-${activity.id}-${index}`">
<div
v-if="shouldShowApprovalReason(task, activity.nodeType)"
v-if="shouldShowReasonAndAttachment(task, activity.nodeType)"
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
审批意见{{ task.reason }}
<div v-if="task.reason">{{ task.reason }}</div>
<div
v-if="(task.attachments?.length || 0) > 0"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
task.reason,
}"
>
<div class="mb-1 text-xs font-semibold text-gray-400">
附件列表
</div>
<div class="flex flex-col gap-1.5">
<template
v-for="(
attachment, attachmentIndex
) in task.attachments"
:key="attachmentIndex"
>
<div class="flex items-center gap-2">
<IconifyIcon
:icon="
isImageAttachment(attachment)
? 'lucide:image'
: 'lucide:file-text'
"
class="text-gray-400"
/>
<Image
v-if="isImageAttachment(attachment)"
:width="32"
:height="32"
class="rounded border border-solid border-gray-200 object-cover"
:src="attachment"
:preview="{ src: attachment }"
/>
<a
v-else
:href="attachment"
target="_blank"
class="max-w-[240px] truncate text-blue-500 hover:text-blue-600 hover:underline"
:title="getAttachmentName(attachment)"
>
{{ getAttachmentName(attachment) }}
</a>
</div>
</template>
</div>
</div>
</div>
<div
v-if="

View File

@ -95,6 +95,24 @@ const popOverVisible: any = 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 APPROVAL_ATTACHMENT_DIRECTORY = 'bpm/task-attachment';
/** 创建流程表达式 */
function openSignatureModal() {
@ -778,7 +796,9 @@ function handleSignFinish(url: string) {
/** 判断文件是否为图片类型 */
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url);
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(
url.split(/[?#]/)[0] || '',
);
}
/** 附件图片预览 */
@ -899,8 +919,12 @@ defineExpose({ loadTodoTask });
<ElFormItem label="上传附件/图片" prop="attachments">
<FileUpload
v-model:value="approveReasonForm.attachments"
:accept="APPROVAL_ATTACHMENT_FILE_TYPES"
:directory="APPROVAL_ATTACHMENT_DIRECTORY"
:max-number="10"
:max-size="APPROVAL_ATTACHMENT_FILE_SIZE"
:multiple="true"
:show-description="true"
help-text="支持多文件/图片上传"
@preview="handleFilePreview"
/>
@ -966,8 +990,12 @@ defineExpose({ loadTodoTask });
<ElFormItem label="上传附件/图片" prop="attachments">
<FileUpload
v-model:value="rejectReasonForm.attachments"
:accept="APPROVAL_ATTACHMENT_FILE_TYPES"
:directory="APPROVAL_ATTACHMENT_DIRECTORY"
:max-number="10"
:max-size="APPROVAL_ATTACHMENT_FILE_SIZE"
:multiple="true"
:show-description="true"
help-text="支持多文件/图片上传"
@preview="handleFilePreview"
/>

View File

@ -204,7 +204,7 @@ function shouldShowCustomUserSelect(
/** 判断是否需要显示审批意见和附件 */
function shouldShowReasonAndAttachment(task: any, nodeType: BpmNodeTypeEnum) {
return (
(task.reason || task.attachments?.length > 0) &&
Boolean(task.reason || task.attachments?.length > 0) &&
[BpmNodeTypeEnum.START_USER_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
nodeType,
)
@ -212,11 +212,17 @@ function shouldShowReasonAndAttachment(task: any, nodeType: BpmNodeTypeEnum) {
}
function getAttachmentName(url: string) {
return decodeURIComponent(url.slice(url.lastIndexOf('/') + 1));
const cleanUrl = url.split(/[?#]/)[0] || '';
const fileName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1);
try {
return decodeURIComponent(fileName);
} catch {
return fileName;
}
}
function isImageAttachment(url: string) {
const ext = url.split('.').pop()?.toLowerCase();
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase();
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '');
}