!11 follow后端发布

* docs: readme
* fix: missing formPath
* chore: 去除锁定的esbuild版本
* perf: 去除debug组件
* perf: 参数键值 自动高度
* refactor: 代码生成配置页面重构 去除步骤条
* perf: 移除文件
* docs: 文件夹说明
* chore: 移除一些配置项
* chore: 注释优化
* refactor: 移除ele和naive目录
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* perf: request support to set how to return response (#5436)
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* refactor: 登录超时的i18n
* fix: requestClient缺失i18n内容
* refactor: 优化oss下载进度提示
* feat: 下载进度loading
* fix: antd button icon style (#5421)
* feat: oss下载进度(已下载的KB 无法作为进度显示 total返回为null)
* fix: 下载文件时(responseType === 'blob')需要判断下载失败(返回json而非二进制)的情况
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* refactor: 新增后跳转到未发布流程
* fix: same name route
* chore: 调整为部署json类型
* fix: mouse events ignored on modal loading (#5409)
* docs: update docs (#5408)
* refactor: 移除已经弃用的方法
* refactor: follow官方handleRangeTimeValue更新
* chore: 删除文件夹(前端路由需要的)
* chore: 修改本地路由写法(新版)/新增本地菜单图标
* fix: form update state error before form mounted (#5406)
* fix: demos route fixed (#5405)
* chore: 不使用基础布局(仅在顶级生效)
* feat: modal state locked on submitting (#5401)
* chore: 修改zIndex
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* refactor: fix popup component zIndex (#5397)
* style: element plus loading style fixed (#5393)
* perf: improve fieldMappingTime to support format function (#5392)
* fix: hide root route in breadcrumb
* feat: support set default props for drawer and modal (#5390)
* fix: root router config fixed (#5389)
* fix: 修改Vxe默认zIndex为995 解决右上角全屏后modal/drawer(zIndex: 1000)被遮挡
* feat: add `noBasicLayout` in route meta (#5386)
* chore: wechat image
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* chore: 改为全局参数配置 去除局部参数
* fix: spinner may stop playing animation after dismiss (#5365)
* style: popover bgColor is too close to common (#5364)
* docs: version update
* docs: changelog
* chore: 文件上传 描述
* ci: retry deploy while faild
* feat: 文件上传 进度条+提示文字
* feat: 文件上传 进度条
* feat: 上传文件格式说明
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into warmflow
* fix: useEcharts return invalid instance (#5360)
* feat: popup component support overlay blur effect (#5359)
* feat: improve `tippy` demo (#5357)
* feat: integrate new component `Tippy` with demo (#5355)
* chore: 优化表格图片显示
* perf: add nested modal demo (#5353)
* chore: 默认显示右边的滚动条 防止出现滚动条被挤压
* perf: modal and drawer api support chain calls (#5351)
* feat: allow close tab when mouse middle button click (#5347)
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into warmflow
* refactor: 重构显示total的逻辑
* chore: 调整高度自适应代码
* chore: vxe升级4.10.0版本(锁定)
* fix: 添加失效的option
* fix: 需要为数组
* fix: locale switching logic correction (#5344)
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into warmflow
* chore: 导入类型优化 解决eslint报红
* refactor: type/注释优化 去除大量any
* fix: vxeGrid init without search form (#5342)
* chore: 锁屏默认false  关闭该功能
* chore: 调整接口
* chore: update deps
* fix: primaryColor calculation (#5337)
* fix: form valid-error style in naive (#5336)
* fix: form `fieldMappingTime` improve and `modelPropName` support (#5335)
* fix: code lint
* fix: form `fieldMappingTime` is not working (#5333)
* chore: 选人组件样式
* fix: download from url triggered twice sometimes (#5319)
* chore: 优化代码
* chore: 动态类名(无效)改为style
* refactor: 字典相关功能重构 采用一个Map储存字典(之前为两个Map)
* feat: 字典支持number类型存储
* chore: 调整样式
* chore: 修改选中border为1px
* chore: 字段
* chore: 改为新窗口打开(适用于pdf/图片)而非直接下载
* chore: 更新样式
* chore: 更新字段
* chore: 改为computed
* chore: 跳转到未发布流程tab
* chore: 优化样式
* docs: readme
* fix: name重复导致的404
* Merge branch 'dev' of https://gitee.com/dapppp/ruoyi-plus-vben5 into warmflow
* chore: 使用legacy来保证copy的兼容性
* chore: 去除log 添加说明
* chore: 优化代码
* feat: 节点关联/节点独立的切换逻辑
* chore: remove logic
* chore: vxe可编辑表格demo
* chore: 不允许在按钮下添加数据
* docs: changelog
* fix: wrong code
* chore: 移除测试菜单
* chore: 优化代码
* refactor: 租户套餐菜单替换为新版
* refactor: 使用新版菜单勾选
* chore: 点行会勾选/取消全部权限  点权限不会勾选行
* chore: 全屏引导+样式优化
* chore: 调整间距
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* feat: useEcharts exports echarts instance#5294 (#5299)
* chore: update quick-start.md (#5303)
* chore: updateCheckedNumber
* refactor: 优化代码
* chore: 优化代码
* chore: 优化样式
* chore: keys依赖于menu 需要先加载menu
* chore: 菜单加载完毕再显示
* feat: 新的菜单选择组件(beta)
* chore: $t
* chore: 测试菜单页面
* chore: 优化代码
* feat: 对ossId回显的支持
* chore: 只获取一次默认密码而非每次打开modal都获取
* fix: vben select placeholder color (#5286)
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* perf: format code with better style (#5283)
* chore: 工作流演示站
* fix: sidebar preferences fixed (#5276)
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* fix: breadcrumb setting not valid for `header-sidebar-nav` layout (#5275)
* fix: header logo may not be collapsed in `header-sidebar-nav` layout (#5274)
* feat: new layout `sidebar nav with full header` (#5270)
* feat: drawer close icon placement (#5269)
* docs: update dialog and drawer docs
* feat: drawer support destroy on close
* feat: drawer support `onOpened` & `onClosed`
* feat: modal support destroy on close
* fix: wrong boolean
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* chore: 调整tab位置
* chore: 删除历史流程 改为tab切换
* fix: header-mixed layout side-menu active (#5265)
* feat: header mixed layout (#5263)
* chore: release 5.5.2
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* chore: downgrade vue-tsc version
* feat: header menu align support (#5256)
* chore: update deps
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* chore: add apiSelect remote search demo (#5246)
* chore: 审批改为description而非disabled的表单
* chore: 改为ts
* chore: 错误的conetnt
* refactor: 终止/转办/委托支持填写意见
* chore: 第一次拿到的是readonly的数据 如果需要修改 需要cloneDeep
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* fix: grid form submit button locale switch (#5205)
* chore: 调整驳回
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* fix: build error (#5199)
* fix: esbuild自动升级导致运行/打包报错
* fix: esbuild自动升级导致运行/打包报错
* chore: 流程定义 激活改为switch
* chore: 流程申请支持上传文件
* chore: title 审批通过
* fix: vxeGrid top padding (#5193)
* fix: 表格排序翻页会丢失排序参数
* chore: 去除log打印
* chore: 流程监控 待办任务
* chore: 我发起的
* chore: 去除已经移除的菜单页面
* chore: 我的已办
* chore: 页面优化
* chore: 重置tooltip
* feat: 我的抄送搜索/优化重复触发的接口
* feat: 流程定义 历史
* chore: 修改分类
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* fix: grid tools in toolbar config not working as expected (#5190)
* feat: add `resizable` and `ColPage` component (#5188)
* chore: 条件
* chore: break-all
* feat: 流程分类 搜索
* chore: 弹窗关闭后仍然显示表单浮层
* chore: 选人组件的样式
* chore: 搜索的样式
* chore: 漏掉的导入
* chore: 最大显示的头像数量 超过显示为省略号头像
* fix: 选人的一些问题
* Merge branch 'warmflow' of https://gitee.com/dapppp/ruoyi-plus-vben5 i…
* chore: 没有更多数据了
* fix: sidebar header height (#5183)
* chore: 搜索表单布局+申请人
* fix: remove the overlap caused by border-b (#5160)
* docs: fix typos (#5169)
* fix: resolve eslint errors as well as TS type errors (#5172)
* chore: enter提交表单
* chore: 修改文案
* chore: 默认全部展开
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* feat: page content class override (#5179)
* fix: sidebar style on focus (#5178)
* fix: 抄送选人 最右侧已选中删除item无效
* feat: 复制
* chore: 昵称过长的显示
* chore: 默认选中第一个
* chore: 修改relative位置
* chore: 搜索
* feat: 我的待办 - 搜索条件
* chore: 流程监控 - 待办任务页面的id不唯一 改为前端处理
* feat: 修改办理人
* chore: 流程干预 - 加签/减签
* chore: avatar大小
* chore: 抄送需要手动添加createByName
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* chore: 审批通过 抄送
* feat: 流程实例-流程预览
* chore: spell
* chore: clientid
* chore: 分类条件
* chore: 修改办理人
* chore: 更改postMessage参数
* chore: 内嵌iframe高度根据表单高度调整
* chore: 流程详情
* feat: 抄送选择
* chore: 调整分类树
* fix: user homePath no effect sometimes (#5166)
* feat: form compact mode support (#5165)
* fix: form auto submit no effect when showDefaultActions is false (#5163)
* chore: 修改width
* feat: 待办任务
* feat: 我的抄送
* chore: 流程定义 样式
* chore: 退回后重新申请
* chore: 请假申请布局
* chore: 请假申请-并行会签网关
* chore: 分类去除根目录
* chore: 详情modal(未完成)
* chore: 请假申请根据不同状态显示按钮
* chore: 流程删除/撤销
* chore: 审批完成后刷新当前页
* feat: 选人组件(未完成) 加签减签
* docs: fix docs-link and add `EllipsisText` docs (#5158)
* chore: 新窗口打开文件
* chore: 审批通过
* chore: 使用useEventListener替换原生
* chore: 字段错误
* chore: iframe通信 加载完毕后才显示表单 解决卡顿问题
* chore: 审批终止/驳回
* chore: 附件图标
* chore: process_running显示按钮
* chore: label错误
* chore: 保存的事件
* chore: 需要加上clientId
* Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
* feat: form `colon` support (#5156)
* chore: 完善请假申请
* feat: improve code login demo (#5154)
* chore: 客户端管理 行高自适应
* chore: 内嵌表单的路径
* chore: 修改avatar背景色
* chore: 注释
* chore: activePath
* chore: leave表单
* chore: 修改请假demo路径
* chore: categoryId
* chore: 我的已办
* chore: 我发起的
* chore: loading
* chore: 历史版本
* chore: 完善task api
* chore: 隐藏'菜单加载中'
* chore: missing import
* feat: add demo for modify menu badge data
* chore: 流程实例
* chore: 审批附件
* chore: 我的待办 提取公共组件
* chore: 流程部署
* chore: 新增/编辑/导出xml
* chore: 流程定义(除历史版本)
* feat: `autoActivateChild` support more layout mode (#5148)
* feat: auto activate subMenu on select root menu (#5147)
* fix: `disabledOnChangeListener` not work in form (#5146)
* fix: login expired modal z-index (#5145)
* feat: user-dropdown support `hover` trigger (#5143)
* fix: pinInput value synchronous update (#5142)
* fix: vxeGrid default sort data no effect in first query (#5141)
* fix: vscode debug profile (#5140)
* fix: form component events bind (#5137)
* chore: 在线用户样式 开启虚拟滚动
* chore: 去掉个人中心 在线设备的分页
* chore: 去掉在线用户的分页
* chore: changelog
* refactor: 获取字典的方法 提取公共函数 减少冗余代码
* fix: element plus validate failed style (#5130)
* chore: 使用私有桶的提示
* feat: tabbar support mouse wheel vertical (#5129)
* fix: form support `disabledOnInputListener` (#5127)
* fix: form submission not appropriate (#5126)
* Merge branch 'main' of https://gitee.com/dapppp/ruoyi-plus-vben5 into dev
* chore: release 5.5.1
* feat: table search form visible control (#5121)
* chore: 需要隐藏菜单
* chore: 我的待办 & 请假
* chore: 流程定义(未完成)
* chore: 流程定义(开发中)
* Merge branch 'main' of https://gitee.com/dapppp/ruoyi-plus-vben5 into dev
* Merge branch 'main' of https://gitee.com/dapppp/ruoyi-plus-vben5 into dev
* Merge branch 'main' of https://gitee.com/dapppp/ruoyi-plus-vben5 into dev
* chore: version
* chore: 锁定vxe-table版本 4.9.8版本存在样式问题
* chore: 暂时锁定cspell版本
* refactor: 由于不能输入 需要使用watch监听
* chore: https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IB7ANL
* chore: 移除冗余代码
* chore: 组件卸载时移除emitter
* fix: the route path did not synchronize with the page (#4947)
* style: typo (#4948)
* chore: 替换为commonDownloadExcel
* fix: 左边部门树错误emit导致会调用两次列表api
* chore: label样式
* chore: 改为Textarea
* chore: 滚动条宽度
* chore: 审批样式
* chore: 部门及以下或本人数据权限
* Merge branch 'main' of https://gitee.com/dapppp/ruoyi-plus-vben5 into dev
* chore: 个人中心强退设备接口路径
This commit is contained in:
玲娜贝er
2025-01-20 03:43:19 +00:00
parent 8ab0bd2212
commit 29f9f3de31
729 changed files with 12839 additions and 9132 deletions

View File

@@ -3,7 +3,12 @@ import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep, getPopupContainer, listToTree } from '@vben/utils';
import {
addFullName,
cloneDeep,
getPopupContainer,
listToTree,
} from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import {
@@ -40,25 +45,20 @@ const [BasicForm, formApi] = useVbenForm({
async function setupCategorySelect() {
const listData = await categoryList();
let treeData = listToTree(listData, {
id: 'id',
const treeData = listToTree(listData, {
id: 'categoryId',
pid: 'parentId',
});
treeData = [
{
categoryName: '根目录',
id: 0,
children: treeData,
},
];
addFullName(treeData, 'categoryName', ' / ');
formApi.updateSchema([
{
fieldName: 'parentId',
componentProps: {
treeData,
treeLine: { showLeafIcon: false },
fieldNames: { label: 'categoryName', value: 'id' },
fieldNames: { label: 'categoryName', value: 'categoryId' },
treeDefaultExpandAll: true,
treeNodeLabelProp: 'fullName',
getPopupContainer,
},
},
@@ -120,7 +120,11 @@ async function handleCancel() {
</script>
<template>
<BasicModal :close-on-click-modal="false" :title="title">
<BasicModal
:close-on-click-modal="false"
:title="title"
class="min-h-[500px]"
>
<BasicForm />
</BasicModal>
</template>

View File

@@ -21,11 +21,7 @@ export const columns: VxeGridProps['columns'] = [
treeNode: true,
},
{
field: 'categoryCode',
title: '分类编码',
},
{
field: 'sortNum',
field: 'orderNum',
title: '排序',
},
{
@@ -39,8 +35,8 @@ export const columns: VxeGridProps['columns'] = [
export const modalSchema: FormSchemaGetter = () => [
{
label: 'id',
fieldName: 'id',
label: 'categoryId',
fieldName: 'categoryId',
component: 'Input',
dependencies: {
show: () => false,
@@ -51,7 +47,7 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'parentId',
label: '父级分类',
rules: 'required',
defaultValue: 0,
defaultValue: 100,
component: 'TreeSelect',
},
{
@@ -61,15 +57,8 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'required',
},
{
fieldName: 'categoryCode',
label: '分类编码',
component: 'Input',
rules: 'required',
},
{
fieldName: 'sortNum',
fieldName: 'orderNum',
label: '排序',
component: 'InputNumber',
rules: 'required',
},
];

View File

@@ -56,11 +56,11 @@ const gridOptions: VxeGridProps = {
gt: 0,
},
rowConfig: {
keyField: 'id',
keyField: 'categoryId',
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
rowField: 'categoryId',
transform: true,
},
// 表格全局唯一表示 保存列配置需要用到
@@ -73,17 +73,17 @@ const [CategoryModal, modalApi] = useVbenModal({
});
function handleAdd(row?: Recordable<any>) {
modalApi.setData({ parentId: row?.id });
modalApi.setData({ parentId: row?.categoryId });
modalApi.open();
}
async function handleEdit(row: Recordable<any>) {
modalApi.setData({ id: row.id });
modalApi.setData({ id: row.categoryId });
modalApi.open();
}
async function handleDelete(row: Recordable<any>) {
await categoryRemove(row.id);
await categoryRemove(row.categoryId);
await tableApi.query();
}

View File

@@ -0,0 +1,135 @@
<!-- 流程发起(启动)的弹窗 -->
<script setup lang="ts">
import type { CompleteTaskReqData } from '#/api/workflow/task/model';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { completeTask } from '#/api/workflow/task';
import { CopyComponent } from '.';
interface Emits {
/**
* 完成
*/
complete: [];
}
const emit = defineEmits<Emits>();
interface ModalProps {
taskId: string;
taskVariables: Record<string, any>;
variables?: any; // 这个干啥的
}
const [BasicModal, modalApi] = useVbenModal({
title: '流程发起',
fullscreenButton: false,
onConfirm: handleSubmit,
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: [
{
fieldName: 'messageType',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '站内信', value: '1', disabled: true },
{ label: '邮件', value: '2' },
{ label: '短信', value: '3' },
],
},
label: '通知方式',
defaultValue: ['1'],
},
{
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
resultField: 'ossId',
maxNumber: 10,
maxSize: 20,
accept: [
'png',
'jpg',
'jpeg',
'doc',
'docx',
'xlsx',
'xls',
'ppt',
'pdf',
],
},
defaultValue: [],
label: '附件上传',
formItemClass: 'items-start',
},
{
fieldName: 'flowCopyList',
component: 'Input',
defaultValue: [],
label: '抄送人',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { messageType, flowCopyList, attachment } = cloneDeep(
await formApi.getValues(),
);
const { taskId, taskVariables, variables } =
modalApi.getData() as ModalProps;
// 需要转换数据 抄送人员
const flowCCList = (flowCopyList as Array<any>).map((item) => ({
userId: item.userId,
userName: item.nickName,
}));
const requestData = {
fileId: attachment.join(','),
messageType,
flowCopyList: flowCCList,
taskId,
taskVariables,
variables,
} as CompleteTaskReqData;
await completeTask(requestData);
modalApi.close();
emit('complete');
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm>
<template #flowCopyList="slotProps">
<CopyComponent v-model:user-list="slotProps.modelValue" />
</template>
</BasicForm>
</BasicModal>
</template>

View File

@@ -1,51 +1,83 @@
<script setup lang="ts">
import { Avatar, Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
import type { TaskInfo } from '#/api/workflow/task/model';
interface Props {
id: string;
endTime: string;
startTime: string;
title: string;
desc: string;
status: string;
import { computed } from 'vue';
import { VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue';
import { renderDict } from '#/utils/render';
import { getDiffTimeString } from './helper';
interface Props extends TaskInfo {
active: boolean;
}
const props = withDefaults(defineProps<{ info: Props }>(), {});
const props = withDefaults(defineProps<{ info: Props; rowKey?: string }>(), {
rowKey: 'id',
});
const emit = defineEmits<{ click: [string] }>();
/**
* TODO: 这里要优化 事件没有用到
*/
function handleClick() {
emit('click', props.info.id);
const idKey = props.rowKey as keyof TaskInfo;
emit('click', props.info[idKey]);
}
const diffUpdateTimeString = computed(() => {
return getDiffTimeString(props.info.updateTime);
});
</script>
<template>
<div
:class="{
'border-primary': info.active,
'border-[2px]': info.active,
}"
class="cursor-pointer rounded-lg border-[1px] border-solid p-3 transition-shadow duration-300 ease-in-out hover:shadow-lg"
@click.stop="handleClick"
>
<Descriptions :column="1" :title="info.title" size="middle">
<Descriptions :column="1" :title="info.flowName" size="middle">
<template #extra>
<Tag color="warning">审批中</Tag>
</template>
<DescriptionsItem label="描述">{{ info.desc }}</DescriptionsItem>
<DescriptionsItem label="开始时间">{{ info.startTime }}</DescriptionsItem>
<DescriptionsItem label="结束时间">{{ info.endTime }}</DescriptionsItem>
</Descriptions>
<div class="flex items-center justify-between text-[14px]">
<div class="flex items-center gap-1">
<Avatar
size="small"
src="https://plus.dapdap.top/minio-server/plus/2024/11/21/925ed278e2d441beb7f695b41e13c4dd.jpg"
<component
:is="renderDict(info.flowStatus, DictEnum.WF_BUSINESS_STATUS)"
/>
<span class="opacity-50">疯狂的牛子Li</span>
</template>
<DescriptionsItem label="当前任务">
<div class="font-bold">{{ info.nodeName }}</div>
</DescriptionsItem>
<DescriptionsItem label="提交时间">
{{ info.createTime }}
</DescriptionsItem>
<!-- <DescriptionsItem label="更新时间">
{{ info.updateTime }}
</DescriptionsItem> -->
</Descriptions>
<div class="flex w-full items-center justify-between text-[14px]">
<div class="flex items-center gap-1 overflow-hidden whitespace-nowrap">
<VbenAvatar
:alt="info.createByName"
class="bg-primary size-[24px] rounded-full text-[10px] text-white"
src=""
/>
<span class="overflow-hidden text-ellipsis opacity-50">
{{ info.createByName }}
</span>
</div>
<div class="text-nowrap opacity-50">
<Tooltip placement="top" :title="`更新时间: ${info.updateTime}`">
<div class="flex items-center gap-1">
<span class="icon-[mdi--clock-outline] size-[16px]"></span>
<span>{{ diffUpdateTimeString }}前更新</span>
</div>
</Tooltip>
</div>
<div class="opacity-50">处理时间: 2022-01-01</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<!-- 审批终止 Modal弹窗的content属性专用 用于填写审批意见 -->
<script setup lang="ts">
import { Textarea } from 'ant-design-vue';
defineOptions({
name: 'ApprovalContent',
inheritAttrs: false,
});
defineProps<{ description: string; value: string }>();
defineEmits<{ 'update:value': [string] }>();
</script>
<template>
<div class="flex flex-col gap-2">
<div>{{ description }}</div>
<Textarea
:allow-clear="true"
:auto-size="true"
:value="value"
placeholder="审批意见(可选)"
@change="(e) => $emit('update:value', e.target.value!)"
/>
</div>
</template>

View File

@@ -0,0 +1,154 @@
<!-- 审批同意的弹窗 -->
<!-- 审批驳回窗口 -->
<script setup lang="ts">
import type { CompleteTaskReqData } from '#/api/workflow/task/model';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from '@vben/utils';
import { omit } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { completeTask } from '#/api/workflow/task';
import { CopyComponent } from '.';
const emit = defineEmits<{ complete: [] }>();
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: [
{
fieldName: 'taskId',
component: 'Input',
label: '任务ID',
dependencies: {
show: false,
triggerFields: [''],
},
},
{
fieldName: 'messageType',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '站内信', value: '1', disabled: true },
{ label: '邮件', value: '2' },
{ label: '短信', value: '3' },
],
},
label: '通知方式',
defaultValue: ['1'],
},
{
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
resultField: 'ossId',
maxNumber: 10,
maxSize: 20,
accept: [
'png',
'jpg',
'jpeg',
'doc',
'docx',
'xlsx',
'xls',
'ppt',
'pdf',
],
},
defaultValue: [],
label: '附件上传',
formItemClass: 'items-start',
},
{
fieldName: 'flowCopyList',
component: 'Input',
defaultValue: [],
label: '抄送人',
},
{
fieldName: 'message',
component: 'Textarea',
label: '审批意见',
formItemClass: 'items-baseline',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
interface ModalProps {
taskId: string;
}
const [BasicModal, modalApi] = useVbenModal({
title: '审批通过',
fullscreenButton: false,
class: 'min-h-[365px]',
onConfirm: handleSubmit,
async onOpenChange(isOpen) {
if (!isOpen) {
await formApi.resetForm();
return null;
}
modalApi.modalLoading(true);
const { taskId } = modalApi.getData() as ModalProps;
await formApi.setFieldValue('taskId', taskId);
modalApi.modalLoading(false);
},
});
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
// 需要转换数据 抄送人员
const flowCopyList = (data.flowCopyList as Array<any>).map((item) => ({
userId: item.userId,
userName: item.nickName,
}));
const requestData = {
...omit(data, ['attachment']),
fileId: data.attachment.join(','),
taskVariables: {},
variables: {},
flowCopyList,
} as CompleteTaskReqData;
await completeTask(requestData);
modalApi.close();
emit('complete');
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm>
<template #flowCopyList="slotProps">
<CopyComponent v-model:user-list="slotProps.modelValue" />
</template>
</BasicForm>
</BasicModal>
</template>

View File

@@ -0,0 +1,520 @@
<script setup lang="ts">
import type { User } from '#/api/core/user';
import type { FlowInfoResponse } from '#/api/workflow/instance/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Fallback, useVbenModal, VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils';
import { CopyOutlined } from '@ant-design/icons-vue';
import { useClipboard, useEventListener } from '@vueuse/core';
import {
Card,
Divider,
Dropdown,
Menu,
MenuItem,
message,
Modal,
Skeleton,
Space,
TabPane,
Tabs,
} from 'ant-design-vue';
import { isObject } from 'lodash-es';
import {
cancelProcessApply,
deleteByInstanceIds,
flowInfo,
} from '#/api/workflow/instance';
import {
getTaskByTaskId,
taskOperation,
terminationTask,
updateAssignee,
} from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import {
approvalModal,
approvalRejectionModal,
ApprovalTimeline,
flowInterfereModal,
} from '.';
import { approveWithReasonModal } from './helper';
import userSelectModal from './user-select-modal.vue';
defineOptions({
name: 'ApprovalPanel',
inheritAttrs: false,
});
// eslint-disable-next-line no-use-before-define
const props = defineProps<{ task?: TaskInfo; type: ApprovalType }>();
/**
* 下面按钮点击后会触发的事件
*/
const emit = defineEmits<{ reload: [] }>();
const currentTask = ref<TaskInfo>();
/**
* 是否显示 加签/减签操作
*/
const showMultiActions = computed(() => {
if (!currentTask.value) {
return false;
}
if (Number(currentTask.value.nodeRatio) > 0) {
return true;
}
return false;
});
/**
* myself 我发起的
* readonly 只读 只用于查看
* approve 审批
* admin 流程监控 - 待办任务使用
*/
type ApprovalType = 'admin' | 'approve' | 'myself' | 'readonly';
const showFooter = computed(() => {
if (props.type === 'readonly') {
return false;
}
// 我发起的 && [已完成, 已作废] 不显示
if (
props.type === 'myself' &&
['finish', 'invalid'].includes(props.task?.flowStatus ?? '')
) {
return false;
}
return true;
});
const currentFlowInfo = ref<FlowInfoResponse>();
/**
* card的loading状态
*/
const loading = ref(false);
const iframeLoaded = ref(false);
const iframeHeight = ref(300);
useEventListener('message', (event) => {
const data = event.data as { [key: string]: any; type: string };
if (!isObject(data)) return;
/**
* iframe通信 加载完毕后才显示表单 解决卡顿问题
*/
if (data.type === 'mounted') {
iframeLoaded.value = true;
}
/**
* 高度与表单高度保持一致
*/
if (data.type === 'height') {
const height = data.height;
iframeHeight.value = height;
}
});
async function handleLoadInfo(task: TaskInfo | undefined) {
try {
if (!task) return null;
loading.value = true;
iframeLoaded.value = false;
const resp = await flowInfo(task.businessId);
currentFlowInfo.value = resp;
const taskResp = await getTaskByTaskId(props.task!.id);
currentTask.value = taskResp;
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
watch(() => props.task, handleLoadInfo);
onUnmounted(() => (currentFlowInfo.value = undefined));
// 进行中 可以撤销
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() {
approvalModalApi.setData({ taskId: props.task?.id });
approvalModalApi.open();
}
/**
* TODO: 1提取公共函数 2原版是可以填写意见的(message参数)
*/
/**
* 委托
*/
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');
},
});
}
/**
* 不加legacy在本地开发没有问题
* 打包后在一些设备会无法复制 使用legacy来保证兼容性
*/
const { copy } = useClipboard({ legacy: true });
async function handleCopy(text: string) {
await copy(text);
message.success('复制成功');
}
</script>
<template>
<Card
v-if="task"
:body-style="{ overflowY: 'auto', height: '100%' }"
:loading="loading"
class="thin-scrollbar flex-1 overflow-y-hidden"
size="small"
>
<template #title>
<div class="flex items-center gap-2">
<div>编号: {{ task.id }}</div>
<CopyOutlined class="cursor-pointer" @click="handleCopy(task.id)" />
</div>
</template>
<template #extra>
<a-button size="small" @click="() => handleLoadInfo(task)">
<div class="flex items-center justify-center">
<span class="icon-[material-symbols--refresh] size-24px"></span>
</div>
</a-button>
</template>
<div 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">{{ task.flowName }}</div>
<div>
<component
:is="renderDict(task.flowStatus, DictEnum.WF_BUSINESS_STATUS)"
/>
</div>
</div>
<div class="flex items-center gap-2">
<VbenAvatar
:alt="task.createByName"
class="bg-primary size-[28px] rounded-full text-white"
src=""
/>
<span>{{ task.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>
流程分类: {{ task.categoryName }}
</div>
<Divider type="vertical" />
<div class="flex items-center gap-1">
<span class="icon-[mdi--clock-outline] size-[16px]"></span>
提交时间: {{ task.createTime }}
</div>
</div>
</div>
</div>
<Tabs v-if="currentFlowInfo" class="flex-1">
<TabPane key="1" tab="审批详情">
<div class="h-fulloverflow-y-auto">
<!-- 约定${task.formPath}/frame 为内嵌表单 用于展示 需要在本地路由添加 -->
<iframe
v-show="iframeLoaded"
:src="`${task.formPath}/iframe?readonly=true&id=${task.businessId}`"
:style="{ height: `${iframeHeight}px` }"
class="w-full"
></iframe>
<Skeleton v-show="!iframeLoaded" :paragraph="{ rows: 6 }" active />
<Divider />
<ApprovalTimeline :list="currentFlowInfo.list" />
</div>
</TabPane>
<TabPane key="2" tab="审批流程图">
<img
:src="`data:image/png;base64,${currentFlowInfo.image}`"
class="rounded-lg border"
/>
</TabPane>
</Tabs>
</div>
<!-- 固定底部 -->
<div class="h-[57px]"></div>
<div
v-if="showFooter"
class="border-t-solid bg-background absolute bottom-0 left-0 w-full border-t-[1px] p-3"
>
<div class="flex justify-end">
<Space v-if="type === 'myself'">
<a-button
v-if="revocable"
danger
type="primary"
@click="handleCancel"
>
撤销申请
</a-button>
<a-button v-if="editableAndRemoveable" @click="handleEdit">
重新编辑
</a-button>
<a-button
v-if="editableAndRemoveable"
danger
type="primary"
@click="handleRemove"
>
删除
</a-button>
</Space>
<Space v-if="type === 'approve'">
<a-button type="primary" @click="handleApproval">通过</a-button>
<a-button danger type="primary" @click="handleTermination">
终止
</a-button>
<a-button danger type="primary" @click="handleRejection">
驳回
</a-button>
<Dropdown
:get-popup-container="getPopupContainer"
placement="bottomRight"
>
<template #overlay>
<Menu>
<MenuItem key="1" @click="() => delegationModalApi.open()">
委托
</MenuItem>
<MenuItem key="2" @click="() => transferModalApi.open()">
转办
</MenuItem>
<MenuItem
v-if="showMultiActions"
key="3"
@click="() => addSignatureModalApi.open()"
>
加签
</MenuItem>
<MenuItem
v-if="showMultiActions"
key="4"
@click="() => reductionSignatureModalApi.open()"
>
减签
</MenuItem>
</Menu>
</template>
<a-button> 其他 </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>
</Card>
<Fallback v-else title="点击左侧选择" />
</template>

View File

@@ -0,0 +1,131 @@
<!-- 审批驳回窗口 -->
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep, getPopupContainer } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { backProcess, getBackTaskNode } from '#/api/workflow/task';
const emit = defineEmits<{ complete: [] }>();
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: [
{
fieldName: 'taskId',
component: 'Input',
label: '任务ID',
dependencies: {
show: false,
triggerFields: [''],
},
},
{
fieldName: 'messageType',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '站内信', value: '1', disabled: true },
{ label: '邮件', value: '2' },
{ label: '短信', value: '3' },
],
},
label: '通知方式',
defaultValue: ['1'],
},
{
fieldName: 'nodeCode',
component: 'Select',
componentProps: {
getPopupContainer,
},
label: '驳回节点',
},
{
fieldName: 'message',
component: 'Textarea',
label: '审批意见',
formItemClass: 'items-baseline',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
interface ModalProps {
taskId: string;
definitionId: string;
nodeCode: string;
}
const [BasicModal, modalApi] = useVbenModal({
title: '审批驳回',
fullscreenButton: false,
class: 'min-h-[365px]',
onConfirm: handleSubmit,
async onOpenChange(isOpen) {
if (!isOpen) {
await formApi.resetForm();
return null;
}
modalApi.modalLoading(true);
const { taskId, definitionId, nodeCode } = modalApi.getData() as ModalProps;
await formApi.setFieldValue('taskId', taskId);
const resp = await getBackTaskNode(definitionId, nodeCode);
const options = resp.map((item) => ({
label: item.nodeName,
value: item.nodeCode,
}));
formApi.updateSchema([
{
fieldName: 'nodeCode',
componentProps: {
options,
},
},
]);
// 默认选中第一个节点
if (options.length > 0) {
formApi.setFieldValue('nodeCode', options[0]?.value);
}
modalApi.modalLoading(false);
},
});
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
console.log(data);
await backProcess(data);
modalApi.close();
emit('complete');
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm />
</BasicModal>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { Flow } from '#/api/workflow/instance/model';
import { onMounted, ref } from 'vue';
import { VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { TimelineItem } from 'ant-design-vue';
import { ossInfo } from '#/api/system/oss';
import { renderDict } from '#/utils/render';
defineOptions({
name: 'ApprovalTimelineItem',
inheritAttrs: false,
});
const props = defineProps<{ item: Flow }>();
interface AttachmentInfo {
ossId: string;
url: string;
name: string;
}
/**
* 处理附件信息
*/
const attachmentInfo = ref<AttachmentInfo[]>([]);
onMounted(async () => {
if (!props.item.ext) {
return null;
}
const resp = await ossInfo(props.item.ext.split(','));
attachmentInfo.value = resp.map((item) => ({
ossId: item.ossId,
url: item.url,
name: item.originalName,
}));
});
</script>
<template>
<TimelineItem :key="item.id">
<template #dot>
<div class="relative rounded-full border">
<VbenAvatar
:alt="item.approveName"
class="bg-primary size-[36px] rounded-full text-white"
src=""
/>
</div>
</template>
<div class="ml-2 flex flex-col gap-0.5">
<div class="flex items-center gap-1">
<div class="font-bold">{{ item.nodeName }}</div>
<component :is="renderDict(item.flowStatus, DictEnum.WF_TASK_STATUS)" />
</div>
<div>{{ item.approveName }}</div>
<div>{{ item.updateTime }}</div>
<div v-if="item.message" class="rounded-lg border p-1">
<div class="break-all opacity-70">{{ item.message }}</div>
</div>
<div v-if="attachmentInfo.length > 0" class="flex flex-wrap gap-2">
<!-- 这里下载的文件名不是原始文件名 -->
<a
v-for="attachment in attachmentInfo"
:key="attachment.ossId"
:href="attachment.url"
class="text-primary"
target="_blank"
>
<div class="flex items-center gap-1">
<span class="icon-[mingcute--attachment-line] size-[18px]"></span>
<span>{{ attachment.name }}</span>
</div>
</a>
</div>
</div>
</TimelineItem>
</template>

View File

@@ -1,50 +1,21 @@
<script setup lang="ts">
import { Timeline, TimelineItem } from 'ant-design-vue';
import type { Flow } from '#/api/workflow/instance/model';
/**
* TODO: 仅为demo 后期会替换
*/
import { VbenAvatar } from '../../../../../../packages/@core/ui-kit/shadcn-ui/src/components';
import { Timeline } from 'ant-design-vue';
interface ApprovalItem {
id: string;
name: string;
status: string;
remark?: string;
time: string;
}
import ApprovalTimelineItem from './approval-timeline-item.vue';
const props = defineProps<{
list: ApprovalItem[];
list: Flow[];
}>();
</script>
<template>
<Timeline>
<TimelineItem v-for="item in props.list" :key="item.id">
<template #dot>
<div class="relative rounded-full border">
<VbenAvatar
class="size-[36px]"
src="https://plus.dapdap.top/minio-server/plus/2024/11/21/925ed278e2d441beb7f695b41e13c4dd.jpg"
/>
<div
class="border-background absolute bottom-0 right-0 size-[16px] rounded-full border-2 bg-green-500 content-['']"
>
<div class="flex items-center justify-center">
<span class="icon-[mdi--success-bold] text-white"></span>
</div>
</div>
</div>
</template>
<div class="ml-2 flex flex-col">
<div>发起人</div>
<div>疯狂的牛子Li</div>
<div>2022-01-01 12:00:00</div>
<div class="rounded-lg border p-1">
<span class="opacity-70">这里是备注信息</span>
</div>
</div>
</TimelineItem>
<Timeline v-if="props.list.length > 0">
<ApprovalTimelineItem
v-for="(item, index) in props.list"
:key="index"
:item="item"
/>
</Timeline>
</template>

View File

@@ -0,0 +1,85 @@
<!--抄送组件-->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import { computed, type PropType } from 'vue';
import { useVbenModal, VbenAvatar } from '@vben/common-ui';
import { Avatar, AvatarGroup, Tooltip } from 'ant-design-vue';
import { userSelectModal } from '.';
defineOptions({
name: 'CopyComponent',
inheritAttrs: false,
});
const props = withDefaults(defineProps<{ ellipseNumber?: number }>(), {
/**
* 最大显示的头像数量 超过显示为省略号头像
*/
ellipseNumber: 3,
});
const emit = defineEmits<{ cancel: []; finish: [User[]] }>();
const [UserSelectModal, modalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
const userListModel = defineModel('userList', {
type: Array as PropType<User[]>,
default: () => [],
});
function handleOpen() {
modalApi.setData({ userList: userListModel.value });
modalApi.open();
}
function handleFinish(userList: User[]) {
// 清空 直接赋值[]会丢失响应性
userListModel.value.splice(0, userListModel.value.length);
userListModel.value.push(...userList);
emit('finish', userList);
}
const displayedList = computed(() => {
return userListModel.value.slice(0, props.ellipseNumber);
});
</script>
<template>
<div class="flex items-center gap-2">
<AvatarGroup v-if="userListModel.length > 0">
<Tooltip
v-for="user in displayedList"
:key="user.userId"
:title="user.nickName"
placement="top"
>
<div>
<VbenAvatar
:alt="user.nickName"
class="bg-primary size-[36px] cursor-pointer rounded-full border text-white"
src=""
/>
</div>
</Tooltip>
<Tooltip
:title="`等${userListModel.length - props.ellipseNumber}人`"
placement="top"
>
<Avatar
v-if="userListModel.length > ellipseNumber"
class="flex size-[36px] cursor-pointer items-center justify-center rounded-full border bg-[gray] text-white"
>
+{{ userListModel.length - props.ellipseNumber }}
</Avatar>
</Tooltip>
</AvatarGroup>
<a-button size="small" @click="handleOpen">选择人员</a-button>
<UserSelectModal @cancel="$emit('cancel')" @finish="handleFinish" />
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { useAppConfig, useTabs } from '@vben/hooks';
import { stringify } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { useEventListener } from '@vueuse/core';
defineOptions({ name: 'FlowDesigner' });
const route = useRoute();
const definitionId = route.query.definitionId as string;
const disabled = route.query.disabled === 'true';
const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
const params = {
Authorization: `Bearer ${accessStore.accessToken}`,
id: definitionId,
clientid: clientId,
disabled,
};
/**
* iframe设计器的地址
*/
const url = `${import.meta.env.VITE_GLOB_API_URL}/warm-flow-ui/index.html?${stringify(params)}`;
const { closeCurrentTab } = useTabs();
const router = useRouter();
function messageHandler(event: MessageEvent) {
switch (event.data.method) {
case 'close': {
// 关闭当前tab
closeCurrentTab();
// 跳转到流程定义列表
router.push('/workflow/processDefinition');
break;
}
}
}
// iframe监听组件内设计器保存事件
useEventListener('message', messageHandler);
</script>
<template>
<iframe :src="url" class="size-full"></iframe>
</template>

View File

@@ -0,0 +1,38 @@
<!-- 弹窗查看流程信息 -->
<script setup lang="ts">
import type { TaskInfo } from '#/api/workflow/task/model';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { getTaskByBusinessId } from '#/api/workflow/instance';
import { ApprovalPanel } from '.';
interface ModalProps {
businessId: string;
}
const taskInfo = ref<TaskInfo>();
const [BasicModal, modalApi] = useVbenModal({
title: '流程信息',
class: 'w-[1000px]',
footer: false,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
const { businessId } = modalApi.getData() as ModalProps;
const taskResp = await getTaskByBusinessId(businessId);
taskInfo.value = taskResp;
},
});
</script>
<template>
<BasicModal>
<ApprovalPanel :task="taskInfo" type="readonly" />
</BasicModal>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Modal } from 'ant-design-vue';
import {
getTaskByTaskId,
taskOperation,
terminationTask,
} from '#/api/workflow/task';
import { userSelectModal } from '.';
const emit = defineEmits<{ complete: [] }>();
const taskInfo = ref<TaskInfo>();
/**
* 是否显示 加签/减签操作
*/
const showMultiActions = computed(() => {
if (!taskInfo.value) {
return false;
}
if (Number(taskInfo.value.nodeRatio) > 0) {
return true;
}
return false;
});
const [BasicModal, modalApi] = useVbenModal({
title: '流程干预',
class: 'w-[800px]',
fullscreenButton: false,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
const { taskId } = modalApi.getData() as { taskId: string };
taskInfo.value = await getTaskByTaskId(taskId);
},
});
/**
* 转办
*/
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleTransfer(userList: User[]) {
if (userList.length === 0 || !taskInfo.value) return;
const current = userList[0];
Modal.confirm({
title: '转办',
content: `确定转办给${current?.nickName}吗?`,
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: taskInfo.value!.id, userId: current!.userId },
'transferTask',
);
emit('complete');
},
});
}
/**
* 审批终止
*/
function handleTermination() {
if (!taskInfo.value) {
return;
}
Modal.confirm({
title: '审批终止',
content: '确定终止当前审批流程吗?',
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await terminationTask({ taskId: taskInfo.value!.id });
emit('complete');
},
});
}
const [AddSignatureModal, addSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleAddSignature(userList: User[]) {
if (userList.length === 0 || !taskInfo.value) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认加签吗?',
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: taskInfo.value!.id, userIds },
'addSignature',
);
emit('complete');
},
});
}
const [ReductionSignatureModal, reductionSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleReductionSignature(userList: User[]) {
if (userList.length === 0 || !taskInfo.value) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认减签吗?',
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: taskInfo.value!.id, userIds },
'reductionSignature',
);
emit('complete');
},
});
}
</script>
<template>
<BasicModal>
<Descriptions v-if="taskInfo" :column="2" bordered size="small">
<DescriptionsItem label="任务名称">
{{ taskInfo.nodeName }}
</DescriptionsItem>
<DescriptionsItem label="节点编码">
{{ taskInfo.nodeCode }}
</DescriptionsItem>
<DescriptionsItem label="开始时间">
{{ taskInfo.createTime }}
</DescriptionsItem>
<DescriptionsItem label="流程实例ID">
{{ taskInfo.instanceId }}
</DescriptionsItem>
<DescriptionsItem label="版本号">
{{ taskInfo.version }}
</DescriptionsItem>
<DescriptionsItem label="业务ID">
{{ taskInfo.businessId }}
</DescriptionsItem>
</Descriptions>
<TransferModal mode="single" @finish="handleTransfer" />
<AddSignatureModal mode="multiple" @finish="handleAddSignature" />
<ReductionSignatureModal
mode="multiple"
@finish="handleReductionSignature"
/>
<template #footer>
<template v-if="showMultiActions">
<a-button @click="() => addSignatureModalApi.open()">加签</a-button>
<a-button @click="() => reductionSignatureModalApi.open()">
减签
</a-button>
</template>
<a-button @click="() => transferModalApi.open()">转办</a-button>
<a-button danger type="primary" @click="handleTermination">终止</a-button>
</template>
</BasicModal>
</template>

View File

@@ -0,0 +1,60 @@
import { defineComponent, h, ref } from 'vue';
import { Modal } from 'ant-design-vue';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import ApprovalContent from './approval-content.vue';
export interface ApproveWithReasonModalProps {
title: string;
description: string;
onOk: (reason: string) => void;
}
/**
* 带审批意见的confirm
* @param props props
*/
export function approveWithReasonModal(props: ApproveWithReasonModalProps) {
const { onOk, title, description } = props;
const content = ref('');
Modal.confirm({
title,
content: h(
defineComponent({
setup() {
return () =>
h(ApprovalContent, {
description,
value: content.value,
'onUpdate:value': (v) => (content.value = v),
});
},
}),
),
centered: true,
okButtonProps: { danger: true },
onOk: () => onOk(content.value),
});
}
dayjs.extend(duration);
dayjs.extend(relativeTime);
/**
* 计算相差的时间
* @param dateTime 时间字符串
* @returns 相差的时间
*/
export function getDiffTimeString(dateTime: string) {
// 计算相差秒数
const diffSeconds = dayjs().diff(dayjs(dateTime), 'second');
/**
* 转为时间显示(x月 x天)
* https://dayjs.fenxianglu.cn/category/duration.html#%E4%BA%BA%E6%80%A7%E5%8C%96
*
*/
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
return diffText;
}

View File

@@ -1,2 +1,30 @@
export { default as applyModal } from './apply-modal.vue';
export { default as ApprovalCard } from './approval-card.vue';
/**
* 审批同意
*/
export { default as approvalModal } from './approval-modal.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
*/
export { default as flowInterfereModal } from './flow-interfere-modal.vue';
/**
* 选人 支持单选/多选
*/
export { default as userSelectModal } from './user-select-modal.vue';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,365 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { User } from '#/api';
import { ref } from 'vue';
import { useVbenModal, VbenAvatar } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { userList } from '#/api/system/user';
import DeptTree from '#/views/system/user/dept-tree.vue';
defineOptions({
name: 'UserSelectModal',
inheritAttrs: false,
});
const props = withDefaults(defineProps<{ mode?: 'multiple' | 'single' }>(), {
mode: 'multiple',
});
const emit = defineEmits<{
/**
* 取消的事件
*/
cancel: [];
/**
* 选择完成的事件
*/
finish: [User[]];
}>();
const [BasicModal, modalApi] = useVbenModal({
title: '选择人员',
class: 'w-[1060px]',
fullscreenButton: false,
onClosed: () => emit('cancel'),
onConfirm: handleSubmit,
async onOpened() {
const { userList = [] } = modalApi.getData() as { userList: User[] };
// 暂时只处理多选 目前并没有单选的情况
if (props.mode === 'multiple') {
// 左边选中
await tableApi.grid.setCheckboxRow(userList, true);
// 右边赋值
await rightTableApi.grid.loadData(userList);
}
},
});
// 左边部门用
const selectDeptId = ref<string[]>([]);
const formOptions: VbenFormProps = {
schema: [
{
component: 'Input',
fieldName: 'userName',
label: '用户账号',
hideLabel: true,
componentProps: {
placeholder: '请输入账号',
},
},
],
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-2',
handleReset: async () => {
selectDeptId.value = [];
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'row',
},
radioConfig: {
trigger: 'row',
strict: true,
},
columns: [
{
type: props.mode === 'single' ? 'radio' : 'checkbox',
width: 60,
resizable: false,
},
{
field: 'userName',
title: '用户',
headerAlign: 'left',
resizable: false,
slots: {
default: 'user',
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
layouts: ['PrevPage', 'Number', 'NextPage', 'Sizes', 'Total'],
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectDeptId.value.length === 1) {
formValues.deptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(formValues, 'deptId');
}
// 加载完毕需要设置选中的行
if (props.mode === 'multiple') {
const records = rightTableApi.grid.getData();
await tableApi.grid.setCheckboxRow(records, true);
}
if (props.mode === 'single') {
const records = rightTableApi.grid.getData();
if (records.length === 1) {
await tableApi.grid.setRadioRow(records[0]);
}
}
return await userList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'userId',
},
toolbarConfig: {
enabled: false,
},
showOverflow: false,
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
// 需要控制不同的事件 radio也会触发checkbox事件
checkboxChange: checkBoxEvent,
checkboxAll: checkBoxEvent,
radioChange: radioEvent,
},
});
function checkBoxEvent() {
if (props.mode !== 'multiple') {
return;
}
/**
* 给右边表格赋值
* records拿到的是当前页的选中数据
* reserveRecords拿到的是其他页选中的数据
*/
const records = tableApi.grid.getCheckboxRecords();
const reserveRecords = tableApi.grid.getCheckboxReserveRecords();
const realRecords = [...records, ...reserveRecords];
rightTableApi.grid.loadData(realRecords);
}
function radioEvent() {
if (props.mode !== 'single') {
return;
}
// 给右边表格赋值
const records = tableApi.grid.getRadioRecord();
rightTableApi.grid.loadData([records]);
}
const rightGridOptions: VxeGridProps = {
checkboxConfig: {},
columns: [
{
field: 'nickName',
title: '昵称',
width: 200,
resizable: false,
slots: {
default: 'user',
},
},
{
field: 'action',
title: '操作',
width: 120,
slots: { default: 'action' },
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
enabled: false,
},
rowConfig: {
keyField: 'userId',
},
toolbarConfig: {
enabled: false,
},
showOverflow: false,
};
const [RightBasicTable, rightTableApi] = useVbenVxeGrid({
gridOptions: rightGridOptions,
});
async function handleRemoveItem(row: any) {
if (props.mode === 'multiple') {
await tableApi.grid.setCheckboxRow(row, false);
}
if (props.mode === 'single') {
await tableApi.grid.clearRadioRow();
}
const data = rightTableApi.grid.getData();
await rightTableApi.grid.loadData(data.filter((item) => item !== row));
// 这个方法有问题
// await rightTableApi.grid.remove(row);
}
function handleRemoveAll() {
if (props.mode === 'multiple') {
tableApi.grid.clearCheckboxRow();
tableApi.grid.clearCheckboxReserve();
}
if (props.mode === 'single') {
tableApi.grid.clearRadioRow();
}
rightTableApi.grid.loadData([]);
}
async function handleDeptQuery() {
await tableApi.reload();
// 重置后恢复 保存勾选的数据
const records = rightTableApi.grid.getData();
if (props.mode === 'multiple') {
tableApi?.grid.setCheckboxRow(records, true);
}
if (props.mode === 'single' && records.length === 1) {
tableApi.grid.setRadioRow(records[0]);
}
}
function handleSubmit() {
const records = rightTableApi.grid.getData();
console.log(records);
emit('finish', records);
modalApi.close();
}
</script>
<template>
<BasicModal>
<div class="flex min-h-[600px]">
<DeptTree
v-model:select-dept-id="selectDeptId"
:show-search="false"
class="w-[230px]"
@reload="() => tableApi.reload()"
@select="handleDeptQuery"
/>
<div class="h-[600px] w-[450px]">
<BasicTable>
<template #user="{ row }">
<div class="flex items-center gap-2">
<VbenAvatar
:alt="row.nickName"
:src="row.avatar ?? ''"
:class="{ 'bg-primary': !row.avatar }"
class="size-[32px] rounded-full text-white"
/>
<div class="flex flex-col items-baseline text-[12px]">
<div>{{ row.nickName }}</div>
<div class="opacity-50">
{{ row.phonenumber || '暂无手机号' }}
</div>
</div>
</div>
</template>
</BasicTable>
</div>
<div class="flex h-[600px] flex-col">
<div class="flex w-full px-4">
<div class="flex w-full items-center justify-between">
<div>已选中人员</div>
<div>
<a-button size="small" @click="handleRemoveAll">
清空选中
</a-button>
</div>
</div>
</div>
<RightBasicTable id="user-select-right-table">
<template #user="{ row }">
<div class="flex items-center gap-2 overflow-hidden">
<VbenAvatar
:alt="row.nickName"
:src="row.avatar ?? ''"
:class="{ 'bg-primary': !row.avatar }"
class="size-[32px] rounded-full text-white"
/>
<div class="flex flex-col items-baseline text-[12px]">
<div class="overflow-ellipsis whitespace-nowrap">
{{ row.nickName }}
</div>
<div class="opacity-50">
{{ row.phonenumber || '暂无手机号' }}
</div>
</div>
</div>
</template>
<template #action="{ row }">
<a-button size="small" @click="handleRemoveItem(row)">
移除
</a-button>
</template>
</RightBasicTable>
</div>
</div>
</BasicModal>
</template>
<style scoped>
:deep(div.vben-link) {
display: none;
}
:deep(.vxe-body--row) {
cursor: pointer;
}
</style>
<style lang="scss">
/**
默认显示右边的滚动条 防止出现滚动条被挤压
*/
#user-select-right-table {
div.vxe-table--body-wrapper.body--wrapper {
overflow: scroll;
}
}
</style>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,62 @@
import type { LeaveForm, LeaveQuery, LeaveVO } from './model';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
* 查询请假申请列表
* @param params
* @returns 请假申请列表
*/
export function leaveList(params?: LeaveQuery) {
return requestClient.get<PageResult<LeaveVO>>('/workflow/leave/list', {
params,
});
}
/**
* 导出请假申请列表
* @param params
* @returns 请假申请列表
*/
export function leaveExport(params?: LeaveQuery) {
return commonExport('/workflow/leave/export', params ?? {});
}
/**
* 查询请假申请详情
* @param id id
* @returns 请假申请详情
*/
export function leaveInfo(id: ID) {
return requestClient.get<LeaveVO>(`/workflow/leave/${id}`);
}
/**
* 新增请假申请
* @param data
* @returns void
*/
export function leaveAdd(data: LeaveForm) {
return requestClient.postWithMsg<LeaveVO>('/workflow/leave', data);
}
/**
* 更新请假申请
* @param data
* @returns void
*/
export function leaveUpdate(data: LeaveForm) {
return requestClient.putWithMsg<LeaveVO>('/workflow/leave', data);
}
/**
* 删除请假申请
* @param id id
* @returns void
*/
export function leaveRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/workflow/leave/${id}`);
}

View File

@@ -0,0 +1,107 @@
import type { BaseEntity, PageQuery } from '#/api/common';
export interface LeaveVO {
/**
* 主键
*/
id: number | string;
/**
* 请假类型
*/
leaveType: string;
/**
* 开始时间
*/
startDate: string;
/**
* 结束时间
*/
endDate: string;
/**
* 请假天数
*/
leaveDays: number;
/**
* 请假原因
*/
remark: string;
/**
*
*/
status: string;
}
export interface LeaveForm extends BaseEntity {
/**
* 主键
*/
id?: number | string;
/**
* 请假类型
*/
leaveType?: string;
/**
* 开始时间
*/
startDate?: string;
/**
* 结束时间
*/
endDate?: string;
/**
* 请假天数
*/
leaveDays?: number;
/**
* 请假原因
*/
remark?: string;
/**
*
*/
status?: string;
}
export interface LeaveQuery extends PageQuery {
/**
* 请假类型
*/
leaveType?: string;
/**
* 开始时间
*/
startDate?: string;
/**
* 结束时间
*/
endDate?: string;
/**
* 请假天数
*/
leaveDays?: number;
/**
*
*/
status?: string;
/**
* 日期范围参数
*/
params?: any;
}

View File

@@ -0,0 +1,173 @@
import type { FormSchemaGetter, VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils';
import dayjs from 'dayjs';
import { OptionsTag } from '#/components/table';
import { renderDict } from '#/utils/render';
export const leaveTypeOptions = [
{ label: '病假', value: '1' },
{ label: '事假', value: '2' },
{ label: '年假', value: '3' },
{ label: '婚假', value: '4' },
{ label: '产假', value: '5' },
{ label: '其他', value: '7' },
];
export const leaveFlowOptions = [
{ label: '请假流程-普通', value: 'leave1' },
{ label: '请假流程-排他网关', value: 'leave2' },
{ label: '请假流程-并行网关', value: 'leave3' },
{ label: '请假流程-会签', value: 'leave4' },
{ label: '请假申请-并行会签网关', value: 'leave5' },
];
export const querySchema: FormSchemaGetter = () => [
{
component: 'InputNumber',
componentProps: {
min: 1,
},
fieldName: 'startLeaveDays',
label: '请假天数',
},
{
component: 'InputNumber',
componentProps: {
min: 1,
},
fieldName: 'endLeaveDays',
label: '至',
labelClass: 'justify-center',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '请假类型',
field: 'leaveType',
slots: {
default: ({ row }) => {
return <OptionsTag options={leaveTypeOptions} value={row.leaveType} />;
},
},
},
{
title: '开始时间',
field: 'startDate',
formatter: ({ cellValue }) => dayjs(cellValue).format('YYYY-MM-DD'),
},
{
title: '结束时间',
field: 'endDate',
formatter: ({ cellValue }) => dayjs(cellValue).format('YYYY-MM-DD'),
},
{
title: '请假天数',
field: 'leaveDays',
formatter: ({ cellValue }) => `${cellValue}`,
},
{
title: '请假原因',
field: 'remark',
},
{
title: '流程状态',
field: 'status',
slots: {
default: ({ row }) => {
return renderDict(row.status, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 210,
},
];
export const modalSchema: (isEdit: boolean) => VbenFormSchema[] = (
isEdit: boolean,
) => [
{
label: '主键',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '流程类型',
fieldName: 'flowType',
component: 'Select',
help: '这里仅仅为了发起流程方便, 实际不应该包含此字段',
componentProps: {
options: leaveFlowOptions,
getPopupContainer,
},
defaultValue: 'leave1',
rules: 'selectRequired',
dependencies: {
show: () => isEdit,
triggerFields: [''],
},
},
{
label: '请假类型',
fieldName: 'leaveType',
component: 'Select',
componentProps: {
options: leaveTypeOptions,
getPopupContainer,
},
rules: 'selectRequired',
formItemClass: 'col-span-1',
},
{
label: '开始时间',
fieldName: 'dateRange',
component: 'RangePicker',
componentProps(model) {
return {
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
onChange: (dates: [string, string]) => {
if (!dates) {
model.leaveDays = null;
return;
}
const [start, end] = dates;
const leaveDays = dayjs(end).diff(dayjs(start), 'day') + 1;
model.leaveDays = leaveDays;
},
};
},
rules: 'required',
formItemClass: 'col-span-1',
},
{
label: '请假天数',
fieldName: 'leaveDays',
component: 'Input',
componentProps: {
disabled: true,
},
rules: 'required',
},
{
label: '请假原因',
fieldName: 'remark',
component: 'Textarea',
formItemClass: 'items-baseline',
},
];

View File

@@ -1,9 +1,198 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { VbenFormProps } from '@vben/common-ui';
import type { LeaveForm } from './api/model';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import { cancelProcessApply } from '#/api/workflow/instance';
import { commonDownloadExcel } from '#/utils/file/download';
import { flowInfoModal } from '../components';
import { leaveExport, leaveList, leaveRemove } from './api';
import { columns, querySchema } from './data';
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',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
// trigger: 'row',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await leaveList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
// 表格全局唯一表示 保存列配置需要用到
id: 'workflow-leave-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const router = useRouter();
function handleAdd() {
router.push('/workflow/leaveEdit/index');
}
async function handleEdit(row: Required<LeaveForm>) {
router.push({ path: '/workflow/leaveEdit/index', query: { id: row.id } });
}
async function handleDelete(row: Required<LeaveForm>) {
await leaveRemove(row.id);
await tableApi.query();
}
async function handleRevoke(row: Required<LeaveForm>) {
await cancelProcessApply({
businessId: row.id,
message: '申请人撤销流程!',
});
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: Required<LeaveForm>) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await leaveRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(
leaveExport,
'请假申请数据',
tableApi.formApi.form.values,
{
fieldMappingTime: formOptions.fieldMappingTime,
},
);
}
const [FlowInfoModal, flowInfoModalApi] = useVbenModal({
connectedComponent: flowInfoModal,
});
function handleInfo(row: Required<LeaveForm>) {
flowInfoModalApi.setData({ businessId: row.id });
flowInfoModalApi.open();
}
</script>
<template>
<div>
<CommonSkeleton />
</div>
<Page :auto-content-height="true">
<BasicTable table-title="请假申请列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['workflow:leave:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['workflow:leave:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['workflow:leave:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-if="['draft', 'cancel', 'back'].includes(row.status)"
v-access:code="['workflow:leave:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认撤销?"
@confirm="handleRevoke(row)"
>
<ghost-button
v-if="['waiting'].includes(row.status)"
v-access:code="['workflow:leave:edit']"
@click.stop=""
>
撤销
</ghost-button>
</Popconfirm>
<ghost-button @click="handleInfo(row)">详情</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button
v-if="['draft', 'cancel', 'back'].includes(row.status)"
danger
v-access:code="['workflow:leave:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<FlowInfoModal />
</Page>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { LeaveVO } from './api/model';
import { computed } from 'vue';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
import dayjs from 'dayjs';
import { leaveTypeOptions } from './data';
defineOptions({
name: 'LeaveDescription',
inheritAttrs: false,
});
const props = defineProps<{ data: LeaveVO }>();
const leaveType = computed(() => {
return (
leaveTypeOptions.find((item) => item.value === props.data.leaveType)
?.label ?? '未知'
);
});
function formatDate(date: string) {
return dayjs(date).format('YYYY-MM-DD');
}
</script>
<template>
<Descriptions :column="1" size="middle">
<DescriptionsItem label="请假类型">
{{ leaveType }}
</DescriptionsItem>
<DescriptionsItem label="请假时间">
{{ formatDate(data.startDate) }} - {{ formatDate(data.endDate) }}
</DescriptionsItem>
<DescriptionsItem label="请假时长">
{{ data.leaveDays }}
</DescriptionsItem>
<DescriptionsItem label="请假原因">
{{ data.remark || '无' }}
</DescriptionsItem>
</Descriptions>
</template>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import type { LeaveVO } from './api/model';
import type { StartWorkFlowReqData } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { Card } from 'ant-design-vue';
import dayjs from 'dayjs';
import { cloneDeep, omit } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { startWorkFlow } from '#/api/workflow/task';
import { applyModal } from '../components';
import { leaveAdd, leaveInfo, leaveUpdate } from './api';
import { modalSchema } from './data';
import LeaveDescription from './leave-description.vue';
const route = useRoute();
const readonly = route.query?.readonly === 'true';
const id = route.query?.id as string;
/**
* id存在&readonly时候
*/
const showActionBtn = computed(() => {
return !readonly;
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
disabled: readonly,
},
},
schema: modalSchema(!readonly),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const leaveDescription = ref<LeaveVO>();
const showDescription = computed(() => {
return readonly && leaveDescription.value;
});
const cardRef = useTemplateRef('cardRef');
onMounted(async () => {
// 只读 获取信息赋值
if (id) {
const resp = await leaveInfo(id);
leaveDescription.value = resp;
await formApi.setValues(resp);
const dateRange = [dayjs(resp.startDate), dayjs(resp.endDate)];
await formApi.setFieldValue('dateRange', dateRange);
/**
* window.parent最近的上一级父页面
* 主要解决内嵌iframe卡顿的问题
*/
if (readonly) {
// 渲染完毕才显示表单
window.parent.postMessage({ type: 'mounted' }, '*');
// 获取表单高度 内嵌时保持一致
setTimeout(() => {
const el = cardRef.value?.$el as HTMLDivElement;
// 获取高度
const height = el?.offsetHeight ?? 0;
if (height) {
window.parent.postMessage({ type: 'height', height }, '*');
}
});
}
}
});
const router = useRouter();
/**
* 提取通用逻辑
*/
async function handleSaveOrUpdate() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
let data = cloneDeep(await formApi.getValues()) as any;
data = omit(data, 'flowType');
// 处理日期
data.startDate = dayjs(data.dateRange[0]).format('YYYY-MM-DD HH:mm:ss');
data.endDate = dayjs(data.dateRange[1]).format('YYYY-MM-DD HH:mm:ss');
if (id) {
data.id = id;
return await leaveUpdate(data);
} else {
return await leaveAdd(data);
}
}
const [ApplyModal, applyModalApi] = useVbenModal({
connectedComponent: applyModal,
});
/**
* 暂存 草稿状态
*/
async function handleTempSave() {
try {
await handleSaveOrUpdate();
router.push('/demo/leave');
} catch (error) {
console.error(error);
}
}
/**
* 保存业务 & 发起流程
*/
async function handleStartWorkFlow() {
try {
// 保存业务
const leaveResp = await handleSaveOrUpdate();
// 启动流程
const taskVariables = {
leaveDays: leaveResp!.leaveDays,
userList: ['1', '3', '4'],
};
const formValues = await formApi.getValues();
const flowCode = formValues?.flowType ?? 'leave1';
const startWorkFlowData: StartWorkFlowReqData = {
businessId: leaveResp!.id,
flowCode,
variables: taskVariables,
};
const { taskId } = await startWorkFlow(startWorkFlowData);
// 打开窗口
applyModalApi.setData({
taskId,
taskVariables,
variables: {},
});
applyModalApi.open();
} catch (error) {
console.error(error);
}
}
function handleComplete() {
formApi.resetForm();
router.push('/demo/leave');
}
/**
* 显示详情时 需要较小的padding
*/
const cardSize = computed(() => {
return showDescription.value ? 'small' : 'default';
});
</script>
<template>
<Card ref="cardRef" :size="cardSize">
<div id="leave-form">
<!-- 使用v-if会影响生命周期 -->
<BasicForm v-show="!showDescription" />
<LeaveDescription v-if="showDescription" :data="leaveDescription!" />
<div v-if="showActionBtn" class="flex justify-end gap-2">
<a-button @click="handleTempSave">暂存</a-button>
<a-button type="primary" @click="handleStartWorkFlow">提交</a-button>
</div>
<ApplyModal @complete="handleComplete" />
</div>
</Card>
</template>
<style lang="scss">
html:has(#leave-form) {
/**
去除 '菜单加载中' 主要是iframe内嵌使用
*/
.ant-message-notice-content:has(.ant-message-loading) {
display: none;
}
/**
去除顶部进度条样式
*/
#nprogress {
display: none;
}
}
</style>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { CategoryTree } from '#/api/workflow/category/model';
import { onMounted, type PropType, ref } from 'vue';
import { SyncOutlined } from '@ant-design/icons-vue';
import { InputSearch, Skeleton, Tree } from 'ant-design-vue';
import { categoryTree } from '#/api/workflow/category';
defineOptions({ inheritAttrs: false });
const emit = defineEmits<{
/**
* 点击刷新按钮的事件
*/
reload: [];
/**
* 点击节点的事件
*/
select: [];
}>();
const selectCode = defineModel('selectCode', {
required: true,
type: Array as PropType<number[] | string[]>,
});
const searchValue = defineModel('searchValue', {
type: String,
default: '',
});
const categoryTreeArray = ref<CategoryTree[]>([]);
/** 骨架屏加载 */
const showTreeSkeleton = ref<boolean>(true);
async function loadTree() {
showTreeSkeleton.value = true;
searchValue.value = '';
selectCode.value = [];
const treeData = await categoryTree();
categoryTreeArray.value = treeData;
showTreeSkeleton.value = false;
}
async function handleReload() {
await loadTree();
emit('reload');
}
onMounted(loadTree);
</script>
<template>
<div :class="$attrs.class">
<Skeleton
:loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="p-[8px]"
>
<div
class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"
>
<!-- 固定在顶部 必须加上bg-background背景色 否则会产生'穿透'效果 -->
<div class="bg-background z-100 sticky left-0 top-0 p-[8px]">
<InputSearch
v-model:value="searchValue"
:placeholder="$t('pages.common.search')"
size="small"
>
<template #enterButton>
<a-button @click="handleReload">
<SyncOutlined class="text-primary" />
</a-button>
</template>
</InputSearch>
</div>
<div class="h-full overflow-x-hidden px-[8px]">
<Tree
v-bind="$attrs"
v-if="categoryTreeArray.length > 0"
v-model:selected-keys="selectCode"
:class="$attrs.class"
:field-names="{ title: 'label', key: 'id' }"
:show-line="{ showLeafIcon: false }"
:tree-data="categoryTreeArray"
:virtual="false"
default-expand-all
@select="$emit('select')"
>
<template #title="{ label }">
<span v-if="label.indexOf(searchValue) > -1">
{{ label.substring(0, label.indexOf(searchValue)) }}
<span style="color: #f50">{{ searchValue }}</span>
{{
label.substring(
label.indexOf(searchValue) + searchValue.length,
)
}}
</span>
<span v-else>{{ label }}</span>
</template>
</Tree>
</div>
</div>
</Skeleton>
</div>
</template>

View File

@@ -0,0 +1,36 @@
import { optionsToEnum } from '@vben/utils';
export const activityStatusOptions = [
{
label: '激活',
value: 1,
color: 'success',
enumName: 'Active',
},
{
label: '挂起',
value: 0,
color: 'error',
enumName: 'Suspended',
},
] as const;
export const ActivityStatusEnum = optionsToEnum(activityStatusOptions);
export const publishStatusOptions = [
{
label: '已发布',
value: 1,
color: 'success',
},
{
label: '未发布',
value: 0,
color: 'warning',
},
{
label: '失效',
value: 9,
color: 'error',
},
];

View File

@@ -0,0 +1,103 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { OptionsTag } from '#/components/table';
import { publishStatusOptions } from './constant';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'flowName',
label: '流程名称',
},
{
component: 'Input',
fieldName: 'flowCode',
label: '流程code',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'flowName',
title: '流程名称',
minWidth: 150,
},
{
field: 'flowCode',
title: '流程code',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
minWidth: 80,
formatter: ({ cellValue }) => `V${cellValue}.0`,
},
{
field: 'activityStatus',
title: '激活状态',
minWidth: 100,
slots: {
default: 'activityStatus',
},
},
{
field: 'isPublish',
title: '发布状态',
minWidth: 100,
slots: {
default: ({ row }) => {
const cellValue = row.isPublish;
return (
<OptionsTag options={publishStatusOptions as any} value={cellValue} />
);
},
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 200,
},
];
export const modalSchema: FormSchemaGetter = () => [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'id',
},
{
component: 'TreeSelect',
fieldName: 'category',
label: '流程分类',
rules: 'selectRequired',
},
{
component: 'Input',
fieldName: 'flowCode',
label: '流程code',
rules: 'required',
},
{
component: 'Input',
fieldName: 'flowName',
label: '流程名称',
rules: 'required',
},
{
component: 'Input',
fieldName: 'formPath',
label: '表单路径',
rules: 'required',
},
];

View File

@@ -1,9 +1,373 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { RadioChangeEvent } from 'ant-design-vue';
import type { VbenFormProps } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { getVxePopupContainer } from '@vben/utils';
import {
message,
Modal,
Popconfirm,
RadioGroup,
Space,
Switch,
} from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
unPublishList,
workflowDefinitionActive,
workflowDefinitionCopy,
workflowDefinitionDelete,
workflowDefinitionExport,
workflowDefinitionList,
workflowDefinitionPublish,
} from '#/api/workflow/definition';
import { downloadByData } from '#/utils/file/download';
import CategoryTree from './category-tree.vue';
import { columns, querySchema } from './data';
import processDefinitionDeployModal from './process-definition-deploy-modal.vue';
import processDefinitionModal from './process-definition-modal.vue';
// 左边部门用
const selectedCode = ref<number[] | string[]>([]);
const formOptions: VbenFormProps = {
schema: querySchema(),
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectedCode.value = [];
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'default',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectedCode.value.length === 1) {
formValues.category = selectedCode.value[0];
} else {
Reflect.deleteProperty(formValues, 'category');
}
return await currentTableApi.value({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
height: 100,
},
id: 'workflow-definition-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
// 左边的切换
const statusOptions = [
{ label: '已发布流程', value: 1 },
{ label: '未发布流程', value: 0 },
];
const currentStatus = ref(1);
const currentTableApi = computed(() => {
if (currentStatus.value === 1) {
return workflowDefinitionList;
}
return unPublishList;
});
async function handleStatusChange(e: RadioChangeEvent) {
currentStatus.value = e.target.value as number;
await tableApi.reload();
}
async function handleDelete(row: Recordable<any>) {
await workflowDefinitionDelete(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: any) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await workflowDefinitionDelete(ids);
await tableApi.query();
},
});
}
const router = useRouter();
/**
* 流程设计/预览
* @param row row
* @param disabled true为预览false为设计
*/
function handleDesign(row: any, disabled: boolean) {
router.push({
path: '/workflow/designer',
query: { definitionId: row.id, disabled: String(disabled) },
});
}
/**
* 激活/挂起流程
* @param row row
*/
async function handleActive(row: any, status: boolean | number | string) {
const lastStatus = status === 1 ? 0 : 1;
try {
await workflowDefinitionActive(row.id, !!status);
await tableApi.query();
} catch (error) {
row.activityStatus = lastStatus;
console.error(error);
}
}
/**
* 发布流程
* @param row row
*/
async function handlePublish(row: any) {
await workflowDefinitionPublish(row.id);
await tableApi.query();
}
/**
* 复制流程
* @param row row
*/
async function handleCopy(row: any) {
await workflowDefinitionCopy(row.id);
// 跳转到未发布流程tab
currentStatus.value = 0;
await tableApi.reload();
}
const [ProcessDefinitionModal, modalApi] = useVbenModal({
connectedComponent: processDefinitionModal,
});
/**
* 新增流程
*/
function handleAdd() {
modalApi.setData({});
modalApi.open();
}
/**
* 编辑流程
*/
function handleEdit(row: any) {
modalApi.setData({ id: row.id });
modalApi.open();
}
/**
* 导出xml
* @param row row
*/
async function handleExportXml(row: any) {
const hideLoading = message.loading($t('pages.common.downloadLoading'), 0);
try {
const blob = await workflowDefinitionExport(row.id);
downloadByData(blob, `${row.flowName}-${Date.now()}.json`);
} catch (error) {
console.error(error);
} finally {
hideLoading();
}
}
const [ProcessDefinitionDeployModal, deployModalApi] = useVbenModal({
connectedComponent: processDefinitionDeployModal,
});
/**
* 部署流程xml
*/
function handleDeploy() {
if (selectedCode.value.length === 0) {
message.warning('请先选择流程分类');
return;
}
const selectedCategory = selectedCode.value[0];
if (selectedCategory === 0) {
message.warning('不可选择根目录进行部署, 请选择子分类');
return;
}
deployModalApi.setData({ category: selectedCategory });
deployModalApi.open();
}
// 部署流程json
async function handleDeploySuccess() {
// 跳转到未发布
currentStatus.value = 0;
await tableApi.reload();
}
// 新增完成需要跳转到未发布
async function handleReload(type: 'add' | 'update') {
if (type === 'add') {
currentStatus.value = 0;
}
await tableApi.reload();
}
</script>
<template>
<div>
<CommonSkeleton />
</div>
<Page :auto-content-height="true">
<div class="flex h-full gap-[8px]">
<CategoryTree
v-model:select-code="selectedCode"
class="w-[260px]"
@reload="() => tableApi.reload()"
@select="() => tableApi.reload()"
/>
<BasicTable class="flex-1 overflow-hidden">
<template #toolbar-actions>
<RadioGroup
v-model:value="currentStatus"
:options="statusOptions"
button-style="solid"
option-type="button"
@change="handleStatusChange"
/>
</template>
<template #toolbar-tools>
<Space>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['system:user:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button v-access:code="['system:user:add']" @click="handleDeploy">
部署
</a-button>
<a-button
type="primary"
v-access:code="['system:user:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #activityStatus="{ row }">
<Switch
v-model:checked="row.activityStatus"
:checked-value="1"
:unchecked-value="0"
checked-children="激活"
un-checked-children="挂起"
@change="(status) => handleActive(row, status)"
/>
</template>
<template #action="{ row }">
<div class="flex flex-col gap-1">
<div>
<a-button size="small" type="link" @click="handleEdit(row)">
编辑信息
</a-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<a-button danger size="small" type="link" @click.stop="">
删除流程
</a-button>
</Popconfirm>
</div>
<div>
<a-button
size="small"
type="link"
@click="handleDesign(row, !!row.isPublish)"
>
{{ row.isPublish ? '查看流程' : '设计流程' }}
</a-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
:title="`确认发布流程[${row.flowName}]?`"
placement="left"
@confirm="handlePublish(row)"
>
<a-button v-if="!row.isPublish" size="small" type="link">
发布流程
</a-button>
</Popconfirm>
</div>
<div>
<Popconfirm
:get-popup-container="getVxePopupContainer"
:title="`确认复制流程[${row.flowName}]?`"
placement="left"
@confirm="handleCopy(row)"
>
<a-button size="small" type="link"> 复制流程 </a-button>
</Popconfirm>
<a-button size="small" type="link" @click="handleExportXml(row)">
导出流程
</a-button>
</div>
</div>
</template>
</BasicTable>
</div>
<ProcessDefinitionModal @reload="handleReload" />
<ProcessDefinitionDeployModal @reload="handleDeploySuccess" />
</Page>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { InBoxIcon } from '@vben/icons';
import { Upload } from 'ant-design-vue';
import { workflowDefinitionImport } from '#/api/workflow/definition';
const emit = defineEmits<{ reload: [] }>();
const UploadDragger = Upload.Dragger;
const [BasicModal, modalApi] = useVbenModal({
onCancel: handleCancel,
onConfirm: handleSubmit,
});
const fileList = ref<UploadFile[]>([]);
async function handleSubmit() {
try {
modalApi.modalLoading(true);
if (fileList.value.length !== 1) {
handleCancel();
return;
}
const data = {
file: fileList.value[0]!.originFileObj as Blob,
category: modalApi.getData().category,
};
await workflowDefinitionImport(data);
emit('reload');
handleCancel();
} catch (error) {
console.warn(error);
modalApi.close();
} finally {
modalApi.modalLoading(false);
}
}
function handleCancel() {
modalApi.close();
fileList.value = [];
}
</script>
<template>
<BasicModal
:close-on-click-modal="false"
:fullscreen-button="false"
title="流程部署"
>
<!-- z-index不设置会遮挡模板下载loading -->
<!-- 手动处理 而不是放入文件就上传 -->
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="true"
accept="text/json"
>
<p class="ant-upload-drag-icon flex items-center justify-center">
<InBoxIcon class="text-primary size-[48px]" />
</p>
<p class="ant-upload-text">点击或者拖拽到此处上传[json]文件</p>
</UploadDragger>
</BasicModal>
</template>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { addFullName, cloneDeep, getPopupContainer } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { categoryTree } from '#/api/workflow/category';
import {
workflowDefinitionAdd,
workflowDefinitionInfo,
workflowDefinitionUpdate,
} from '#/api/workflow/definition';
import { modalSchema } from './data';
const emit = defineEmits<{ reload: [type: 'add' | 'update'] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 90,
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function setupCategorySelect() {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
formApi.updateSchema([
{
componentProps: {
fieldNames: {
label: 'label',
value: 'id',
},
getPopupContainer,
// 设置弹窗滚动高度 默认256
listHeight: 300,
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
// 默认展开的树节点
// treeDefaultExpandedKeys: [0],
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
treeNodeLabelProp: 'fullName',
},
fieldName: 'category',
},
]);
}
const [BasicDrawer, modalApi] = useVbenModal({
onCancel: handleCancel,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
// 加载分类树选择
await setupCategorySelect();
if (isUpdate.value && id) {
const record = await workflowDefinitionInfo(id);
await formApi.setValues(record);
}
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
if (isUpdate.value) {
await workflowDefinitionUpdate(data);
emit('reload', 'update');
} else {
await workflowDefinitionAdd(data);
emit('reload', 'add');
}
await handleCancel();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
async function handleCancel() {
modalApi.close();
await formApi.resetForm();
}
</script>
<template>
<BasicDrawer
:close-on-click-modal="false"
:fullscreen-button="false"
:title="title"
class="w-[550px]"
>
<div class="min-h-[400px]">
<BasicForm />
</div>
</BasicDrawer>
</template>

View File

@@ -0,0 +1,91 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { OptionsTag } from '#/components/table';
import { renderDict } from '#/utils/render';
import { activityStatusOptions } from '../processDefinition/constant';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
label: '任务名称',
fieldName: 'nodeName',
},
{
component: 'Input',
label: '流程名称',
fieldName: 'flowName',
},
{
component: 'Input',
label: '流程编码',
fieldName: 'flowCode',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'flowName',
title: '流程名称',
minWidth: 150,
},
{
field: 'nodeName',
title: '任务名称',
minWidth: 150,
},
{
field: 'flowCode',
title: '流程编码',
minWidth: 150,
},
{
field: 'createByName',
title: '申请人',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
minWidth: 150,
formatter: ({ cellValue }) => `V${cellValue}.0`,
},
{
field: 'activityStatus',
title: '状态',
minWidth: 100,
slots: {
default: ({ row }) => {
const cellValue = row.activityStatus;
return (
<OptionsTag
options={activityStatusOptions as any}
value={cellValue}
/>
);
},
},
},
{
field: 'flowStatus',
title: '流程状态',
minWidth: 100,
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 200,
},
];

View File

@@ -1,9 +1,235 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { RadioChangeEvent } from 'ant-design-vue';
import type { VbenFormProps } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, RadioGroup, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
deleteByInstanceIds,
pageByFinish,
pageByRunning,
} from '#/api/workflow/instance';
import CategoryTree from '#/views/workflow/processDefinition/category-tree.vue';
import { flowInfoModal } from '../components';
import { columns, querySchema } from './data';
import instanceInvalidModal from './instance-invalid-modal.vue';
import instanceVariableModal from './instance-variable-modal.vue';
// 左边分类用
const selectedCode = ref<number[] | string[]>([]);
const formOptions: VbenFormProps = {
schema: querySchema(),
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectedCode.value = [];
// eslint-disable-next-line no-use-before-define
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
};
const typeOptions = [
{ label: '运行中', value: 'process_running' },
{ label: '已完成', value: 'process_completed' },
];
let currentTypeApi = pageByRunning;
const currentType = ref('process_running');
async function handleTypeChange(e: RadioChangeEvent) {
const { value } = e.target;
switch (value) {
case 'process_completed': {
currentTypeApi = pageByFinish;
break;
}
case 'process_running': {
currentTypeApi = pageByRunning;
break;
}
}
// eslint-disable-next-line no-use-before-define
await tableApi.reload();
}
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'default',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectedCode.value.length === 1) {
formValues.category = selectedCode.value[0];
} else {
Reflect.deleteProperty(formValues, 'category');
}
return await currentTypeApi({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
height: 66,
},
id: 'workflow-definition-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [InstanceInvalidModal, instanceInvalidModalApi] = useVbenModal({
connectedComponent: instanceInvalidModal,
});
async function handleInvalid(row: Recordable<any>) {
instanceInvalidModalApi.setData({ id: row.id });
instanceInvalidModalApi.open();
}
async function handleDelete(row: Recordable<any>) {
await deleteByInstanceIds(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: any) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await deleteByInstanceIds(ids);
await tableApi.query();
},
});
}
const [InstanceVariableModal, instanceVariableModalApi] = useVbenModal({
connectedComponent: instanceVariableModal,
});
function handleVariable(row: Recordable<any>) {
instanceVariableModalApi.setData({ record: row.variable });
instanceVariableModalApi.open();
}
const [FlowInfoModal, flowInfoModalApi] = useVbenModal({
connectedComponent: flowInfoModal,
});
function handleInfo(row: any) {
console.log(row);
flowInfoModalApi.setData({ businessId: row.businessId });
flowInfoModalApi.open();
}
</script>
<template>
<div>
<CommonSkeleton />
</div>
<Page :auto-content-height="true">
<div class="flex h-full gap-[8px]">
<CategoryTree
v-model:select-code="selectedCode"
class="w-[260px]"
@reload="() => tableApi.reload()"
@select="() => tableApi.reload()"
/>
<BasicTable class="flex-1 overflow-hidden">
<template #toolbar-actions>
<RadioGroup
v-model:value="currentType"
:options="typeOptions"
button-style="solid"
option-type="button"
@change="handleTypeChange"
/>
</template>
<template #toolbar-tools>
<Space>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['system:user:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<div class="flex flex-col">
<div v-if="currentType === 'process_running'">
<a-button
danger
size="small"
type="link"
@click.stop="handleInvalid(row)"
>
作废流程
</a-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<a-button danger size="small" type="link" @click.stop="">
删除流程
</a-button>
</Popconfirm>
</div>
<div>
<a-button size="small" type="link" @click.stop="handleInfo(row)">
流程预览
</a-button>
<a-button
size="small"
type="link"
@click.stop="handleVariable(row)"
>
变量查看
</a-button>
</div>
</div>
</template>
</BasicTable>
</div>
<InstanceInvalidModal @reload="() => tableApi.reload()" />
<InstanceVariableModal />
<FlowInfoModal />
</Page>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { workflowInstanceInvalid } from '#/api/workflow/instance';
const emit = defineEmits<{ reload: [] }>();
const [BasicModal, modalApi] = useVbenModal({
onConfirm: handleSubmit,
onCancel: handleCancel,
fullscreenButton: false,
title: '作废原因',
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
componentProps: {
class: 'w-full',
},
labelWidth: 80,
},
layout: 'vertical',
schema: [
{
fieldName: 'comment',
label: '作废原因',
component: 'Textarea',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function handleCancel() {
modalApi.close();
await formApi.resetForm();
}
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
data.id = modalApi.getData().id;
await workflowInstanceInvalid(data as any);
emit('reload');
handleCancel();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm />
</BasicModal>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue';
import { JsonPreview, useVbenModal } from '@vben/common-ui';
const data = ref({});
const [BasicModal, modalApi] = useVbenModal({
title: '流程变量',
fullscreenButton: false,
footer: false,
onOpenChange: (visible) => {
if (!visible) {
data.value = {};
return null;
}
const recordString = modalApi.getData().record;
data.value = JSON.parse(recordString);
},
});
</script>
<template>
<BasicModal>
<div class="min-h-[400px] overflow-y-auto">
<JsonPreview :data="data" />
</div>
</BasicModal>
</template>

View File

@@ -1,9 +1,357 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
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 { categoryTree } from '#/api/workflow/category';
import { pageByAllTaskFinish, pageByAllTaskWait } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
/**
* 流程监控 - 待办任务页面的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);
const typeOptions = [
{ label: '待办任务', value: 'todo' },
{ label: '已办任务', value: 'done' },
];
const currentType = ref('todo');
const currentApi = computed(() => {
if (currentType.value === 'todo') {
return pageByAllTaskWait;
}
return pageByAllTaskFinish;
});
const approvalType = computed(() => {
if (currentType.value === 'done') {
return 'readonly';
}
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, // 流程分类
};
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,
});
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;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await currentApi.value({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
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;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.randomId === randomId;
});
lastSelectId.value = randomId;
}
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;
});
</script>
<template>
<div>
<CommonSkeleton />
</div>
<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
v-model:value="currentType"
:options="typeOptions"
block
class="mb-2"
@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>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,9 +1,256 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { getPopupContainer } from '@vben/utils';
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 { pageByCurrent } from '#/api/workflow/instance';
import { ApprovalCard, ApprovalPanel } from '../components';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<({ active: boolean } & TaskInfo)[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
category: null as null | number, // 流程分类
};
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,
});
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;
// 滚动到底部且没有加载完成
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>
<div>
<CommonSkeleton />
</div>
<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>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,9 +1,298 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
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 { categoryTree } from '#/api/workflow/category';
import { pageByTaskCopy } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<({ active: boolean } & TaskInfo)[]>([]);
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 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;
// 滚动到底部且没有加载完成
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;
});
</script>
<template>
<div>
<CommonSkeleton />
</div>
<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>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,9 +1,298 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
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 { categoryTree } from '#/api/workflow/category';
import { pageByTaskFinish } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<({ active: boolean } & TaskInfo)[]>([]);
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 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;
// 滚动到底部且没有加载完成
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;
});
</script>
<template>
<div>
<CommonSkeleton />
</div>
<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>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,25 +1,97 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Alert,
Avatar,
Card,
Divider,
Empty,
Form,
FormItem,
Input,
InputSearch,
Space,
TabPane,
Tabs,
Tag,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { debounce, uniqueId } from 'lodash-es';
import { cloneDeep, debounce } from 'lodash-es';
import { ApprovalCard, ApprovalTimeline } from '../components';
import RejectionPng from '../components/rejection.png';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskWait } from '#/api/workflow/task';
const handleScroll = debounce((e: Event) => {
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<({ active: boolean } & TaskInfo)[]>([]);
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 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;
}
@@ -29,132 +101,189 @@ const handleScroll = debounce((e: Event) => {
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight;
console.log(isBottom);
// console.log(scrollTop + clientHeight);
// console.log(scrollHeight);
// 滚动到底部且没有加载完成
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 data = reactive(
Array.from({ length: 10 }).map(() => ({
id: uniqueId(),
startTime: '2022-01-01',
endTime: '2022-01-02',
title: '审批任务',
desc: '审批任务描述',
status: '审批中',
active: false,
})),
);
const timeLine = Array.from({ length: 5 }).map(() => ({
id: uniqueId(),
name: '张三',
status: '审批中',
remark: '审批任务描述',
time: '2022-01-01',
}));
const lastSelectId = ref('');
function handleCardClick(id: string) {
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
data.forEach((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;
});
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div class="bg-background flex h-full w-[320px] flex-col rounded-lg">
<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"
>
<InputSearch placeholder="搜索" />
<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"
>
<ApprovalCard
v-for="item in data"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick"
/>
<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">
{{ data.length }} 条记录
{{ taskTotal }} 条记录
</div>
</div>
</div>
<Card
:body-style="{ overflowY: 'auto', height: '100%' }"
class="thin-scrollbar flex-1 overflow-y-hidden"
size="small"
title="编号: 1234567890123456789012"
>
<div 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">报销申请</div>
<div>
<Tag color="warning">申请中</Tag>
</div>
</div>
<div class="flex items-center gap-2">
<Avatar
size="small"
src="https://plus.dapdap.top/minio-server/plus/2024/11/21/925ed278e2d441beb7f695b41e13c4dd.jpg"
/>
<span>疯狂的牛子Li</span>
<div class="flex items-center opacity-50">
<span>XXXX有限公司</span>
<Divider type="vertical" />
<span>提交于: 2022-01-01 12:00:00</span>
</div>
</div>
<!-- 右侧图标 -->
<div class="z-100 absolute right-3 top-3">
<img :src="RejectionPng" class="size-[96px]" />
</div>
</div>
<Tabs class="flex-1">
<TabPane key="1" tab="审批详情">
<div class="h-fulloverflow-y-auto">
<Alert message="该页面仅为静态页 后期可能会用到!" type="info" />
<Divider />
<ApprovalTimeline :list="timeLine" />
</div>
</TabPane>
<TabPane key="2" tab="审批记录">审批记录</TabPane>
<TabPane key="3" tab="全文评论(999+)">全文评论</TabPane>
</Tabs>
</div>
<!-- 固定底部 -->
<div
class="border-t-solid bg-background absolute bottom-0 left-0 w-full border-t-[1px] p-3"
>
<div class="flex justify-end">
<Space>
<a-button type="primary">通过</a-button>
<a-button danger type="primary">驳回</a-button>
<a-button>其他</a-button>
</Space>
</div>
</div>
</Card>
<ApprovalPanel :task="currentTask" type="approve" @reload="refreshTab" />
</div>
</Page>
</template>