feat(bpm): 展示流程任务附件和签名

- 统一在流程 timeline、流转记录、任务管理中展示附件和签名
- 办理节点展示办理意见,审批节点展示审批意见
- 新增任务附件/签名展示组件,复用文件名解析和图片判断逻辑
- 接入流程评论列表并补齐评论 API 与字典类型
- 清理冗余 helper、临时常量和 form-create ref 类型写法
This commit is contained in:
YunaiV
2026-07-02 19:47:43 +08:00
parent c5f845125c
commit 088e3c62ae
22 changed files with 525 additions and 263 deletions

View File

@@ -14,6 +14,8 @@ export namespace BpmTaskApi {
endTime: number; // 结束时间
durationInMillis: number; // 持续时间
reason: string; // 审批理由
attachments?: string[]; // 审批附件
signPicUrl?: string; // 签名图片
ownerUser: any; // 负责人
assigneeUser: any; // 处理人
taskDefinitionKey: string; // 任务定义的标识

View File

@@ -40,11 +40,11 @@ import {
Textarea,
} from 'ant-design-vue';
import { createComment } from '#/api/bpm/comment';
import {
cancelProcessInstanceByStartUser,
getNextApprovalNodes,
} from '#/api/bpm/processInstance';
import { createComment } from '#/api/bpm/comment';
import {
approveTask,
copyTask,
@@ -114,7 +114,6 @@ const APPROVAL_ATTACHMENT_FILE_TYPES = [
'webp',
];
const APPROVAL_ATTACHMENT_FILE_SIZE = 5;
const APPROVAL_ATTACHMENT_DIRECTORY = 'bpm/task-attachment';
/** 创建流程表达式 */
function openSignatureModal() {
@@ -951,7 +950,6 @@ defineExpose({ loadTodoTask });
<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"
@@ -1022,7 +1020,6 @@ defineExpose({ loadTodoTask });
<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"

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getFileNameFromUrl, isImage } from '@vben/utils';
defineOptions({ name: 'BpmTaskEvidence' });
const props = withDefaults(
defineProps<{
attachments?: null | string | string[];
compact?: boolean;
emptyText?: string;
reason?: string;
reasonLabel?: string;
signPicUrl?: null | string;
}>(),
{
attachments: () => [],
emptyText: '-',
reason: '',
reasonLabel: '审批意见',
signPicUrl: '',
},
);
const reasonText = computed(() => props.reason?.trim() || '');
const attachmentList = computed(() => normalizeAttachments(props.attachments)); // 附件列表
const signPicUrlValue = computed(() => props.signPicUrl?.trim() || ''); // 签名图片地址
const hasEvidence = computed(
() =>
!!reasonText.value ||
attachmentList.value.length > 0 ||
!!signPicUrlValue.value,
); // 是否存在审批凭证
/** 标准化附件列表 */
function normalizeAttachments(attachments?: null | string | string[]) {
if (!attachments) {
return [];
}
const list = Array.isArray(attachments)
? attachments
: String(attachments).split(',');
return list.map((item) => item.trim()).filter(Boolean);
}
/** 判断是否图片附件 */
function isImageAttachment(url: string) {
return isImage(getFileNameFromUrl(url));
}
</script>
<template>
<div
v-if="hasEvidence"
:class="
compact
? 'flex flex-col items-center gap-1 text-xs text-gray-500'
: 'mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500'
"
>
<div v-if="reasonText" class="w-full">
{{ reasonLabel }}{{ reasonText }}
</div>
<div
v-if="attachmentList.length > 0"
class="w-full"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
reasonText && !compact,
}"
>
<div v-if="!compact" class="mb-1 text-xs font-semibold text-gray-400">
附件列表
</div>
<div class="flex flex-wrap gap-1.5" :class="{ 'justify-center': compact }">
<a
v-for="attachment in attachmentList"
:key="attachment"
:href="attachment"
target="_blank"
class="inline-flex max-w-[220px] items-center gap-1 rounded border border-solid border-gray-200 bg-white px-2 py-1 text-blue-500 hover:text-blue-600 hover:underline"
:title="getFileNameFromUrl(attachment)"
>
<img
v-if="isImageAttachment(attachment)"
:src="attachment"
class="size-5 rounded object-cover"
/>
<IconifyIcon v-else icon="lucide:file-text" class="text-gray-400" />
<span class="truncate">{{ getFileNameFromUrl(attachment) }}</span>
</a>
</div>
</div>
<div
v-if="signPicUrlValue"
class="w-full"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
(reasonText || attachmentList.length) && !compact,
}"
>
<div class="mb-1 text-xs font-semibold text-gray-400">签名</div>
<a :href="signPicUrlValue" target="_blank" title="查看签名">
<img
:src="signPicUrlValue"
class="max-h-[60px] max-w-[180px] rounded border border-solid border-gray-200 bg-white object-contain"
/>
</a>
</div>
</div>
<span v-else-if="compact" class="text-gray-400">{{ emptyText }}</span>
</template>

View File

@@ -16,6 +16,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTaskListByProcessInstanceId } from '#/api/bpm/task';
import { setConfAndFields2 } from '#/components/form-create';
import TaskEvidence from './task-evidence.vue';
defineOptions({
name: 'BpmProcessInstanceTaskList',
});
@@ -79,6 +81,14 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
},
minWidth: 200,
},
{
field: 'evidence',
title: '附件/签名',
slots: {
default: 'slot-evidence',
},
minWidth: 220,
},
{
field: 'durationInMillis',
title: '耗时',
@@ -186,6 +196,13 @@ defineExpose({
</Button>
</div>
</template>
<template #slot-evidence="{ row }">
<TaskEvidence
compact
:attachments="row.attachments"
:sign-pic-url="row.signPicUrl"
/>
</template>
</Grid>
</div>
<Modal class="w-3/5">

View File

@@ -14,10 +14,12 @@ import {
import { IconifyIcon } from '@vben/icons';
import { formatDateTime, isEmpty } from '@vben/utils';
import { Avatar, Button, Image, Timeline, Tooltip } from 'ant-design-vue';
import { Avatar, Button, Timeline, Tooltip } from 'ant-design-vue';
import { UserSelectModal } from '#/views/system/user/components';
import TaskEvidence from './task-evidence.vue';
defineOptions({ name: 'BpmProcessInstanceTimeline' });
const props = withDefaults(
@@ -196,7 +198,7 @@ function shouldShowCustomUserSelect(
);
}
/** 判断是否需要显示审批意见和附件 */
/** 判断是否需要显示审批凭证 */
function shouldShowReasonAndAttachment(
task: any,
nodeType: BpmNodeTypeEnum,
@@ -207,26 +209,25 @@ function shouldShowReasonAndAttachment(
return false;
}
return (
Boolean(task.reason || task.attachments?.length > 0) &&
[BpmNodeTypeEnum.START_USER_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
nodeType,
)
hasTaskEvidence(task) &&
[
BpmNodeTypeEnum.START_USER_NODE,
BpmNodeTypeEnum.TRANSACTOR_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 hasTaskEvidence(task: any) {
return Boolean(
task?.reason || task?.attachments?.length > 0 || task?.signPicUrl,
);
}
function isImageAttachment(url: string) {
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase();
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '');
/** 获取意见文案 */
function getReasonLabel(nodeType: BpmNodeTypeEnum) {
return nodeType === BpmNodeTypeEnum.TRANSACTOR_NODE ? '办理意见' : '审批意见';
}
/** 用户选择弹窗关闭 */
@@ -428,76 +429,16 @@ defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers });
</div>
</div>
<!-- 审批意见,附件和签名 -->
<div
<!-- 审批凭证 -->
<TaskEvidence
v-if="
shouldShowReasonAndAttachment(task, activity.nodeType, index)
"
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
<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="
task.signPicUrl &&
activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE
"
class="mt-1 flex w-full items-center rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
签名:
<Image
class="ml-2"
:width="180"
:height="60"
:src="task.signPicUrl"
:preview="{ src: task.signPicUrl }"
/>
</div>
:attachments="task.attachments"
:reason="task.reason"
:reason-label="getReasonLabel(activity.nodeType)"
:sign-pic-url="task.signPicUrl"
/>
</div>
<!-- 情况二遍历每个审批节点下的【候选的】task 任务 -->

View File

@@ -78,6 +78,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
title: '审批建议',
minWidth: 180,
},
{
field: 'evidence',
title: '附件/签名',
minWidth: 220,
slots: { default: 'evidence' },
},
{
field: 'durationInMillis',
title: '耗时',

View File

@@ -7,6 +7,7 @@ import { DocAlert, Page } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTaskManagerPage } from '#/api/bpm/task';
import { router } from '#/router';
import TaskEvidence from '#/views/bpm/processInstance/detail/modules/task-evidence.vue';
import { useGridColumns, useGridFormSchema } from './data';
@@ -60,6 +61,13 @@ const [Grid] = useVbenVxeGrid({
</template>
<Grid table-title="流程任务">
<template #evidence="{ row }">
<TaskEvidence
compact
:attachments="row.attachments"
:sign-pic-url="row.signPicUrl"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[

View File

@@ -14,6 +14,8 @@ export namespace BpmTaskApi {
endTime: number; // 结束时间
durationInMillis: number; // 持续时间
reason: string; // 审批理由
attachments?: string[]; // 审批附件
signPicUrl?: string; // 签名图片
ownerUser: any; // 负责人
assigneeUser: any; // 处理人
taskDefinitionKey: string; // 任务定义的标识

View File

@@ -38,11 +38,11 @@ import {
TextArea,
} from 'antdv-next';
import { createComment } from '#/api/bpm/comment';
import {
cancelProcessInstanceByStartUser,
getNextApprovalNodes,
} from '#/api/bpm/processInstance';
import { createComment } from '#/api/bpm/comment';
import {
approveTask,
copyTask,
@@ -113,7 +113,6 @@ const APPROVAL_ATTACHMENT_FILE_TYPES = [
'webp',
];
const APPROVAL_ATTACHMENT_FILE_SIZE = 5;
const APPROVAL_ATTACHMENT_DIRECTORY = 'bpm/task-attachment';
/** 创建流程表达式 */
function openSignatureModal() {
@@ -954,7 +953,6 @@ defineExpose({ loadTodoTask });
<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"
@@ -1025,7 +1023,6 @@ defineExpose({ loadTodoTask });
<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"

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getFileNameFromUrl, isImage } from '@vben/utils';
defineOptions({ name: 'BpmTaskEvidence' });
const props = withDefaults(
defineProps<{
attachments?: null | string | string[];
compact?: boolean;
emptyText?: string;
reason?: string;
reasonLabel?: string;
signPicUrl?: null | string;
}>(),
{
attachments: () => [],
emptyText: '-',
reason: '',
reasonLabel: '审批意见',
signPicUrl: '',
},
);
const reasonText = computed(() => props.reason?.trim() || '');
const attachmentList = computed(() => normalizeAttachments(props.attachments)); // 附件列表
const signPicUrlValue = computed(() => props.signPicUrl?.trim() || ''); // 签名图片地址
const hasEvidence = computed(
() =>
!!reasonText.value ||
attachmentList.value.length > 0 ||
!!signPicUrlValue.value,
); // 是否存在审批凭证
/** 标准化附件列表 */
function normalizeAttachments(attachments?: null | string | string[]) {
if (!attachments) {
return [];
}
const list = Array.isArray(attachments)
? attachments
: String(attachments).split(',');
return list.map((item) => item.trim()).filter(Boolean);
}
/** 判断是否图片附件 */
function isImageAttachment(url: string) {
return isImage(getFileNameFromUrl(url));
}
</script>
<template>
<div
v-if="hasEvidence"
:class="
compact
? 'flex flex-col items-center gap-1 text-xs text-gray-500'
: 'mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500'
"
>
<div v-if="reasonText" class="w-full">
{{ reasonLabel }}{{ reasonText }}
</div>
<div
v-if="attachmentList.length > 0"
class="w-full"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
reasonText && !compact,
}"
>
<div v-if="!compact" class="mb-1 text-xs font-semibold text-gray-400">
附件列表
</div>
<div class="flex flex-wrap gap-1.5" :class="{ 'justify-center': compact }">
<a
v-for="attachment in attachmentList"
:key="attachment"
:href="attachment"
target="_blank"
class="inline-flex max-w-[220px] items-center gap-1 rounded border border-solid border-gray-200 bg-white px-2 py-1 text-blue-500 hover:text-blue-600 hover:underline"
:title="getFileNameFromUrl(attachment)"
>
<img
v-if="isImageAttachment(attachment)"
:src="attachment"
class="size-5 rounded object-cover"
/>
<IconifyIcon v-else icon="lucide:file-text" class="text-gray-400" />
<span class="truncate">{{ getFileNameFromUrl(attachment) }}</span>
</a>
</div>
</div>
<div
v-if="signPicUrlValue"
class="w-full"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
(reasonText || attachmentList.length) && !compact,
}"
>
<div class="mb-1 text-xs font-semibold text-gray-400">签名</div>
<a :href="signPicUrlValue" target="_blank" title="查看签名">
<img
:src="signPicUrlValue"
class="max-h-[60px] max-w-[180px] rounded border border-solid border-gray-200 bg-white object-contain"
/>
</a>
</div>
</div>
<span v-else-if="compact" class="text-gray-400">{{ emptyText }}</span>
</template>

View File

@@ -16,6 +16,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTaskListByProcessInstanceId } from '#/api/bpm/task';
import { setConfAndFields2 } from '#/components/form-create';
import TaskEvidence from './task-evidence.vue';
defineOptions({
name: 'BpmProcessInstanceTaskList',
});
@@ -79,6 +81,14 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
},
minWidth: 200,
},
{
field: 'evidence',
title: '附件/签名',
slots: {
default: 'slot-evidence',
},
minWidth: 220,
},
{
field: 'durationInMillis',
title: '耗时',
@@ -186,6 +196,13 @@ defineExpose({
</Button>
</div>
</template>
<template #slot-evidence="{ row }">
<TaskEvidence
compact
:attachments="row.attachments"
:sign-pic-url="row.signPicUrl"
/>
</template>
</Grid>
</div>
<Modal class="w-3/5">

View File

@@ -17,7 +17,6 @@ import { formatDateTime, isEmpty } from '@vben/utils';
import {
Avatar,
Button,
Image,
Timeline,
TimelineItem,
Tooltip,
@@ -25,6 +24,8 @@ import {
import { UserSelectModal } from '#/views/system/user/components';
import TaskEvidence from './task-evidence.vue';
defineOptions({ name: 'BpmProcessInstanceTimeline' });
const props = withDefaults(
@@ -203,7 +204,7 @@ function shouldShowCustomUserSelect(
);
}
/** 判断是否需要显示审批意见和附件 */
/** 判断是否需要显示审批凭证 */
function shouldShowReasonAndAttachment(
task: any,
nodeType: BpmNodeTypeEnum,
@@ -214,26 +215,25 @@ function shouldShowReasonAndAttachment(
return false;
}
return (
Boolean(task.reason || task.attachments?.length > 0) &&
[BpmNodeTypeEnum.START_USER_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
nodeType,
)
hasTaskEvidence(task) &&
[
BpmNodeTypeEnum.START_USER_NODE,
BpmNodeTypeEnum.TRANSACTOR_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 hasTaskEvidence(task: any) {
return Boolean(
task?.reason || task?.attachments?.length > 0 || task?.signPicUrl,
);
}
function isImageAttachment(url: string) {
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase();
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '');
/** 获取意见文案 */
function getReasonLabel(nodeType: BpmNodeTypeEnum) {
return nodeType === BpmNodeTypeEnum.TRANSACTOR_NODE ? '办理意见' : '审批意见';
}
/** 用户选择弹窗关闭 */
@@ -435,76 +435,16 @@ defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers });
</div>
</div>
<!-- 审批意见、附件和签名 -->
<div
<!-- 审批凭证 -->
<TaskEvidence
v-if="
shouldShowReasonAndAttachment(task, activity.nodeType, index)
"
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
<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="
task.signPicUrl &&
activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE
"
class="mt-1 flex w-full items-center rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
签名:
<Image
class="ml-2"
:width="180"
:height="60"
:src="task.signPicUrl"
:preview="{ src: task.signPicUrl }"
/>
</div>
:attachments="task.attachments"
:reason="task.reason"
:reason-label="getReasonLabel(activity.nodeType)"
:sign-pic-url="task.signPicUrl"
/>
</div>
<!-- 情况二遍历每个审批节点下的【候选的】task 任务 -->

View File

@@ -78,6 +78,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
title: '审批建议',
minWidth: 180,
},
{
field: 'evidence',
title: '附件/签名',
minWidth: 220,
slots: { default: 'evidence' },
},
{
field: 'durationInMillis',
title: '耗时',

View File

@@ -7,6 +7,7 @@ import { DocAlert, Page } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTaskManagerPage } from '#/api/bpm/task';
import { router } from '#/router';
import TaskEvidence from '#/views/bpm/processInstance/detail/modules/task-evidence.vue';
import { useGridColumns, useGridFormSchema } from './data';
@@ -60,6 +61,13 @@ const [Grid] = useVbenVxeGrid({
</template>
<Grid table-title="流程任务">
<template #evidence="{ row }">
<TaskEvidence
compact
:attachments="row.attachments"
:sign-pic-url="row.signPicUrl"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[

View File

@@ -14,6 +14,8 @@ export namespace BpmTaskApi {
endTime: number; // 结束时间
durationInMillis: number; // 持续时间
reason: string; // 审批理由
attachments?: string[]; // 审批附件
signPicUrl?: string; // 签名图片
ownerUser: any; // 负责人
assigneeUser: any; // 处理人
taskDefinitionKey: string; // 任务定义的标识

View File

@@ -40,11 +40,11 @@ import {
ElSpace,
} from 'element-plus';
import { createComment } from '#/api/bpm/comment';
import {
cancelProcessInstanceByStartUser,
getNextApprovalNodes,
} from '#/api/bpm/processInstance';
import { createComment } from '#/api/bpm/comment';
import {
approveTask,
copyTask,
@@ -114,7 +114,6 @@ const APPROVAL_ATTACHMENT_FILE_TYPES = [
'webp',
];
const APPROVAL_ATTACHMENT_FILE_SIZE = 5;
const APPROVAL_ATTACHMENT_DIRECTORY = 'bpm/task-attachment';
/** 创建流程表达式 */
function openSignatureModal() {
@@ -958,7 +957,6 @@ defineExpose({ loadTodoTask });
<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"
@@ -1029,7 +1027,6 @@ defineExpose({ loadTodoTask });
<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"

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getFileNameFromUrl, isImage } from '@vben/utils';
defineOptions({ name: 'BpmTaskEvidence' });
const props = withDefaults(
defineProps<{
attachments?: null | string | string[];
compact?: boolean;
emptyText?: string;
reason?: string;
reasonLabel?: string;
signPicUrl?: null | string;
}>(),
{
attachments: () => [],
emptyText: '-',
reason: '',
reasonLabel: '审批意见',
signPicUrl: '',
},
);
const reasonText = computed(() => props.reason?.trim() || '');
const attachmentList = computed(() => normalizeAttachments(props.attachments)); // 附件列表
const signPicUrlValue = computed(() => props.signPicUrl?.trim() || ''); // 签名图片地址
const hasEvidence = computed(
() =>
!!reasonText.value ||
attachmentList.value.length > 0 ||
!!signPicUrlValue.value,
); // 是否存在审批凭证
/** 标准化附件列表 */
function normalizeAttachments(attachments?: null | string | string[]) {
if (!attachments) {
return [];
}
const list = Array.isArray(attachments)
? attachments
: String(attachments).split(',');
return list.map((item) => item.trim()).filter(Boolean);
}
/** 判断是否图片附件 */
function isImageAttachment(url: string) {
return isImage(getFileNameFromUrl(url));
}
</script>
<template>
<div
v-if="hasEvidence"
:class="
compact
? 'flex flex-col items-center gap-1 text-xs text-gray-500'
: 'mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500'
"
>
<div v-if="reasonText" class="w-full">
{{ reasonLabel }}{{ reasonText }}
</div>
<div
v-if="attachmentList.length > 0"
class="w-full"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
reasonText && !compact,
}"
>
<div v-if="!compact" class="mb-1 text-xs font-semibold text-gray-400">
附件列表
</div>
<div class="flex flex-wrap gap-1.5" :class="{ 'justify-center': compact }">
<a
v-for="attachment in attachmentList"
:key="attachment"
:href="attachment"
target="_blank"
class="inline-flex max-w-[220px] items-center gap-1 rounded border border-solid border-gray-200 bg-white px-2 py-1 text-blue-500 hover:text-blue-600 hover:underline"
:title="getFileNameFromUrl(attachment)"
>
<img
v-if="isImageAttachment(attachment)"
:src="attachment"
class="size-5 rounded object-cover"
/>
<IconifyIcon v-else icon="lucide:file-text" class="text-gray-400" />
<span class="truncate">{{ getFileNameFromUrl(attachment) }}</span>
</a>
</div>
</div>
<div
v-if="signPicUrlValue"
class="w-full"
:class="{
'mt-2 border-t border-dashed border-gray-300 pt-2':
(reasonText || attachmentList.length) && !compact,
}"
>
<div class="mb-1 text-xs font-semibold text-gray-400">签名</div>
<a :href="signPicUrlValue" target="_blank" title="查看签名">
<img
:src="signPicUrlValue"
class="max-h-[60px] max-w-[180px] rounded border border-solid border-gray-200 bg-white object-contain"
/>
</a>
</div>
</div>
<span v-else-if="compact" class="text-gray-400">{{ emptyText }}</span>
</template>

View File

@@ -15,6 +15,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTaskListByProcessInstanceId } from '#/api/bpm/task';
import { setConfAndFields2 } from '#/components/form-create';
import TaskEvidence from './task-evidence.vue';
defineOptions({
name: 'BpmProcessInstanceTaskList',
});
@@ -78,6 +80,14 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
},
minWidth: 200,
},
{
field: 'evidence',
title: '附件/签名',
slots: {
default: 'slot-evidence',
},
minWidth: 220,
},
{
field: 'durationInMillis',
title: '耗时',
@@ -187,6 +197,13 @@ defineExpose({
</ElButton>
</div>
</template>
<template #slot-evidence="{ row }">
<TaskEvidence
compact
:attachments="row.attachments"
:sign-pic-url="row.signPicUrl"
/>
</template>
</Grid>
</div>
<Modal class="w-3/5">

View File

@@ -17,7 +17,6 @@ import { formatDateTime, isEmpty } from '@vben/utils';
import {
ElAvatar,
ElButton,
ElImage,
ElTimeline,
ElTimelineItem,
ElTooltip,
@@ -25,6 +24,8 @@ import {
import { UserSelectModal } from '#/views/system/user/components';
import TaskEvidence from './task-evidence.vue';
defineOptions({ name: 'BpmProcessInstanceTimeline' });
const props = withDefaults(
@@ -203,7 +204,7 @@ function shouldShowCustomUserSelect(
);
}
/** 判断是否需要显示审批意见和附件 */
/** 判断是否需要显示审批凭证 */
function shouldShowReasonAndAttachment(
task: any,
nodeType: BpmNodeTypeEnum,
@@ -214,26 +215,25 @@ function shouldShowReasonAndAttachment(
return false;
}
return (
Boolean(task.reason || task.attachments?.length > 0) &&
[BpmNodeTypeEnum.START_USER_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
nodeType,
)
hasTaskEvidence(task) &&
[
BpmNodeTypeEnum.START_USER_NODE,
BpmNodeTypeEnum.TRANSACTOR_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 hasTaskEvidence(task: any) {
return Boolean(
task?.reason || task?.attachments?.length > 0 || task?.signPicUrl,
);
}
function isImageAttachment(url: string) {
const ext = url.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase();
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(ext || '');
/** 获取意见文案 */
function getReasonLabel(nodeType: BpmNodeTypeEnum) {
return nodeType === BpmNodeTypeEnum.TRANSACTOR_NODE ? '办理意见' : '审批意见';
}
/** 用户选择弹窗关闭 */
@@ -433,74 +433,16 @@ defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers });
</div>
</div>
<!-- 审批意见,附件和签名 -->
<div
<!-- 审批凭证 -->
<TaskEvidence
v-if="
shouldShowReasonAndAttachment(task, activity.nodeType, index)
"
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
<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"
/>
<ElImage
v-if="isImageAttachment(attachment)"
style="width: 32px; height: 32px"
class="rounded border border-solid border-gray-200 object-cover"
:src="attachment"
:preview-src-list="[attachment]"
fit="cover"
/>
<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="
task.signPicUrl &&
activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE
"
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
>
签名:
<ElImage
class="ml-1 h-10 w-24"
:src="task.signPicUrl"
:preview-src-list="[task.signPicUrl]"
/>
</div>
:attachments="task.attachments"
:reason="task.reason"
:reason-label="getReasonLabel(activity.nodeType)"
:sign-pic-url="task.signPicUrl"
/>
</div>
<!-- 情况二遍历每个审批节点下的【候选的】task 任务 -->

View File

@@ -78,6 +78,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
title: '审批建议',
minWidth: 180,
},
{
field: 'evidence',
title: '附件/签名',
minWidth: 220,
slots: { default: 'evidence' },
},
{
field: 'durationInMillis',
title: '耗时',

View File

@@ -7,6 +7,7 @@ import { DocAlert, Page } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTaskManagerPage } from '#/api/bpm/task';
import { router } from '#/router';
import TaskEvidence from '#/views/bpm/processInstance/detail/modules/task-evidence.vue';
import { useGridColumns, useGridFormSchema } from './data';
@@ -60,6 +61,13 @@ const [Grid] = useVbenVxeGrid({
</template>
<Grid table-title="流程任务">
<template #evidence="{ row }">
<TaskEvidence
compact
:attachments="row.attachments"
:sign-pic-url="row.signPicUrl"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[