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
parent
2a45d2d324
commit
7222e320e2
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export namespace BpmProcessInstanceApi {
|
|||
export interface ApprovalTaskInfo {
|
||||
id: number;
|
||||
assigneeUser: User;
|
||||
attachments?: string[];
|
||||
ownerUser: User;
|
||||
reason: string;
|
||||
signPicUrl: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue