refactor: 新增流程审批相关组件和功能

- 新增 `flow-actions.vue` 审批操作按钮组件,支持撤销、编辑、删除、审批、驳回、终止、委托、转办、加签、减签等操作
- 新增 `approval-panel-drawer.vue` 审批详情抽屉组件,整合审批信息展示与操作按钮
- 新增 `task` 目录,包含任务列表页面及数据定义
- 修改 `copy-component.vue` 组件,支持自定义头像大小并优化样式
- 调整 `flow-preview.vue` 中 iframe 高度从 500px 增加到 600px
- 优化 `processInstance/data.tsx` 查询条件,使用 `_nodeName` 替代 `nodeName` 避免冲突,并支持多选人员筛选
- 导出新增的审批面板抽屉组件 `ApprovalPanelDrawerComp`
- 引入 `DefaultSlot` 组件用于自定义表单项渲染
- 定义 `ApprovalType` 类型用于区分不同审批场景(我的申请、审批、管理、只读)
This commit is contained in:
dap
2025-10-17 13:37:20 +08:00
parent 4291d59b97
commit c37a0fefa1
19 changed files with 1503 additions and 1090 deletions

View File

@@ -0,0 +1,423 @@
<script setup lang="ts">
import type { ApprovalType } from '../type';
import type { User } from '#/api/core/user';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, h } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { cn, getPopupContainer } from '@vben/utils';
import {
ArrowLeftOutlined,
CheckOutlined,
EditOutlined,
ExclamationCircleOutlined,
MenuOutlined,
RollbackOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { Dropdown, Menu, MenuItem, Modal, Space } from 'ant-design-vue';
import {
cancelProcessApply,
deleteByInstanceIds,
} from '#/api/workflow/instance';
import {
taskOperation,
terminationTask,
updateAssignee,
} from '#/api/workflow/task';
import { approvalModal, approvalRejectionModal, flowInterfereModal } from '..';
import { approveWithReasonModal } from '../helper';
import userSelectModal from '../user-select-modal.vue';
interface Props {
task?: TaskInfo;
type: ApprovalType;
}
const props = defineProps<Props>();
const emit = defineEmits<{
reload: [];
}>();
/**
* 按钮权限
*/
const buttonPermissions = computed(() => {
const record: Record<string, boolean> = {};
if (!props.task) {
return record;
}
props.task.buttonList.forEach((item) => {
record[item.code] = item.show;
});
return record;
});
// 是否显示 `其他` 按钮
const showButtonOther = computed(() => {
const moreCollections = new Set(['addSign', 'subSign', 'transfer', 'trust']);
return Object.keys(buttonPermissions.value).some(
(key) => moreCollections.has(key) && buttonPermissions.value[key],
);
});
// 进行中 可以撤销
const revocable = computed(() => props.task?.flowStatus === 'waiting');
async function handleCancel() {
Modal.confirm({
title: '提示',
content: '确定要撤销该申请吗?',
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await cancelProcessApply({
businessId: props.task!.businessId,
message: '申请人撤销流程!',
});
emit('reload');
},
});
}
/**
* 是否可编辑/删除
*/
const editableAndRemoveable = computed(() => {
if (!props.task) {
return false;
}
return ['back', 'cancel', 'draft'].includes(props.task.flowStatus);
});
const router = useRouter();
function handleEdit() {
const path = props.task?.formPath;
if (path) {
router.push({ path, query: { id: props.task!.businessId } });
}
}
function handleRemove() {
Modal.confirm({
title: '提示',
content: '确定删除该申请吗?',
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await deleteByInstanceIds([props.task!.id]);
emit('reload');
},
});
}
/**
* 审批驳回
*/
const [RejectionModal, rejectionModalApi] = useVbenModal({
connectedComponent: approvalRejectionModal,
});
function handleRejection() {
rejectionModalApi.setData({
taskId: props.task?.id,
definitionId: props.task?.definitionId,
nodeCode: props.task?.nodeCode,
});
rejectionModalApi.open();
}
/**
* 审批终止
*/
function handleTermination() {
approveWithReasonModal({
title: '审批终止',
description: '确定终止当前审批流程吗?',
onOk: async (reason) => {
await terminationTask({ taskId: props.task!.id, comment: reason });
emit('reload');
},
});
}
/**
* 审批通过
*/
const [ApprovalModal, approvalModalApi] = useVbenModal({
connectedComponent: approvalModal,
});
function handleApproval() {
// 是否具有抄送权限
const copyPermission = buttonPermissions.value?.copy ?? false;
// 是否具有选人权限
const assignPermission = buttonPermissions.value?.pop ?? false;
approvalModalApi.setData({
taskId: props.task?.id,
copyPermission,
assignPermission,
});
approvalModalApi.open();
}
/**
* 委托
*/
const [DelegationModal, delegationModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleDelegation(userList: User[]) {
if (userList.length === 0) return;
const current = userList[0];
approveWithReasonModal({
title: '委托',
description: `确定委托给[${current?.nickName}]吗?`,
onOk: async (reason) => {
await taskOperation(
{ taskId: props.task!.id, userId: current!.userId, message: reason },
'delegateTask',
);
emit('reload');
},
});
}
/**
* 转办
*/
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleTransfer(userList: User[]) {
if (userList.length === 0) return;
const current = userList[0];
approveWithReasonModal({
title: '转办',
description: `确定转办给[${current?.nickName}]吗?`,
onOk: async (reason) => {
await taskOperation(
{ taskId: props.task!.id, userId: current!.userId, message: reason },
'transferTask',
);
emit('reload');
},
});
}
const [AddSignatureModal, addSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleAddSignature(userList: User[]) {
if (userList.length === 0) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认加签吗?',
centered: true,
onOk: async () => {
await taskOperation({ taskId: props.task!.id, userIds }, 'addSignature');
emit('reload');
},
});
}
const [ReductionSignatureModal, reductionSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleReductionSignature(userList: User[]) {
if (userList.length === 0) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认减签吗?',
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: props.task!.id, userIds },
'reductionSignature',
);
emit('reload');
},
});
}
// 流程干预
const [FlowInterfereModal, flowInterfereModalApi] = useVbenModal({
connectedComponent: flowInterfereModal,
});
function handleFlowInterfere() {
flowInterfereModalApi.setData({ taskId: props.task?.id });
flowInterfereModalApi.open();
}
// 修改办理人
const [UpdateAssigneeModal, updateAssigneeModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleUpdateAssignee(userList: User[]) {
if (userList.length === 0) return;
const current = userList[0];
if (!current) return;
Modal.confirm({
title: '修改办理人',
content: `确定修改办理人为${current?.nickName}吗?`,
centered: true,
onOk: async () => {
await updateAssignee([props.task!.id], current.userId);
emit('reload');
},
});
}
/**
* 是否显示 加签/减签操作
*/
const showMultiActions = computed(() => {
if (!props.task) {
return false;
}
if (Number(props.task.nodeRatio) > 0) {
return true;
}
return false;
});
</script>
<template>
<div
:class="
cn(
'absolute bottom-0 left-0',
'border-t-solid border-t-[1px]',
'bg-background w-full p-3',
)
"
>
<div class="flex justify-end">
<Space v-if="type === 'myself'">
<a-button
v-if="revocable"
danger
ghost
type="primary"
:icon="h(RollbackOutlined)"
@click="handleCancel"
>
撤销申请
</a-button>
<a-button
type="primary"
ghost
v-if="editableAndRemoveable"
:icon="h(EditOutlined)"
@click="handleEdit"
>
重新编辑
</a-button>
<a-button
v-if="editableAndRemoveable"
danger
ghost
type="primary"
:icon="h(EditOutlined)"
@click="handleRemove"
>
删除
</a-button>
</Space>
<Space v-if="type === 'approve'">
<a-button
type="primary"
ghost
:icon="h(CheckOutlined)"
@click="handleApproval"
>
通过
</a-button>
<a-button
v-if="buttonPermissions?.termination"
danger
ghost
type="primary"
:icon="h(ExclamationCircleOutlined)"
@click="handleTermination"
>
终止
</a-button>
<a-button
v-if="buttonPermissions?.back"
danger
ghost
type="primary"
:icon="h(ArrowLeftOutlined)"
@click="handleRejection"
>
驳回
</a-button>
<Dropdown
:get-popup-container="getPopupContainer"
placement="bottomRight"
>
<template #overlay>
<Menu>
<MenuItem
v-if="buttonPermissions?.trust"
key="1"
@click="() => delegationModalApi.open()"
>
<UserOutlined class="mr-2" />委托
</MenuItem>
<MenuItem
v-if="buttonPermissions?.transfer"
key="2"
@click="() => transferModalApi.open()"
>
<RollbackOutlined class="mr-2" /> 转办
</MenuItem>
<MenuItem
v-if="showMultiActions && buttonPermissions?.addSign"
key="3"
@click="() => addSignatureModalApi.open()"
>
<UsergroupAddOutlined class="mr-2" /> 加签
</MenuItem>
<MenuItem
v-if="showMultiActions && buttonPermissions?.subSign"
key="4"
@click="() => reductionSignatureModalApi.open()"
>
<UsergroupDeleteOutlined class="mr-2" /> 减签
</MenuItem>
</Menu>
</template>
<a-button v-if="showButtonOther" :icon="h(MenuOutlined)">
其他
</a-button>
</Dropdown>
<ApprovalModal @complete="$emit('reload')" />
<RejectionModal @complete="$emit('reload')" />
<DelegationModal mode="single" @finish="handleDelegation" />
<TransferModal mode="single" @finish="handleTransfer" />
<AddSignatureModal mode="multiple" @finish="handleAddSignature" />
<ReductionSignatureModal
mode="multiple"
@finish="handleReductionSignature"
/>
</Space>
<Space v-if="type === 'admin'">
<a-button @click="handleFlowInterfere"> 流程干预 </a-button>
<a-button @click="() => updateAssigneeModalApi.open()">
修改办理人
</a-button>
<FlowInterfereModal @complete="$emit('reload')" />
<UpdateAssigneeModal mode="single" @finish="handleUpdateAssignee" />
</Space>
</div>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as FlowActions } from './flow-actions.vue';

View File

@@ -1,6 +1,6 @@
<!--
审批详情
动态渲染要显示的内容 需要flowDescripionsMap先定义好组件
动态渲染要显示的内容 需要flowDescripionsMap先定义好组件
-->
<script setup lang="ts">
import type { FlowComponentsMapMapKey } from '../register';
@@ -20,8 +20,6 @@ defineOptions({
defineProps<{
currentFlowInfo: FlowInfoResponse;
iframeHeight: number;
iframeLoaded: boolean;
task: TaskInfo;
}>();
</script>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { FlowInfoResponse } from '#/api/workflow/instance/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { ref } from 'vue';
import { useVbenDrawer, VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { cloneDeep, cn } from '@vben/utils';
import { Divider, Skeleton, TabPane, Tabs } from 'ant-design-vue';
import { flowInfo } from '#/api/workflow/instance';
import { getTaskByTaskId } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { FlowActions } from './actions';
import ApprovalDetails from './approval-details.vue';
import FlowPreview from './flow-preview.vue';
const emit = defineEmits<{ reload: [] }>();
interface DrawerProps {
task: TaskInfo;
type: 'admin' | 'approve' | 'myself' | 'readonly';
}
const currentTask = ref<null | TaskInfo>(null);
const currentFlowInfo = ref<FlowInfoResponse | null>(null);
const currentType = ref<DrawerProps['type'] | null>(null);
const [BasicDrawer, drawerApi] = useVbenDrawer({
title: '流程',
// 默认不显示footer
footer: false,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
try {
loading.value = true;
const { task, type } = drawerApi.getData() as DrawerProps;
const { businessId, id: taskId } = task;
const flowResp = await flowInfo(businessId);
currentFlowInfo.value = flowResp;
/**
* 审批需要查询按钮权限 通过drawer传递的task是空的
* TODO: promise.all
*/
if (type === 'approve') {
const taskResp = await getTaskByTaskId(taskId);
const cloneTask = cloneDeep(task);
// 赋值 按钮权限
cloneTask.buttonList = taskResp.buttonList;
currentTask.value = cloneTask;
} else {
// default逻辑
currentTask.value = task;
}
// 最后赋值type
currentType.value = type;
// 设置是否显示footer
drawerApi.setState({
footer: type !== 'readonly',
});
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
},
onClosed() {
currentTask.value = null;
currentType.value = null;
currentFlowInfo.value = null;
},
});
const loading = ref(false);
/**
* 底部按钮操作后
*/
async function handleAfterAction() {
emit('reload');
drawerApi.close();
}
</script>
<template>
<BasicDrawer class="w-[900px]" content-class="flex w-full">
<div :class="cn('flex-1 text-[#323639E0]')">
<Skeleton active :loading="loading">
<div v-if="currentTask" class="flex flex-col gap-5 p-4">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<div class="text-2xl font-bold">
{{ currentTask.businessTitle ?? currentTask.flowName }}
</div>
<div>
<component
:is="
renderDict(
currentTask.flowStatus,
DictEnum.WF_BUSINESS_STATUS,
)
"
/>
</div>
</div>
<div class="flex items-center gap-2 text-sm">
<VbenAvatar
:alt="currentTask.createByName ?? ''"
class="bg-primary size-[28px] rounded-full text-white"
src=""
/>
<span>{{ currentTask.createByName }}</span>
<div class="flex items-center opacity-50">
<div class="flex items-center gap-1">
<span class="icon-[bxs--category-alt] size-[16px]"></span>
流程分类: {{ currentTask.categoryName }}
</div>
<Divider type="vertical" />
<div class="flex items-center gap-1">
<span class="icon-[mdi--clock-outline] size-[16px]"></span>
提交时间: {{ currentTask.createTime }}
</div>
</div>
</div>
</div>
<Tabs v-if="currentFlowInfo" class="flex-1">
<TabPane key="1" tab="审批详情">
<ApprovalDetails
:current-flow-info="currentFlowInfo"
:task="currentTask"
/>
</TabPane>
<TabPane key="2" tab="审批流程图">
<FlowPreview :instance-id="currentFlowInfo.instanceId" />
</TabPane>
</Tabs>
</div>
</Skeleton>
</div>
<template #footer>
<FlowActions
v-if="currentTask && currentType"
:task="currentTask"
:type="currentType"
@reload="handleAfterAction"
/>
</template>
</BasicDrawer>
</template>

View File

@@ -1,6 +1,6 @@
<!--抄送组件-->
<script setup lang="ts">
import type { PropType } from 'vue';
import type { CSSProperties, PropType } from 'vue';
import type { User } from '#/api/system/user/model';
@@ -18,7 +18,11 @@ defineOptions({
});
const props = withDefaults(
defineProps<{ allowUserIds?: string; ellipseNumber?: number }>(),
defineProps<{
allowUserIds?: string;
avatarSize?: number;
ellipseNumber?: number;
}>(),
{
/**
* 最大显示的头像数量 超过显示为省略号头像
@@ -28,6 +32,10 @@ const props = withDefaults(
* 允许选择允许选择的人员ID 会当做参数拼接在uselist接口
*/
allowUserIds: '',
/**
* 头像大小
*/
avatarSize: 36,
},
);
@@ -57,6 +65,14 @@ function handleFinish(userList: User[]) {
const displayedList = computed(() => {
return userListModel.value.slice(0, props.ellipseNumber);
});
const avatarStyle = computed<CSSProperties>(() => {
const { avatarSize } = props;
return {
width: `${avatarSize}px`,
height: `${avatarSize}px`,
};
});
</script>
<template>
@@ -71,8 +87,9 @@ const displayedList = computed(() => {
<div>
<VbenAvatar
:alt="user?.nickName ?? ''"
class="bg-primary size-[36px] cursor-pointer rounded-full border text-white"
class="bg-primary cursor-pointer rounded-full border text-white"
src=""
:size="avatarSize"
/>
</div>
</Tooltip>
@@ -82,13 +99,14 @@ const displayedList = computed(() => {
>
<Avatar
v-if="userListModel.length > ellipseNumber"
class="flex size-[36px] cursor-pointer items-center justify-center rounded-full border bg-[gray] text-white"
class="flex cursor-pointer items-center justify-center rounded-full border bg-[gray] text-white"
:style="avatarStyle"
>
+{{ userListModel.length - props.ellipseNumber }}
</Avatar>
</Tooltip>
</AvatarGroup>
<a-button size="small" @click="handleOpen">选择人员</a-button>
<a-button class="ml-1" size="small" @click="handleOpen">选择人员</a-button>
<UserSelectModal
:allow-user-ids="allowUserIds"
@cancel="$emit('cancel')"

View File

@@ -28,5 +28,5 @@ const { iframeRef } = useWarmflowIframe();
</script>
<template>
<iframe ref="iframeRef" :src="url" class="h-[500px] w-full border"></iframe>
<iframe ref="iframeRef" :src="url" class="h-[600px] w-full border"></iframe>
</template>

View File

@@ -1,3 +1,4 @@
/* eslint-disable vue/one-component-per-file */
import { defineComponent, h, ref } from 'vue';
import { Modal } from 'ant-design-vue';
@@ -58,3 +59,13 @@ export function getDiffTimeString(dateTime: string) {
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
return diffText;
}
/**
* 使用默认插槽来定义schema
*/
export const DefaultSlot = defineComponent({
name: 'DefaultSlot',
render() {
return <div>{this.$slots.default?.()}</div>;
},
});

View File

@@ -4,21 +4,23 @@ export { default as ApprovalCard } from './approval-card.vue';
* 审批同意
*/
export { default as approvalModal } from './approval-modal.vue';
export { default as ApprovalPanelDrawerComp } from './approval-panel-drawer.vue';
export { default as ApprovalPanel } from './approval-panel.vue';
/**
* 审批驳回
*/
export { default as approvalRejectionModal } from './approval-rejection-modal.vue';
export { default as ApprovalTimeline } from './approval-timeline.vue';
/**
* 选择抄送人
*/
export { default as CopyComponent } from './copy-component.vue';
/**
* 详情信息 modal
*/
export { default as flowInfoModal } from './flow-info-modal.vue';
/**
* 流程干预 modal
*/

View File

@@ -0,0 +1,85 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils';
import { getDictOptions } from '#/utils/dict';
import { renderDict } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'configName',
label: '参数名称',
},
{
component: 'Input',
fieldName: 'configKey',
label: '参数键名',
},
{
component: 'Select',
componentProps: {
getPopupContainer,
options: getDictOptions(DictEnum.SYS_YES_NO),
},
fieldName: 'configType',
label: '系统内置',
},
{
component: 'RangePicker',
fieldName: 'createTime',
label: '创建时间',
},
];
export const columns: VxeGridProps['columns'] = [
{
field: 'id',
title: 'ID',
},
{
field: 'businessTitle',
title: '业务标题',
formatter: ({ cellValue }) => cellValue ?? '-',
},
{
field: 'flowName',
title: '流程名称',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'processedByName',
title: '办理人',
formatter: ({ cellValue }) => cellValue?.split?.(',') ?? cellValue,
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
// {
// field: 'action',
// fixed: 'right',
// slots: { default: 'action' },
// title: '操作',
// resizable: false,
// width: 'auto',
// },
];

View File

@@ -0,0 +1 @@
export { default as TaskPage } from './task.vue';

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { TaskPageProps } from './types';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { ApprovalPanelDrawerComp } from '..';
import { columns, querySchema } from './data';
const props = defineProps<TaskPageProps>();
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await props.listApi({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
/**
* TODO: id
*/
id: 'workflow-task',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: props.type }).open();
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable :table-title="tableTitle" />
<ApprovalPanelDrawer @reload="() => tableApi.query()" />
</Page>
</template>

View File

@@ -0,0 +1,18 @@
import type { ApprovalType } from '../type';
import type { PageResult } from '#/api/common';
import type { TaskInfo } from '#/api/workflow/task/model';
export {};
export interface TaskPageProps {
/**
* 表格显示的标题
*/
tableTitle?: string;
/**
* 类型
*/
type: ApprovalType;
listApi: (params?: any) => Promise<PageResult<TaskInfo>>;
}

View File

@@ -0,0 +1,9 @@
export {};
/**
* myself 我发起的
* readonly 只读 只用于查看
* approve 审批
* admin 流程监控 - 待办任务使用
*/
export type ApprovalType = 'admin' | 'approve' | 'myself' | 'readonly';

View File

@@ -1,18 +1,27 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { markRaw } from 'vue';
import { DictEnum } from '@vben/constants';
import { OptionsTag } from '#/components/table';
import { renderDict } from '#/utils/render';
import { CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper';
import { activityStatusOptions } from '../processDefinition/constant';
export const querySchema: FormSchemaGetter = () => [
/**
* https://github.com/facebook/react/issues/6284
* https://github.com/ant-design/ant-design/issues/2950#issuecomment-245852795
* nodeName算一种`关键字` 会导致报错
*/
{
component: 'Input',
label: '任务名称',
fieldName: 'nodeName',
fieldName: '_nodeName',
},
{
component: 'Input',
@@ -24,6 +33,17 @@ export const querySchema: FormSchemaGetter = () => [
label: '流程编码',
fieldName: 'flowCode',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
];
export const columns: VxeGridProps['columns'] = [

View File

@@ -13,6 +13,7 @@ import { $t } from '@vben/locales';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, RadioGroup, Space } from 'ant-design-vue';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
@@ -95,6 +96,18 @@ const gridOptions: VxeGridProps = {
Reflect.deleteProperty(formValues, 'category');
}
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
);
}
// 使用nodeName会导致选人组件hover报错
if (formValues._nodeName) {
formValues.nodeName = formValues._nodeName;
Reflect.deleteProperty(formValues, '_nodeName');
}
return await currentTypeApi({
pageNum: page.currentPage,
pageSize: page.pageSize,

View File

@@ -1,257 +1,162 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { TaskInfo } from '#/api/workflow/task/model';
import type { VbenFormProps } from '@vben/common-ui';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { ComponentType } from '#/adapter/component';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { getPopupContainer } from '@vben/utils';
import { onMounted } from 'vue';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category';
import { pageByCurrent } from '#/api/workflow/instance';
import { renderDict } from '#/utils/render';
import { ApprovalCard, ApprovalPanel } from '../components';
import { bottomOffset } from './constant';
import { ApprovalPanelDrawerComp } from '../components';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
category: null as null | number, // 流程分类
const formOptions: VbenFormProps<ComponentType> = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: [
{
fieldName: 'category',
component: 'TreeSelect',
label: '流程分类',
},
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
onMounted(async () => {
const tree = await categoryTree();
tableApi.formApi.updateSchema([
{
fieldName: 'category',
componentProps: {
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
// {
// field: 'businessTitle',
// title: '业务标题',
// formatter: ({ cellValue }) => cellValue ?? '-',
// },
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await pageByCurrent({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-myself',
rowClassName: 'cursor-pointer',
};
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
}
loading.value = true;
const resp = await pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'myself' }).open();
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
const { refreshTab } = useTabs();
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="myself" @reload="refreshTab" />
</div>
<BasicTable table-title="我发起的" />
<ApprovalPanelDrawer @reload="() => tableApi.query()" />
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,299 +1,183 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
<script setup lang="tsx">
import type { VbenFormProps } from '@vben/common-ui';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { ComponentType } from '#/adapter/component';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
import { markRaw, onMounted } from 'vue';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskCopy } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
import { ApprovalPanelDrawerComp, CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
const formOptions: VbenFormProps<ComponentType> = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: [
{
fieldName: 'category',
component: 'TreeSelect',
label: '流程分类',
},
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await pageByTaskCopy({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByTaskCopy({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
tableApi.formApi.updateSchema([
{
fieldName: 'category',
componentProps: {
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
// {
// field: 'businessTitle',
// title: '业务标题',
// formatter: ({ cellValue }) => cellValue ?? '-',
// },
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
);
}
return await pageByTaskCopy({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-finish',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'readonly' }).open();
}
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="readonly" />
</div>
<BasicTable table-title="我的抄送" />
<ApprovalPanelDrawer @reload="() => tableApi.query()" />
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,299 +1,183 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
<script setup lang="tsx">
import type { VbenFormProps } from '@vben/common-ui';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { ComponentType } from '#/adapter/component';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
import { markRaw, onMounted } from 'vue';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskFinish } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
import { ApprovalPanelDrawerComp, CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
const formOptions: VbenFormProps<ComponentType> = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: [
{
fieldName: 'category',
component: 'TreeSelect',
label: '流程分类',
},
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await pageByTaskFinish({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByTaskFinish({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
tableApi.formApi.updateSchema([
{
fieldName: 'category',
componentProps: {
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
// {
// field: 'businessTitle',
// title: '业务标题',
// formatter: ({ cellValue }) => cellValue ?? '-',
// },
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
);
}
return await pageByTaskFinish({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-finish',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'readonly' }).open();
}
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="readonly" />
</div>
<BasicTable table-title="已办理任务" />
<ApprovalPanelDrawer @reload="() => tableApi.query()" />
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,302 +1,188 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
<script setup lang="tsx">
import type { VbenFormProps } from '@vben/common-ui';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { ComponentType } from '#/adapter/component';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
import { markRaw, onMounted } from 'vue';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskWait } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
import { ApprovalPanelDrawerComp, CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
const formOptions: VbenFormProps<ComponentType> = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: [
{
fieldName: 'category',
component: 'TreeSelect',
label: '流程分类',
},
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await pageByTaskWait({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByTaskWait({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
const { refreshTab } = useTabs();
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
tableApi.formApi.updateSchema([
{
fieldName: 'category',
componentProps: {
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
{
field: 'businessTitle',
title: '业务标题',
formatter: ({ cellValue }) => cellValue ?? '-',
},
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'processedByName',
title: '办理人',
formatter: ({ cellValue }) => cellValue?.split?.(',') ?? cellValue,
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
);
}
return await pageByTaskWait({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-myself',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'approve' }).open();
}
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="approve" @reload="refreshTab" />
</div>
<BasicTable table-title="我的待办" />
<ApprovalPanelDrawer @reload="() => tableApi.query()" />
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>