Compare commits

...

4 Commits

Author SHA1 Message Date
dap
69f5fd8a4f feat: 重构待办任务页面为表格展示并优化交互逻辑 2025-10-20 13:40:33 +08:00
dap
fd6eabc4d8 feat: 优化审批面板抽屉组件类型与逻辑
- 引入 ApprovalType 类型替代内联联合类型,提升代码可维护性
- 使用 computed 计算属性 showFooter 控制底部操作栏显隐逻辑
- 移除原先通过 drawerApi 动态设置 footer 的方式,改用条件渲染
- 修复 footer 插槽在只读模式下仍渲染内容的问题
- 添加 TODO 注释说明当前 footer 插槽存在的设计问题
2025-10-17 15:41:36 +08:00
dap
7d48dba86a feat: 新增流程实例变量修改功能
新增了流程实例变量的查询与修改接口,并在前端页面中实现了对应的弹窗表单,
支持用户查看和修改流程变量。同时优化了变量展示界面,增加了字段类型标签
和修改限制提示。
2025-10-17 14:45:21 +08:00
dap
c37a0fefa1 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` 类型用于区分不同审批场景(我的申请、审批、管理、只读)
2025-10-17 13:37:20 +08:00
23 changed files with 1913 additions and 1435 deletions

View File

@ -1,5 +1,5 @@
import type { TaskInfo } from '../task/model';
import type { FlowInfoResponse } from './model';
import type { FlowInfoResponse, FlowInstanceVariableResp } from './model';
import type { ID, IDS, PageQuery, PageResult } from '#/api/common';
@ -104,8 +104,8 @@ export function flowInfo(businessId: string) {
* @returns Map<string,any>
*/
export function instanceVariable(instanceId: string) {
return requestClient.get<Record<string, any>>(
`/workflow/instance/variable/${instanceId}`,
return requestClient.get<FlowInstanceVariableResp>(
`/workflow/instance/instanceVariable/${instanceId}`,
);
}
@ -118,3 +118,22 @@ export function workflowInstanceInvalid(data: {
}) {
return requestClient.postWithMsg<void>('/workflow/instance/invalid', data);
}
/**
*
* @param data
* @param data.instanceId ID
* @param data.key key
* @param data.value
* @returns void
*/
export function updateFlowVariable(data: {
instanceId: string;
key: string;
value: any;
}) {
return requestClient.putWithMsg<void>(
'/workflow/instance/updateVariable',
data,
);
}

View File

@ -1,3 +1,5 @@
export {};
export interface Flow {
id: string;
createTime: string;
@ -39,3 +41,14 @@ export interface FlowInfoResponse {
instanceId: string;
list: Flow[];
}
export interface FlowInstanceVariableResp {
/**
* json字符串
*/
variable: string;
variableList: {
key: string;
value: any;
}[];
}

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,169 @@
<script setup lang="ts">
import type { ApprovalType } from './type';
import type { FlowInfoResponse } from '#/api/workflow/instance/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, 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: ApprovalType;
}
const currentTask = ref<null | TaskInfo>(null);
const currentFlowInfo = ref<FlowInfoResponse | null>(null);
const currentType = ref<DrawerProps['type'] | null>(null);
const [BasicDrawer, drawerApi] = useVbenDrawer({
title: '流程',
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();
}
const showFooter = computed(() => {
return ![null, 'readonly'].includes(currentType.value);
});
</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>
<!-- TODO: 暂时只能这样处理 footer常驻但不显示内容 这个插槽有点迷 -->
<FlowActions
v-if="showFooter && currentTask && currentType"
:task="currentTask"
:type="currentType"
@reload="handleAfterAction"
/>
<div v-else></div>
</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,
);
}
// 使nodeNamehover
if (formValues._nodeName) {
formValues.nodeName = formValues._nodeName;
Reflect.deleteProperty(formValues, '_nodeName');
}
return await currentTypeApi({
pageNum: page.currentPage,
pageSize: page.pageSize,
@ -150,7 +163,9 @@ const [InstanceVariableModal, instanceVariableModalApi] = useVbenModal({
connectedComponent: instanceVariableModal,
});
function handleVariable(row: Recordable<any>) {
instanceVariableModalApi.setData({ record: row.variable });
instanceVariableModalApi.setData({
instanceId: row.id,
});
instanceVariableModalApi.open();
}
const [FlowInfoModal, flowInfoModalApi] = useVbenModal({

View File

@ -1,28 +1,178 @@
<script setup lang="ts">
<script setup lang="tsx">
import { ref } from 'vue';
import { JsonPreview, useVbenModal } from '@vben/common-ui';
import { cn, getPopupContainer } from '@vben/utils';
import { Modal, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { instanceVariable, updateFlowVariable } from '#/api/workflow/instance';
interface ModalData {
/**
* 变量 json字符串
*/
record: string;
instanceId: string;
}
const data = ref({});
const [BasicModal, modalApi] = useVbenModal({
title: '流程变量',
fullscreenButton: false,
footer: false,
onOpenChange: (visible) => {
onOpenChange: async (visible) => {
if (!visible) {
data.value = {};
return null;
}
const recordString = modalApi.getData().record;
data.value = JSON.parse(recordString);
modalApi.modalLoading(true);
await loadData();
modalApi.modalLoading(false);
},
});
const fieldTypeColors = {
string: 'cyan',
number: 'blue',
boolean: 'orange',
object: 'purple',
};
function getFieldTypeColor(fieldType: string) {
return (
fieldTypeColors[fieldType as keyof typeof fieldTypeColors] ?? 'default'
);
}
async function loadData() {
const { instanceId } = modalApi.getData() as ModalData;
const resp = await instanceVariable(instanceId);
const jsonObj = JSON.parse(resp.variable);
data.value = jsonObj;
//
const objEntry = Object.entries(jsonObj);
interface OptionsType {
label: string;
value: string;
fieldType: string;
}
formApi.updateSchema([
{
fieldName: 'key',
componentProps: {
options: objEntry.map(
([key, value]) =>
({
label: key,
value: key,
fieldType: typeof value,
}) as OptionsType,
),
},
renderComponentContent: () => ({
option: (option: OptionsType) => (
<div>
{option.label}
<Tag class="ml-1" color={getFieldTypeColor(option.fieldType)}>
{option.fieldType}
</Tag>
</div>
),
}),
},
]);
}
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
allowClear: true,
},
labelWidth: 80,
},
schema: [
{
fieldName: 'key',
component: 'Select',
label: '变量名称',
rules: 'selectRequired',
componentProps: {
getPopupContainer,
},
},
{
fieldName: 'value',
component: 'Input',
label: '变量值',
rules: 'selectRequired',
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '修改',
},
handleSubmit: async (values) => {
console.log(values);
Modal.confirm({
title: '修改流程变量',
content: '确认修改流程变量吗?',
centered: true,
okButtonProps: {
danger: true,
},
onOk: async () => {
await handleSubmit(values);
},
});
},
});
async function handleSubmit(values: any) {
try {
modalApi.lock(true);
const { instanceId } = modalApi.getData() as ModalData;
//
const requestData = {
instanceId,
key: values.key,
value: values.value,
};
await updateFlowVariable(requestData);
await formApi.resetForm();
//
const resp = await instanceVariable(instanceId);
const jsonObj = JSON.parse(resp.variable);
data.value = jsonObj;
} catch (error) {
console.error(error);
} finally {
modalApi.lock(false);
}
}
</script>
<template>
<BasicModal>
<div class="min-h-[400px] overflow-y-auto">
<div
:class="cn('min-h-[400px] overflow-y-auto border', 'rounded-[4px] p-2')"
>
<JsonPreview :data="data" />
</div>
<div class="mt-2 text-sm font-medium text-red-500">
由于限制 只能变更字段为string类型
</div>
<Form class="mt-2" />
</BasicModal>
</template>

View File

@ -1,360 +1,235 @@
<!-- 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 { RadioChangeEvent } from 'ant-design-vue';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import type { VbenFormProps } from '@vben/common-ui';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
import type { ComponentType } from '#/adapter/component';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Segmented,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce, uniqueId } from 'lodash-es';
import { markRaw, onMounted, ref } from 'vue';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { RadioGroup } from 'ant-design-vue';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category';
import { pageByAllTaskFinish, pageByAllTaskWait } 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 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'],
],
],
};
/**
* 流程监控 - 待办任务页面的id不唯一 改为前端处理
*/
interface TaskItem extends TaskInfo {
active: boolean;
randomId: string;
}
const taskList = ref<TaskItem[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
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',
},
},
]);
});
const typeOptions = [
{ label: '待办任务', value: 'todo' },
{ label: '已办任务', value: 'done' },
];
const currentType = ref('todo');
const currentApi = computed(() => {
if (currentType.value === 'todo') {
return pageByAllTaskWait;
{ label: '运行中', value: 'running' },
{ label: '已完成', value: 'finish' },
] as const;
type OptionValue = (typeof typeOptions)[number]['value'];
let currentTypeApi = pageByAllTaskWait;
const currentType = ref<OptionValue>('running');
async function handleTypeChange(e: RadioChangeEvent) {
const { value } = e.target;
switch (value) {
case 'finish': {
currentTypeApi = pageByAllTaskFinish;
break;
}
return pageByAllTaskFinish;
});
const approvalType = computed(() => {
if (currentType.value === 'done') {
return 'readonly';
case 'running': {
currentTypeApi = pageByAllTaskWait;
break;
}
return 'admin';
});
async function handleTypeChange() {
//
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
taskList.value = [];
await nextTick();
await reload(true);
}
const defaultFormData = {
flowName: '', //
nodeName: '', //
flowCode: '', //
createByIds: [] as string[], //
category: null as null | number, //
await tableApi.reload();
}
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 currentTypeApi({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-myself',
rowClassName: 'cursor-pointer',
};
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 currentApi.value({
pageSize: 10,
pageNum: page.value,
...formData.value,
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
taskList.value = resp.rows.map((item) => ({
...item,
active: false,
randomId: uniqueId(),
}));
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;
console.log('scrollTop + clientHeight', scrollTop + clientHeight);
console.log('scrollHeight', scrollHeight);
//
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await currentApi.value({
pageSize: 10,
pageNum: page.value,
...formData.value,
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
taskList.value.push(
...resp.rows.map((item) => ({
...item,
active: false,
randomId: uniqueId(),
})),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskItem) {
const { randomId } = item;
//
if (lastSelectId.value === randomId) {
return;
function handleOpen(row: any) {
let type = '';
switch (currentType.value) {
case 'finish': {
type = 'readonly';
break;
}
currentTask.value = item;
// & &
taskList.value.forEach((item) => {
item.active = item.randomId === randomId;
});
lastSelectId.value = randomId;
case 'running': {
type = 'admin';
break;
}
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;
});
drawerApi.setData({ task: row, type }).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"
>
<Segmented
<BasicTable>
<template #toolbar-actions>
<RadioGroup
v-model:value="currentType"
:options="typeOptions"
block
class="mb-2"
button-style="solid"
option-type="button"
@change="handleTypeChange"
/>
<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.randomId"
:info="item"
class="mx-2"
row-key="randomId"
@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="approvalType"
@reload="refreshTab"
/>
</div>
</BasicTable>
<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,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,
);
// 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);
}
loading.value = true;
const resp = await pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.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',
},
},
]);
});
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 pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.value,
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,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-myself',
rowClassName: 'cursor-pointer',
};
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;
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
cellClick: ({ row }) => {
handleOpen(row);
},
},
});
lastSelectId.value = id;
}
const { refreshTab } = useTabs();
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'myself' }).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
: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>