19 Commits

Author SHA1 Message Date
dap
4c39bef181 docs: version 2025-10-28 13:32:43 +08:00
dap
86bcceaa84 feat: 租户管理 同步租户参数配置 2025-10-28 13:30:09 +08:00
dap
1980a2482d fix: modal/drawer里使用列配置 重置列弹窗被遮挡 2025-10-27 19:19:52 +08:00
dap
a38cf80ea4 refactor: 重构数据权限 - 部门分配组件 2025-10-24 16:05:04 +08:00
dap
a986e1a2ab fix: v-access:role指令错误判断code而非role 2025-10-24 10:27:03 +08:00
dap
9822d2af8a fix: 菜单管理 新增没有加载下拉选择api 2025-10-24 10:06:17 +08:00
dap
b51f5d1fa6 update: 发起流程loading + 默认不显示抄送字段 2025-10-23 14:16:22 +08:00
dap
56104b2abf refactor: tinymce 避免图片地址和链接地址转换成相对路径 2025-10-22 11:02:29 +08:00
dap
b4ca3f43a9 docs: 更新私有桶预览说明文档 2025-10-22 10:27:02 +08:00
dap
0666483c58 refactor: 调整菜单圆角大小 2025-10-21 14:11:31 +08:00
dap
77c45d855b docs: changelog 2025-10-20 16:01:23 +08:00
dap
8ce52eef51 feat: 修改流程变量 2025-10-20 15:51:47 +08:00
dap
738a918df6 refactor: 优化代码 2025-10-20 14:49:18 +08:00
dap
d9131cbe22 fix: 修复审批人昵称包含逗号时显示不正确的问题
将审批人昵称分割逻辑从 approveName 字段改为 approver 字段,
并添加注释说明昵称中带逗号的处理仍不准确,为后续优化提供提醒。
2025-10-20 14:45:10 +08:00
dap
968a2eb7b6 refactor: 优化审批面板接口加载逻辑 防止多余的api加载 2025-10-20 14:39:05 +08:00
dap
9b59a8acdb refactor: 重构审批详情footer及相关代码 移除之前iframe方案过时代码
新增 `FlowActions` 审批操作按钮组件,统一处理我的申请、审批、管理员等不同场景下的操作逻辑。
重构 `ApprovalPanel` 组件,将操作按钮抽离为独立组件,简化原有代码结构。
移除了 `ApprovalDetails` 中冗余的 iframe 高度控制属性。
优化 `FlowPreview` 组件,调整 iframe 样式并增强主题切换时与子页面的通信逻辑。
新增 `ApprovalType` 类型定义文件,明确各审批场景类型。
2025-10-20 14:23:51 +08:00
dap
ab756b3434 chore: version 2025-10-20 10:24:22 +08:00
dap
e23e5cd5a8 docs: changelog 2025-10-18 15:03:46 +08:00
dap
c3033d66bd chore: 调整环境打包命令 2025-10-18 15:01:04 +08:00
43 changed files with 1902 additions and 1981 deletions

View File

@@ -1,3 +1,24 @@
# 1.5.2
对应后端版本 单体/微服务: 5.5.1/2.5.1
该版本后端功能值包含一个`同步租户参数配置`功能 旧版本也能升级(使用)
**REFACTOR**
- 流程相关代码重构 移除之前的历史代码
**FEATURES**
- 修改流程变量
- 租户管理 同步租户参数配置
**BUG FIX**
- 菜单管理 新增没有加载下拉选择api
- v-access:role指令错误判断code而非role
- modal/drawer里使用列配置 重置列弹窗被遮挡
# 1.5.1 # 1.5.1
对应后端版本 单体/微服务: 5.5.0/2.5.0 对应后端版本 单体/微服务: 5.5.0/2.5.0
@@ -6,6 +27,10 @@
- 拖拽列宽时的颜色与primary保持一致 - 拖拽列宽时的颜色与primary保持一致
**OTHERS**
- 调整不同环境打包命令 兼容windows系统
# 1.5.0 # 1.5.0
对应后端版本 单体/微服务: 5.5.0/2.5.0 对应后端版本 单体/微服务: 5.5.0/2.5.0

View File

@@ -6,7 +6,7 @@
v5版本采用分仓(包)目录结构, 具体开发路径为: `根目录/apps/web-antd` v5版本采用分仓(包)目录结构, 具体开发路径为: `根目录/apps/web-antd`
目前对应后端版本: **分布式5.5.0/微服务2.5.0** 目前对应后端版本: **分布式5.5.1/微服务2.5.1**
V1.1.0版本已支持离线图标 V1.1.0版本已支持离线图标

View File

@@ -1,6 +1,6 @@
{ {
"name": "@vben/web-antd", "name": "@vben/web-antd",
"version": "1.5.0", "version": "1.5.2",
"homepage": "https://vben.pro", "homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": { "repository": {
@@ -16,7 +16,8 @@
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "pnpm vite build", "build:prod": "pnpm vite build",
"build:test": "pnpm vite build --mode test",
"build:analyze": "pnpm vite build --mode analyze", "build:analyze": "pnpm vite build --mode analyze",
"dev": "pnpm vite --mode development", "dev": "pnpm vite --mode development",
"preview": "vite preview", "preview": "vite preview",

View File

@@ -125,3 +125,13 @@ export function dictSyncTenant(tenantId?: string) {
successMessageMode: 'message', successMessageMode: 'message',
}); });
} }
/**
* 同步租户配置
* @returns void
*/
export function syncTenantConfig() {
return requestClient.get<void>('/system/tenant/syncTenantConfig', {
successMessageMode: 'message',
});
}

View File

@@ -0,0 +1,27 @@
import { defineComponent, h } from 'vue';
/**
* 使用默认插槽来自定义组件
* 给vbenForm的components使用
*/
export const DefaultSlot = defineComponent({
name: 'DefaultSlot',
inheritAttrs: false,
props: {
/**
* 绑定到根节点的div上的属性
*/
rootDivAttrs: {
type: Object,
default: () => ({}),
},
},
render() {
/**
* 获取属性 传递给作用域插槽供外部使用
*/
const attrs = this.$attrs;
return h('div', { ...this.rootDivAttrs }, this.$slots.default?.(attrs));
},
});

View File

@@ -133,6 +133,11 @@ const initOptions = computed((): InitOptions => {
toolbar_mode: 'sliding', toolbar_mode: 'sliding',
// 隐藏下面的 按xxx获取帮助 // 隐藏下面的 按xxx获取帮助
help_accessibility: false, help_accessibility: false,
// https://blog.csdn.net/qq_46380656/article/details/122171418
// 避免图片地址和链接地址转换成相对路径
relative_urls: false,
remove_script_host: false,
convert_urls: false,
...options, ...options,
/** /**
* 覆盖默认的base64行为 * 覆盖默认的base64行为

View File

@@ -1,215 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'; import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface';
import type { DataNode } from 'ant-design-vue/es/tree'; import type { DataNode } from 'ant-design-vue/es/tree';
import type { CheckInfo } from 'ant-design-vue/es/vc-tree/props';
import type { PropType, SetupContext } from 'vue'; import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue'; import { treeToList } from '@vben/utils';
import { findGroupParentIds, treeToList } from '@vben/utils';
import { Checkbox, Tree } from 'ant-design-vue'; import { Checkbox, Tree } from 'ant-design-vue';
import { uniq } from 'lodash-es';
/** 需要禁止透传 */ /** 需要禁止透传 */
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
const props = defineProps({ const props = withDefaults(defineProps<Props>(), {
checkStrictly: { expandAllOnInit: false,
default: true, fieldNames: () => ({ key: 'id', title: 'label' }),
type: Boolean, resetOnStrictlyChange: true,
}, treeData: () => [],
expandAllOnInit: {
default: false,
type: Boolean,
},
fieldNames: {
default: () => ({ key: 'id', title: 'label' }),
type: Object as PropType<{ key: string; title: string }>,
},
/** 点击节点关联/独立时 清空已勾选的节点 */
resetOnStrictlyChange: {
default: true,
type: Boolean,
},
treeData: {
default: () => [],
type: Array as PropType<DataNode[]>,
},
}); });
const emit = defineEmits<{ checkStrictlyChange: [boolean] }>();
const expandStatus = ref(false); interface Props {
const selectAllStatus = ref(false); /**
* 是否展开所有节点 mount
*/
expandAllOnInit?: boolean;
/**
* 自定义字段
*/
fieldNames?: { key: string; title: string };
/**
* 点击节点关联/独立时 清空已勾选的节点
*/
resetOnStrictlyChange?: boolean;
/**
* 树结构数据
*/
treeData?: DataNode[];
}
/** /**
* 后台的这个字段跟antd/ele是反的 * 展开的状态
* 组件库这个字段代表不关联
* 后台这个代表关联
*/ */
const innerCheckedStrictly = computed(() => { const expandStatus = ref(false);
return !props.checkStrictly; /**
}); * 全选状态
*/
const selectAllStatus = ref(false);
const associationText = computed(() => { const associationText = computed(() => {
return props.checkStrictly ? '父子节点关联' : '父子节点独立'; return checkStrictly.value ? '父子节点关联' : '父子节点独立';
}); });
/** /**
* 这个只用于界面显示 * 这个只用于界面显示
* 关联情况下 只会有最末尾的节点被选中 * 关联情况下 只会有最末尾的节点被选中
*/ */
const checkedKeys = defineModel('value', { const checkedKeys = defineModel<(number | string)[]>('value', {
default: () => [], default: () => [],
type: Array as PropType<(number | string)[]>,
}); });
/**
* 是否节点关联 后端字段跟前端字段是反的
*/
const checkStrictly = defineModel<boolean>('checkStrictly', {
default: () => true,
});
const computedCheckedKeys = computed<any>({
get() {
/**
* 严格模式(节点不关联) 需要返回{checked: string[] | number[], halfChecked: string[]}
* @see https://www.antdv.com/components/tree-cn#tree-props
*/
if (!checkStrictly.value) {
return {
checked: [...checkedKeys.value],
halfChecked: [],
};
}
return checkedKeys.value;
},
set(v) {
if (!checkStrictly.value) {
checkedKeys.value = [...v.checked, ...v.halfChecked];
return;
}
checkedKeys.value = v;
},
});
// 所有节点的ID // 所有节点的ID
const allKeys = computed(() => { const allKeys = computed(() => {
const idField = props.fieldNames.key; const idField = props.fieldNames.key;
return treeToList(props.treeData).map((item: any) => item[idField]); return treeToList(props.treeData).map((item: any) => item[idField]);
}); });
/** 已经选择的所有节点 包括子/父节点 用于提交 */ function handleCheckedAllChange(e: CheckboxChangeEvent) {
const checkedRealKeys = ref<(number | string)[]>([]);
/**
* 取第一次的menuTree id 设置到checkedMenuKeys
* 主要为了解决没有任何修改 直接点击保存的情况
*
* length为0情况(即新增时候没有勾选节点) 勾选这里会延迟触发 节点会拼接上父节点 导致ID重复
*/
const stop = watch([checkedKeys, () => props.treeData], () => {
if (
props.checkStrictly &&
checkedKeys.value.length > 0 &&
props.treeData.length > 0
) {
/** 找到父节点 添加上 */
const parentIds = findGroupParentIds(
props.treeData,
checkedKeys.value as any,
{ id: props.fieldNames.key },
);
/**
* uniq 解决上面的id重复问题
*/
checkedRealKeys.value = uniq([...parentIds, ...checkedKeys.value]);
stop();
}
if (!props.checkStrictly && checkedKeys.value.length > 0) {
/** 节点独立 这里是全部的节点 */
checkedRealKeys.value = checkedKeys.value;
stop();
}
});
/**
*
* @param checkedStateKeys 已经选中的子节点的ID
* @param info info.halfCheckedKeys为父节点的ID
*/
type CheckedState<T = number | string> =
| T[]
| { checked: T[]; halfChecked: T[] };
function handleChecked(checkedStateKeys: CheckedState, info: CheckInfo) {
// 数组的话为节点关联
if (Array.isArray(checkedStateKeys)) {
const halfCheckedKeys: number[] = (info.halfCheckedKeys || []) as number[];
checkedRealKeys.value = [...halfCheckedKeys, ...checkedStateKeys];
} else {
checkedRealKeys.value = [...checkedStateKeys.checked];
// fix: Invalid prop: type check failed for prop "value". Expected Array, got Object
checkedKeys.value = [...checkedStateKeys.checked];
}
}
function handleExpandChange(e: CheckboxChangeEvent) {
// 这个用于展示 // 这个用于展示
checkedKeys.value = e.target.checked ? allKeys.value : []; checkedKeys.value = e.target.checked ? allKeys.value : [];
// 这个用于提交
checkedRealKeys.value = e.target.checked ? allKeys.value : [];
} }
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
function handleExpandOrCollapseAll(e: CheckboxChangeEvent) { function handleExpandOrCollapseAll() {
const expand = e.target.checked; expandStatus.value = !expandStatus.value;
expandedKeys.value = expand ? allKeys.value : []; expandedKeys.value = expandStatus.value ? allKeys.value : [];
} }
function handleCheckStrictlyChange(e: CheckboxChangeEvent) { function handleCheckStrictlyChange() {
emit('checkStrictlyChange', e.target.checked);
if (props.resetOnStrictlyChange) { if (props.resetOnStrictlyChange) {
checkedKeys.value = []; checkedKeys.value = [];
checkedRealKeys.value = [];
} }
} }
/**
* 暴露方法来获取用于提交的全部节点
* uniq去重(保险方案)
*/
defineExpose({
getCheckedKeys: () => uniq(checkedRealKeys.value),
});
onMounted(async () => { onMounted(async () => {
if (props.expandAllOnInit) { if (props.expandAllOnInit) {
await nextTick(); await nextTick();
expandedKeys.value = allKeys.value; expandedKeys.value = allKeys.value;
} }
}); });
const slots = useSlots() as SetupContext['slots'];
</script> </script>
<template> <template>
<div class="bg-background w-full rounded-lg border-[1px] p-[12px]"> <div class="bg-background w-full rounded-lg border-[1px] p-[12px]">
<!-- <div class="flex flex-col gap-6 text-[13px]">
<div>computedCheckedKeys {{ computedCheckedKeys }}</div>
<div>checkedKeys {{ checkedKeys }}</div>
</div> -->
<div class="flex items-center justify-between gap-2 border-b-[1px] pb-2"> <div class="flex items-center justify-between gap-2 border-b-[1px] pb-2">
<div> <div class="opacity-75">
<span>节点状态: </span> <span>节点状态: </span>
<span :class="[props.checkStrictly ? 'text-primary' : 'text-red-500']"> <span :class="[checkStrictly ? 'text-primary' : 'text-red-500']">
{{ associationText }} {{ associationText }}
</span> </span>
</div> </div>
<div>
已选中
<span class="text-primary mx-1 font-semibold">
{{ checkedRealKeys.length }}
</span>
个节点
</div>
</div> </div>
<div <div
class="flex flex-wrap items-center justify-between border-b-[1px] py-2" class="flex flex-wrap items-center justify-between border-b-[1px] py-2"
> >
<Checkbox <a-button size="small" @click="handleExpandOrCollapseAll">
v-model:checked="expandStatus"
@change="handleExpandOrCollapseAll"
>
展开/折叠全部 展开/折叠全部
</Checkbox> </a-button>
<Checkbox v-model:checked="selectAllStatus" @change="handleExpandChange"> <Checkbox
v-model:checked="selectAllStatus"
@change="handleCheckedAllChange"
>
全选/取消全选 全选/取消全选
</Checkbox> </Checkbox>
<Checkbox :checked="checkStrictly" @change="handleCheckStrictlyChange"> <Checkbox
v-model:checked="checkStrictly"
@change="handleCheckStrictlyChange"
>
父子节点关联 父子节点关联
</Checkbox> </Checkbox>
</div> </div>
<div class="py-2"> <div class="py-2">
<Tree <Tree
v-if="treeData.length > 0" :check-strictly="!checkStrictly"
v-model:check-strictly="innerCheckedStrictly" v-model:checked-keys="computedCheckedKeys"
v-model:checked-keys="checkedKeys"
v-model:expanded-keys="expandedKeys" v-model:expanded-keys="expandedKeys"
:checkable="true" :checkable="true"
:field-names="fieldNames" :field-names="fieldNames"
:selectable="false" :selectable="false"
:tree-data="treeData" :tree-data="treeData"
@check="handleChecked"
> >
<template <template
v-for="slotName in Object.keys(slots)" v-for="slotName in Object.keys($slots)"
:key="slotName" :key="slotName"
#[slotName]="data" #[slotName]="data"
> >
@@ -219,3 +174,20 @@ const slots = useSlots() as SetupContext['slots'];
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
:deep(.ant-tree) {
// 勾选框居中
& .ant-tree-checkbox {
margin: 0;
margin-right: 6px;
}
// 展开图标居中
& .ant-tree-switcher {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -115,9 +115,14 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
if (id) { if (id) {
await formApi.setFieldValue('parentId', id); await formApi.setFieldValue('parentId', id);
if (update) { // 创建元组(不是数组 元素位置固定)
// 没有依赖关系 同时加载 const promise = [
const [record] = await Promise.all([menuInfo(id), setupMenuSelect()]); update ? menuInfo(id) : null,
setupMenuSelect(),
] as const;
// 并行获取菜单树选择和菜单信息
const [record] = await Promise.all(promise);
if (record) {
await formApi.setValues(record); await formApi.setValues(record);
} }
} else { } else {

View File

@@ -97,11 +97,19 @@ async function handleClosed() {
<BasicForm> <BasicForm>
<template #tip> <template #tip>
<div class="ml-7 w-full"> <div class="ml-7 w-full">
<Alert <Alert show-icon type="warning">
message="私有桶使用自定义域名无法预览, 但可以正常上传/下载" <template #message>
show-icon 私有桶(minio)使用自定义域名需要参考
type="warning" <a
/> href="https://gitee.com/dromara/RuoYi-Vue-Plus/issues/IBQIKC"
target="_blank"
class="text-primary"
>
支持minio预览私有桶
</a>
, 否则无法预览
</template>
</Alert>
</div> </div>
</template> </template>
</BasicForm> </BasicForm>

View File

@@ -1,11 +1,15 @@
import type { FormSchemaGetter } from '#/adapter/form'; import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table'; import type { VxeGridProps } from '#/adapter/vxe-table';
import { markRaw } from 'vue';
import { DictEnum } from '@vben/constants'; import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils'; import { getPopupContainer } from '@vben/utils';
import { Tag } from 'ant-design-vue'; import { Tag } from 'ant-design-vue';
import { DefaultSlot } from '#/components/global/slot';
import { TreeSelectPanel } from '#/components/tree';
import { getDictOptions } from '#/utils/dict'; import { getDictOptions } from '#/utils/dict';
/** /**
@@ -177,15 +181,6 @@ export const authModalSchemas: FormSchemaGetter = () => [
fieldName: 'roleId', fieldName: 'roleId',
label: '角色ID', label: '角色ID',
}, },
{
component: 'Radio',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'deptCheckStrictly',
label: 'deptCheckStrictly',
},
{ {
component: 'Input', component: 'Input',
componentProps: { componentProps: {
@@ -214,12 +209,39 @@ export const authModalSchemas: FormSchemaGetter = () => [
label: '权限范围', label: '权限范围',
}, },
{ {
component: 'TreeSelect', component: 'Radio',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'deptCheckStrictly',
label: 'deptCheckStrictly',
},
{
// 这种的场景基本上是一个组件需要绑定两个或以上的场景
component: markRaw(DefaultSlot),
defaultValue: [], defaultValue: [],
componentProps: {
rootDivAttrs: {
class: 'w-full',
},
},
dependencies: { dependencies: {
show: (values) => values.dataScope === '2', show: (values) => values.dataScope === '2',
triggerFields: ['dataScope'], triggerFields: ['dataScope'],
}, },
renderComponentContent: (model) => ({
default: (attrs: any) => {
return (
<TreeSelectPanel
expand-all-on-init={true}
treeData={attrs.treeData}
v-model:checkStrictly={model.deptCheckStrictly}
v-model:value={model.deptIds}
/>
);
},
}),
fieldName: 'deptIds', fieldName: 'deptIds',
help: '更改后立即生效', help: '更改后立即生效',
label: '部门权限', label: '部门权限',

View File

@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DeptOption } from '#/api/system/role/model'; import type { DeptOption } from '#/api/system/role/model';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from '@vben/utils'; import { cloneDeep, findGroupParentIds } from '@vben/utils';
import { uniq } from 'lodash-es';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { roleDataScope, roleDeptTree, roleInfo } from '#/api/system/role'; import { roleDataScope, roleDeptTree, roleInfo } from '#/api/system/role';
import { TreeSelectPanel } from '#/components/tree';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup'; import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { authModalSchemas } from './data'; import { authModalSchemas } from './data';
@@ -26,26 +25,32 @@ const [BasicForm, formApi] = useVbenForm({
showDefaultActions: false, showDefaultActions: false,
}); });
const deptTree = ref<DeptOption[]>([]); /**
* 保存部门数据 用于获取祖先节点
*/
let treeData: DeptOption[] = [];
async function setupDeptTree(id: number | string) { async function setupDeptTree(id: number | string) {
const resp = await roleDeptTree(id); const resp = await roleDeptTree(id);
formApi.setFieldValue('deptIds', resp.checkedKeys); const { checkedKeys, depts } = resp;
// 设置菜单信息
deptTree.value = resp.depts;
}
async function customFormValueGetter() { /**
const v = await defaultFormValueGetter(formApi)(); * 设置部门树数据
// 获取勾选信息 */
const menuIds = deptSelectRef.value?.[0]?.getCheckedKeys() ?? []; formApi.updateSchema([
const mixStr = v + menuIds.join(','); { fieldName: 'deptIds', componentProps: { treeData: depts } },
return mixStr; ]);
/**
* 设置选中 必须先传递treeData
* Note: Tree missing follow keys: '1981565541727186945'
*/
await formApi.setFieldValue('deptIds', checkedKeys);
treeData = depts;
} }
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff( const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{ {
initializedGetter: customFormValueGetter, initializedGetter: defaultFormValueGetter(formApi),
currentGetter: customFormValueGetter, currentGetter: defaultFormValueGetter(formApi),
}, },
); );
@@ -56,14 +61,14 @@ const [BasicModal, modalApi] = useVbenModal({
onConfirm: handleConfirm, onConfirm: handleConfirm,
onOpenChange: async (isOpen) => { onOpenChange: async (isOpen) => {
if (!isOpen) { if (!isOpen) {
treeData = [];
return null; return null;
} }
modalApi.modalLoading(true); modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id: number | string }; const { id } = modalApi.getData() as { id: number | string };
setupDeptTree(id); const [record] = await Promise.all([roleInfo(id), setupDeptTree(id)]);
const record = await roleInfo(id);
await formApi.setValues(record); await formApi.setValues(record);
markInitialized(); markInitialized();
@@ -71,11 +76,6 @@ const [BasicModal, modalApi] = useVbenModal({
}, },
}); });
/**
* 这里拿到的是一个数组ref
*/
const deptSelectRef = ref();
async function handleConfirm() { async function handleConfirm() {
try { try {
modalApi.lock(true); modalApi.lock(true);
@@ -87,7 +87,15 @@ async function handleConfirm() {
const data = cloneDeep(await formApi.getValues()); const data = cloneDeep(await formApi.getValues());
// 不为自定义权限的话 删除部门id // 不为自定义权限的话 删除部门id
if (data.dataScope === '2') { if (data.dataScope === '2') {
const deptIds = deptSelectRef.value?.[0]?.getCheckedKeys() ?? []; let { deptIds, deptCheckStrictly } = data;
// 节点关联 需要拼接上祖级ID(获取的是不带的)
if (deptCheckStrictly) {
// 找到所有父级ID
const parentIds = findGroupParentIds(treeData, deptIds, { id: 'id' });
// 去重
deptIds = uniq([...parentIds, ...deptIds]);
}
// 赋值
data.deptIds = deptIds; data.deptIds = deptIds;
} else { } else {
data.deptIds = []; data.deptIds = [];
@@ -107,29 +115,10 @@ async function handleClosed() {
await formApi.resetForm(); await formApi.resetForm();
resetInitialized(); resetInitialized();
} }
/**
* 通过回调更新 无法通过v-model
* @param value 菜单选择是否严格模式
*/
function handleCheckStrictlyChange(value: boolean) {
formApi.setFieldValue('deptCheckStrictly', value);
}
</script> </script>
<template> <template>
<BasicModal class="min-h-[600px] w-[550px]" title="分配权限"> <BasicModal class="min-h-[600px] w-[550px]" title="分配权限">
<BasicForm> <BasicForm />
<template #deptIds="slotProps">
<TreeSelectPanel
ref="deptSelectRef"
v-bind="slotProps"
:check-strictly="formApi.form.values.deptCheckStrictly"
:expand-all-on-init="true"
:tree-data="deptTree"
@check-strictly-change="handleCheckStrictlyChange"
/>
</template>
</BasicForm>
</BasicModal> </BasicModal>
</template> </template>

View File

@@ -15,6 +15,7 @@ import { Modal, Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table'; import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import { import {
dictSyncTenant, dictSyncTenant,
syncTenantConfig,
tenantExport, tenantExport,
tenantList, tenantList,
tenantRemove, tenantRemove,
@@ -144,6 +145,18 @@ function handleSyncTenantDict() {
}, },
}); });
} }
function handleSyncTenantConfig() {
Modal.confirm({
title: '提示',
iconType: 'warning',
content: '确认同步租户参数配置?',
onOk: async () => {
await syncTenantConfig();
await tableApi.query();
},
});
}
</script> </script>
<template> <template>
@@ -157,6 +170,12 @@ function handleSyncTenantDict() {
> >
同步租户字典 同步租户字典
</a-button> </a-button>
<a-button
v-access:code="['system:tenant:edit']"
@click="handleSyncTenantConfig"
>
同步租户参数配置
</a-button>
<a-button <a-button
v-access:code="['system:tenant:export']" v-access:code="['system:tenant:export']"
@click="handleDownloadExcel" @click="handleDownloadExcel"

View File

@@ -38,8 +38,18 @@ import { approveWithReasonModal } from '../helper';
import userSelectModal from '../user-select-modal.vue'; import userSelectModal from '../user-select-modal.vue';
interface Props { interface Props {
/**
* 行数据的taskInfo?
*/
task?: TaskInfo; task?: TaskInfo;
/**
* 审批类型 根据不同类型显示按钮
*/
type: ApprovalType; type: ApprovalType;
/**
* 为审批类型时候 显示的按钮(按钮权限)
*/
buttonPermissions: Record<string, boolean>;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -47,25 +57,11 @@ const emit = defineEmits<{
reload: []; reload: [];
}>(); }>();
/**
* 按钮权限
*/
const buttonPermissions = computed(() => {
const record: Record<string, boolean> = {};
if (!props.task) {
return record;
}
props.task.buttonList.forEach((item) => {
record[item.code] = item.show;
});
return record;
});
// 是否显示 `其他` 按钮 // 是否显示 `其他` 按钮
const showButtonOther = computed(() => { const showButtonOther = computed(() => {
const moreCollections = new Set(['addSign', 'subSign', 'transfer', 'trust']); const moreCollections = new Set(['addSign', 'subSign', 'transfer', 'trust']);
return Object.keys(buttonPermissions.value).some( return Object.keys(props.buttonPermissions).some(
(key) => moreCollections.has(key) && buttonPermissions.value[key], (key) => moreCollections.has(key) && props.buttonPermissions[key],
); );
}); });
@@ -154,10 +150,11 @@ const [ApprovalModal, approvalModalApi] = useVbenModal({
connectedComponent: approvalModal, connectedComponent: approvalModal,
}); });
function handleApproval() { function handleApproval() {
const { buttonPermissions } = props;
// 是否具有抄送权限 // 是否具有抄送权限
const copyPermission = buttonPermissions.value?.copy ?? false; const copyPermission = buttonPermissions?.copy ?? false;
// 是否具有选人权限 // 是否具有选人权限
const assignPermission = buttonPermissions.value?.pop ?? false; const assignPermission = buttonPermissions?.pop ?? false;
approvalModalApi.setData({ approvalModalApi.setData({
taskId: props.task?.id, taskId: props.task?.id,
copyPermission, copyPermission,

View File

@@ -43,6 +43,8 @@ const [BasicModal, modalApi] = useVbenModal({
if (!isOpen) { if (!isOpen) {
return null; return null;
} }
modalApi.modalLoading(true);
const { taskId } = modalApi.getData() as ModalProps; const { taskId } = modalApi.getData() as ModalProps;
// 查询是否有按钮权限 // 查询是否有按钮权限
@@ -63,6 +65,8 @@ const [BasicModal, modalApi] = useVbenModal({
}, },
}, },
]); ]);
modalApi.modalLoading(false);
}, },
}); });
@@ -108,6 +112,11 @@ const [BasicForm, formApi] = useVbenForm({
component: 'Input', component: 'Input',
defaultValue: [], defaultValue: [],
label: '抄送人', label: '抄送人',
// 默认不显示
dependencies: {
if: false,
triggerFields: [''],
},
}, },
], ],
showDefaultActions: false, showDefaultActions: false,

View File

@@ -1,6 +1,6 @@
<!-- <!--
审批详情 审批详情
动态渲染要显示的内容 需要flowDescripionsMap先定义好组件 动态渲染要显示的内容 需要flowDescripionsMap先定义好组件
--> -->
<script setup lang="ts"> <script setup lang="ts">
import type { FlowComponentsMapMapKey } from '../register'; import type { FlowComponentsMapMapKey } from '../register';

View File

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

View File

@@ -1,117 +1,72 @@
<!-- 该文件需要重构 但我没空 --> <!--
TODO: 优化项
会先加载流程信息 再加载业务表单信息
-->
<script setup lang="ts"> <script setup lang="ts">
import type { User } from '#/api/core/user'; import type { ApprovalType } from './type';
import type { FlowInfoResponse } from '#/api/workflow/instance/model'; import type { FlowInfoResponse } from '#/api/workflow/instance/model';
import type { TaskInfo } from '#/api/workflow/task/model'; import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, h, onUnmounted, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Fallback, useVbenModal, VbenAvatar } from '@vben/common-ui'; import { Fallback, VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants'; import { DictEnum } from '@vben/constants';
import { cn, getPopupContainer } from '@vben/utils'; import { cn } from '@vben/utils';
import { import { CopyOutlined } from '@ant-design/icons-vue';
ArrowLeftOutlined, import { useClipboard } from '@vueuse/core';
CheckOutlined, import { Card, Divider, message, TabPane, Tabs } from 'ant-design-vue';
CopyOutlined,
EditOutlined,
ExclamationCircleOutlined,
MenuOutlined,
RollbackOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { useClipboard, useEventListener } from '@vueuse/core';
import {
Card,
Divider,
Dropdown,
Menu,
MenuItem,
message,
Modal,
Space,
TabPane,
Tabs,
} from 'ant-design-vue';
import { isObject } from 'lodash-es';
import { import { flowInfo } from '#/api/workflow/instance';
cancelProcessApply, import { getTaskByTaskId } from '#/api/workflow/task';
deleteByInstanceIds,
flowInfo,
} from '#/api/workflow/instance';
import {
getTaskByTaskId,
taskOperation,
terminationTask,
updateAssignee,
} from '#/api/workflow/task';
import { renderDict } from '#/utils/render'; import { renderDict } from '#/utils/render';
import { approvalModal, approvalRejectionModal, flowInterfereModal } from '.'; import { FlowActions } from './actions';
import ApprovalDetails from './approval-details.vue'; import ApprovalDetails from './approval-details.vue';
import FlowPreview from './flow-preview.vue'; import FlowPreview from './flow-preview.vue';
import { approveWithReasonModal } from './helper';
import userSelectModal from './user-select-modal.vue';
defineOptions({ defineOptions({
name: 'ApprovalPanel', name: 'ApprovalPanel',
inheritAttrs: false, inheritAttrs: false,
}); });
const props = defineProps<{ task?: TaskInfo; type: ApprovalType }>(); const props = defineProps<Props>();
/** /**
* 下面按钮点击后会触发的事件 * 下面按钮点击后会触发的事件
*/ */
const emit = defineEmits<{ reload: [] }>(); defineEmits<{ reload: [] }>();
const currentTask = ref<TaskInfo>(); interface Props {
/** /**
* 是否显示 加签/减签操作 * 行数据(list)的info
*/ */
const showMultiActions = computed(() => { task?: TaskInfo;
if (!currentTask.value) { /**
return false; * 审批类型
*/
type: ApprovalType;
} }
if (Number(currentTask.value.nodeRatio) > 0) {
return true;
}
return false;
});
/**
* 目前的作用只为了获取按钮权限 因为list接口(行数据)获取为空
*/
const onlyForBtnPermissionTask = ref<TaskInfo>();
/** /**
* 按钮权限 * 按钮权限
*/ */
const buttonPermissions = computed(() => { const buttonPermissions = computed(() => {
const record: Record<string, boolean> = {}; const record: Record<string, boolean> = {};
if (!currentTask.value) { if (!onlyForBtnPermissionTask.value) {
return record; return record;
} }
currentTask.value.buttonList.forEach((item) => { onlyForBtnPermissionTask.value.buttonList.forEach((item) => {
record[item.code] = item.show; record[item.code] = item.show;
}); });
return record; return record;
}); });
// 是否显示 `其他` 按钮
const showButtonOther = computed(() => {
const moreCollections = new Set(['addSign', 'subSign', 'transfer', 'trust']);
return Object.keys(buttonPermissions.value).some(
(key) => moreCollections.has(key) && buttonPermissions.value[key],
);
});
/**
* myself 我发起的
* readonly 只读 只用于查看
* approve 审批
* admin 流程监控 - 待办任务使用
*/
type ApprovalType = 'admin' | 'approve' | 'myself' | 'readonly';
const showFooter = computed(() => { const showFooter = computed(() => {
if (props.type === 'readonly') { if (props.type === 'readonly') {
return false; return false;
@@ -131,36 +86,34 @@ const currentFlowInfo = ref<FlowInfoResponse>();
* card的loading状态 * card的loading状态
*/ */
const loading = ref(false); 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) { async function handleLoadInfo(task: TaskInfo | undefined) {
if (!task) {
return null;
}
try { try {
if (!task) return null;
loading.value = true; 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; * 不为审批不需要调用`getTaskByTaskId`接口
*/
if (props.type !== 'approve') {
const flowResp = await flowInfo(task.businessId);
currentFlowInfo.value = flowResp;
return;
}
/**
* getTaskByTaskId主要为了获取按钮权限 目前没有其他功能
* 行数据(即props.task)获取的是没有按钮权限的
*/
const [flowResp, taskResp] = await Promise.all([
flowInfo(task.businessId),
getTaskByTaskId(task.id),
]);
currentFlowInfo.value = flowResp;
onlyForBtnPermissionTask.value = taskResp;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@@ -170,217 +123,6 @@ async function handleLoadInfo(task: TaskInfo | undefined) {
watch(() => props.task, handleLoadInfo); 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() {
// 是否具有抄送权限
const copyPermission = buttonPermissions.value?.copy ?? false;
// 是否具有选人权限
const assignPermission = buttonPermissions.value?.pop ?? false;
approvalModalApi.setData({
taskId: props.task?.id,
copyPermission,
assignPermission,
});
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在本地开发没有问题
* 打包后在一些设备会无法复制 使用legacy来保证兼容性 * 打包后在一些设备会无法复制 使用legacy来保证兼容性
@@ -407,6 +149,7 @@ async function handleCopy(text: string) {
<CopyOutlined class="cursor-pointer" @click="handleCopy(task.id)" /> <CopyOutlined class="cursor-pointer" @click="handleCopy(task.id)" />
</div> </div>
</template> </template>
<template #extra> <template #extra>
<a-button size="small" @click="() => handleLoadInfo(task)"> <a-button size="small" @click="() => handleLoadInfo(task)">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -414,6 +157,7 @@ async function handleCopy(text: string) {
</div> </div>
</a-button> </a-button>
</template> </template>
<div class="flex flex-col gap-5 p-4"> <div class="flex flex-col gap-5 p-4">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -426,19 +170,24 @@ async function handleCopy(text: string) {
/> />
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<VbenAvatar <VbenAvatar
:alt="task?.createByName ?? ''" :alt="task?.createByName ?? ''"
class="bg-primary size-[28px] rounded-full text-white" class="bg-primary size-[28px] rounded-full text-white"
src="" src=""
/> />
<span>{{ task.createByName }}</span> <span>{{ task.createByName }}</span>
<div class="flex items-center opacity-50"> <div class="flex items-center opacity-50">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="icon-[bxs--category-alt] size-[16px]"></span> <span class="icon-[bxs--category-alt] size-[16px]"></span>
流程分类: {{ task.categoryName }} 流程分类: {{ task.categoryName }}
</div> </div>
<Divider type="vertical" /> <Divider type="vertical" />
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="icon-[mdi--clock-outline] size-[16px]"></span> <span class="icon-[mdi--clock-outline] size-[16px]"></span>
提交时间: {{ task.createTime }} 提交时间: {{ task.createTime }}
@@ -446,154 +195,32 @@ async function handleCopy(text: string) {
</div> </div>
</div> </div>
</div> </div>
<Tabs v-if="currentFlowInfo" class="flex-1"> <Tabs v-if="currentFlowInfo" class="flex-1">
<TabPane key="1" tab="审批详情"> <TabPane key="1" tab="审批详情">
<ApprovalDetails <ApprovalDetails
:current-flow-info="currentFlowInfo" :current-flow-info="currentFlowInfo"
:iframe-loaded="iframeLoaded"
:iframe-height="iframeHeight"
:task="task" :task="task"
/> />
</TabPane> </TabPane>
<TabPane key="2" tab="审批流程图"> <TabPane key="2" tab="审批流程图">
<FlowPreview :instance-id="currentFlowInfo.instanceId" /> <FlowPreview :instance-id="currentFlowInfo.instanceId" />
</TabPane> </TabPane>
</Tabs> </Tabs>
</div> </div>
<!-- 固定底部 -->
<!-- 固定底部 占位高度 -->
<div class="h-[58px]"></div> <div class="h-[58px]"></div>
<div <FlowActions
v-if="showFooter" v-if="showFooter"
:class=" :type="type"
cn( :task="task"
'absolute bottom-0 left-0', :button-permissions="buttonPermissions"
'border-t-solid border-t-[1px]', @reload="$emit('reload')"
'bg-background w-full p-3',
)
"
>
<div class="flex justify-end">
<Space v-if="type === 'myself'">
<a-button
v-if="revocable"
danger
ghost
type="primary"
:icon="h(RollbackOutlined)"
@click="handleCancel"
>
撤销申请
</a-button>
<a-button
type="primary"
ghost
v-if="editableAndRemoveable"
:icon="h(EditOutlined)"
@click="handleEdit"
>
重新编辑
</a-button>
<a-button
v-if="editableAndRemoveable"
danger
ghost
type="primary"
:icon="h(EditOutlined)"
@click="handleRemove"
>
删除
</a-button>
</Space>
<Space v-if="type === 'approve'">
<a-button
type="primary"
ghost
:icon="h(CheckOutlined)"
@click="handleApproval"
>
通过
</a-button>
<a-button
v-if="buttonPermissions?.termination"
danger
ghost
type="primary"
:icon="h(ExclamationCircleOutlined)"
@click="handleTermination"
>
终止
</a-button>
<a-button
v-if="buttonPermissions?.back"
danger
ghost
type="primary"
:icon="h(ArrowLeftOutlined)"
@click="handleRejection"
>
驳回
</a-button>
<Dropdown
:get-popup-container="getPopupContainer"
placement="bottomRight"
>
<template #overlay>
<Menu>
<MenuItem
v-if="buttonPermissions?.trust"
key="1"
@click="() => delegationModalApi.open()"
>
<UserOutlined class="mr-2" />委托
</MenuItem>
<MenuItem
v-if="buttonPermissions?.transfer"
key="2"
@click="() => transferModalApi.open()"
>
<RollbackOutlined class="mr-2" /> 转办
</MenuItem>
<MenuItem
v-if="showMultiActions && buttonPermissions?.addSign"
key="3"
@click="() => addSignatureModalApi.open()"
>
<UsergroupAddOutlined class="mr-2" /> 加签
</MenuItem>
<MenuItem
v-if="showMultiActions && buttonPermissions?.subSign"
key="4"
@click="() => reductionSignatureModalApi.open()"
>
<UsergroupDeleteOutlined class="mr-2" /> 减签
</MenuItem>
</Menu>
</template>
<a-button v-if="showButtonOther" :icon="h(MenuOutlined)">
其他
</a-button>
</Dropdown>
<ApprovalModal @complete="$emit('reload')" />
<RejectionModal @complete="$emit('reload')" />
<DelegationModal mode="single" @finish="handleDelegation" />
<TransferModal mode="single" @finish="handleTransfer" />
<AddSignatureModal mode="multiple" @finish="handleAddSignature" />
<ReductionSignatureModal
mode="multiple"
@finish="handleReductionSignature"
/> />
</Space>
<Space v-if="type === 'admin'">
<a-button @click="handleFlowInterfere"> 流程干预 </a-button>
<a-button @click="() => updateAssigneeModalApi.open()">
修改办理人
</a-button>
<FlowInterfereModal @complete="$emit('reload')" />
<UpdateAssigneeModal mode="single" @finish="handleUpdateAssignee" />
</Space>
</div>
</div>
</Card> </Card>
<slot v-else name="empty"> <slot v-else name="empty">
<Fallback title="点击左侧选择" /> <Fallback title="点击左侧选择" />
</slot> </slot>

View File

@@ -45,11 +45,8 @@ onMounted(async () => {
})); }));
}); });
/**
* 这里无法处理昵称中带,的情况
*/
const isMultiplePerson = computed( const isMultiplePerson = computed(
() => props.item.approveName?.split(',').length > 1, () => props.item.approver?.split(',').length > 1,
); );
</script> </script>
@@ -87,6 +84,7 @@ const isMultiplePerson = computed(
</div> </div>
<div :class="cn('mt-2 flex flex-wrap gap-2')" v-if="isMultiplePerson"> <div :class="cn('mt-2 flex flex-wrap gap-2')" v-if="isMultiplePerson">
<!-- 如果昵称中带, 这里的处理是不准确的 -->
<div <div
:class="cn('bg-foreground/5 flex items-center rounded-full', 'p-1')" :class="cn('bg-foreground/5 flex items-center rounded-full', 'p-1')"
v-for="(name, index) in item.approveName.split(',')" v-for="(name, index) in item.approveName.split(',')"

View File

@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Flow } from '#/api/workflow/instance/model'; import type { Flow } from '#/api/workflow/instance/model';
import { Timeline } from 'ant-design-vue'; import { Empty, Timeline } from 'ant-design-vue';
import ApprovalTimelineItem from './approval-timeline-item.vue'; import ApprovalTimelineItem from './approval-timeline-item.vue';
const props = defineProps<{ interface Props {
list: Flow[]; list: Flow[];
}>(); }
defineProps<Props>();
</script> </script>
<template> <template>
<Timeline v-if="props.list.length > 0"> <Timeline v-if="list.length > 0">
<ApprovalTimelineItem <ApprovalTimelineItem v-for="item in list" :key="item.id" :item="item" />
v-for="item in props.list"
:key="item.id"
:item="item"
/>
</Timeline> </Timeline>
<Empty v-else />
</template> </template>

View File

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

View File

@@ -1,3 +1,5 @@
<!-- 流程图预览组件 -->
<script setup lang="ts"> <script setup lang="ts">
import { useAppConfig } from '@vben/hooks'; import { useAppConfig } from '@vben/hooks';
import { stringify } from '@vben/request'; import { stringify } from '@vben/request';
@@ -7,7 +9,14 @@ import { useWarmflowIframe } from './hook';
defineOptions({ name: 'FlowPreview' }); defineOptions({ name: 'FlowPreview' });
const props = defineProps<{ instanceId: string }>(); const props = defineProps<Props>();
interface Props {
/**
* 流程实例ID
*/
instanceId: string;
}
const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD); const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD);
@@ -21,6 +30,7 @@ const params = {
/** /**
* iframe地址 * iframe地址
* 后端地址 + 固定flow地址拼接
*/ */
const url = `${import.meta.env.VITE_GLOB_API_URL}/warm-flow-ui/index.html?${stringify(params)}`; const url = `${import.meta.env.VITE_GLOB_API_URL}/warm-flow-ui/index.html?${stringify(params)}`;
@@ -28,5 +38,9 @@ const { iframeRef } = useWarmflowIframe();
</script> </script>
<template> <template>
<iframe ref="iframeRef" :src="url" class="h-[600px] w-full border"></iframe> <iframe
ref="iframeRef"
:src="url"
class="h-[600px] w-full rounded-[6px] border"
></iframe>
</template> </template>

View File

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

View File

@@ -10,19 +10,25 @@ export function useWarmflowIframe() {
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframeRef'); const iframeRef = useTemplateRef<HTMLIFrameElement>('iframeRef');
const { isDark } = usePreferences(); const { isDark } = usePreferences();
onMounted(() => { async function iframeLoadEvent() {
/**
* load只是iframe加载完 而非vue加载完
*/
iframeRef.value?.addEventListener('load', async () => {
/** /**
* TODO: 这里可以优化 因为拿不到内部vue的mount状态 * TODO: 这里可以优化 因为拿不到内部vue的mount状态
*/ */
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
const theme = isDark.value ? 'theme-dark' : 'theme-light'; const theme = isDark.value ? 'theme-dark' : 'theme-light';
iframeRef.value?.contentWindow?.postMessage({ type: theme }); iframeRef.value?.contentWindow?.postMessage({ type: theme });
}
onMounted(() => {
/**
* load只是iframe加载完 而非vue加载完
*/
iframeRef.value?.addEventListener('load', iframeLoadEvent);
}); });
});
// onBeforeUnmount(() => {
// iframeRef.value?.removeEventListener('load', iframeLoadEvent);
// });
// 监听主题切换 通知iframe切换 // 监听主题切换 通知iframe切换
watch(isDark, (dark) => { watch(isDark, (dark) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import { $t } from '@vben/locales';
import { getVxePopupContainer } from '@vben/utils'; import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, RadioGroup, Space } from 'ant-design-vue'; import { Modal, Popconfirm, RadioGroup, Space } from 'ant-design-vue';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table'; import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import { import {
@@ -96,18 +95,6 @@ const gridOptions: VxeGridProps = {
Reflect.deleteProperty(formValues, 'category'); Reflect.deleteProperty(formValues, 'category');
} }
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
);
}
// 使用nodeName会导致选人组件hover报错
if (formValues._nodeName) {
formValues.nodeName = formValues._nodeName;
Reflect.deleteProperty(formValues, '_nodeName');
}
return await currentTypeApi({ return await currentTypeApi({
pageNum: page.currentPage, pageNum: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
@@ -163,11 +150,10 @@ const [InstanceVariableModal, instanceVariableModalApi] = useVbenModal({
connectedComponent: instanceVariableModal, connectedComponent: instanceVariableModal,
}); });
function handleVariable(row: Recordable<any>) { function handleVariable(row: Recordable<any>) {
instanceVariableModalApi.setData({ instanceVariableModalApi.setData({ instanceId: row.id });
instanceId: row.id,
});
instanceVariableModalApi.open(); instanceVariableModalApi.open();
} }
const [FlowInfoModal, flowInfoModalApi] = useVbenModal({ const [FlowInfoModal, flowInfoModalApi] = useVbenModal({
connectedComponent: flowInfoModal, connectedComponent: flowInfoModal,
}); });

View File

@@ -4,7 +4,7 @@ import { ref } from 'vue';
import { JsonPreview, useVbenModal } from '@vben/common-ui'; import { JsonPreview, useVbenModal } from '@vben/common-ui';
import { cn, getPopupContainer } from '@vben/utils'; import { cn, getPopupContainer } from '@vben/utils';
import { Modal, Tag } from 'ant-design-vue'; import { message, Modal, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { instanceVariable, updateFlowVariable } from '#/api/workflow/instance'; import { instanceVariable, updateFlowVariable } from '#/api/workflow/instance';
@@ -107,11 +107,30 @@ const [Form, formApi] = useVbenForm({
getPopupContainer, getPopupContainer,
}, },
}, },
{
fieldName: 'valueType',
component: 'Select',
label: '变量类型',
rules: 'selectRequired',
componentProps: {
getPopupContainer,
options: [
{
label: 'string',
value: 'string',
},
{
label: 'boolean | number | object (使用JSON.parse)',
value: 'object',
},
],
},
},
{ {
fieldName: 'value', fieldName: 'value',
component: 'Input', component: 'Input',
label: '变量值', label: '变量值',
rules: 'selectRequired', rules: 'required',
}, },
], ],
resetButtonOptions: { resetButtonOptions: {
@@ -142,11 +161,24 @@ async function handleSubmit(values: any) {
const { instanceId } = modalApi.getData() as ModalData; const { instanceId } = modalApi.getData() as ModalData;
let transformValue = values.value;
if (values.valueType !== 'string') {
try {
transformValue = JSON.parse(values.value);
} catch (error) {
console.error(error);
if (error instanceof Error) {
message.error(error.message);
}
throw error;
}
}
// 修改 // 修改
const requestData = { const requestData = {
instanceId, instanceId,
key: values.key, key: values.key,
value: values.value, value: transformValue,
}; };
await updateFlowVariable(requestData); await updateFlowVariable(requestData);
await formApi.resetForm(); await formApi.resetForm();
@@ -170,8 +202,12 @@ async function handleSubmit(values: any) {
> >
<JsonPreview :data="data" /> <JsonPreview :data="data" />
</div> </div>
<div class="mt-2 text-sm font-medium text-red-500"> <div class="mt-2 break-all text-sm font-medium text-orange-500">
由于限制 只能变更字段为string类型 需要支持变量类型需要更改后端代码(原版只支持string类型)
<div>
ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowVariableBo.java
</div>
将value的类型改为Object才能使用
</div> </div>
<Form class="mt-2" /> <Form class="mt-2" />
</BasicModal> </BasicModal>

View File

@@ -1,235 +1,360 @@
<script setup lang="tsx"> <!-- eslint-disable no-use-before-define -->
import type { RadioChangeEvent } from 'ant-design-vue'; <script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import type { VbenFormProps } from '@vben/common-ui'; import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import type { ComponentType } from '#/adapter/component'; import { Page } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table'; import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
import { markRaw, onMounted, ref } from 'vue'; 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 { Page, useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { RadioGroup } from 'ant-design-vue';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category'; import { categoryTree } from '#/api/workflow/category';
import { pageByAllTaskFinish, pageByAllTaskWait } from '#/api/workflow/task'; import { pageByAllTaskFinish, pageByAllTaskWait } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { ApprovalPanelDrawerComp, CopyComponent } from '../components'; import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper'; import { bottomOffset } from './constant';
const formOptions: VbenFormProps<ComponentType> = { const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: [
{
fieldName: 'category',
component: 'TreeSelect',
label: '流程分类',
},
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
onMounted(async () => { /**
const tree = await categoryTree(); * 流程监控 - 待办任务页面的id不唯一 改为前端处理
tableApi.formApi.updateSchema([ */
{ interface TaskItem extends TaskInfo {
fieldName: 'category', active: boolean;
componentProps: { randomId: string;
fieldNames: { label: 'label', value: 'id' }, }
showSearch: true,
treeData: tree, const taskList = ref<TaskItem[]>([]);
treeDefaultExpandAll: true, const taskTotal = ref(0);
treeLine: { showLeafIcon: false }, const page = ref(1);
// 筛选的字段 const loading = ref(false);
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const typeOptions = [ const typeOptions = [
{ label: '运行中', value: 'running' }, { label: '待办任务', value: 'todo' },
{ label: '已完成', value: 'finish' }, { label: '已办任务', value: 'done' },
] as const; ];
type OptionValue = (typeof typeOptions)[number]['value']; const currentType = ref('todo');
let currentTypeApi = pageByAllTaskWait; const currentApi = computed(() => {
const currentType = ref<OptionValue>('running'); if (currentType.value === 'todo') {
async function handleTypeChange(e: RadioChangeEvent) { return pageByAllTaskWait;
const { value } = e.target;
switch (value) {
case 'finish': {
currentTypeApi = pageByAllTaskFinish;
break;
} }
case 'running': { return pageByAllTaskFinish;
currentTypeApi = pageByAllTaskWait;
break;
}
}
await tableApi.reload();
}
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
{
field: 'businessTitle',
title: '业务标题',
formatter: ({ cellValue }) => cellValue ?? '-',
},
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'processedByName',
title: '办理人',
formatter: ({ cellValue }) => cellValue?.split?.(',') ?? cellValue,
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
);
}
return await currentTypeApi({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
}); });
}, const approvalType = computed(() => {
}, if (currentType.value === 'done') {
}, return 'readonly';
rowConfig: { }
keyField: 'id', return 'admin';
isCurrent: true, });
}, async function handleTypeChange() {
id: 'workflow-task-myself', // 需要先滚动到顶部
rowClassName: 'cursor-pointer', 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));
const [BasicTable, tableApi] = useVbenVxeGrid({ /**
formOptions, * 是否已经加载全部数据 即 taskList.length === taskTotal
gridOptions, */
gridEvents: { const isLoadComplete = computed(
cellClick: ({ row }) => { () => taskList.value.length === taskTotal.value,
handleOpen(row); );
},
}, // 卡片父容器的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;
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({ loading.value = false;
connectedComponent: ApprovalPanelDrawerComp, // 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
console.log('scrollTop + clientHeight', scrollTop + clientHeight);
console.log('scrollHeight', scrollHeight);
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await currentApi.value({
pageSize: 10,
pageNum: page.value,
...formData.value,
}); });
taskList.value.push(
...resp.rows.map((item) => ({
...item,
active: false,
randomId: uniqueId(),
})),
);
loading.value = false;
}
}, 200);
function handleOpen(row: any) { const lastSelectId = ref('');
let type = ''; const currentTask = ref<TaskInfo>();
switch (currentType.value) { async function handleCardClick(item: TaskItem) {
case 'finish': { const { randomId } = item;
type = 'readonly'; // 点击的是同一个
break; if (lastSelectId.value === randomId) {
return;
} }
case 'running': { currentTask.value = item;
type = 'admin'; // 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
break; 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);
} }
drawerApi.setData({ task: row, type }).open();
} const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
});
</script> </script>
<template> <template>
<Page :auto-content-height="true"> <Page :auto-content-height="true">
<BasicTable> <div class="flex h-full gap-2">
<template #toolbar-actions> <div
<RadioGroup 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" v-model:value="currentType"
:options="typeOptions" :options="typeOptions"
button-style="solid" block
option-type="button" class="mb-2"
@change="handleTypeChange" @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>
</BasicTable> <template #content>
<ApprovalPanelDrawer @reload="() => tableApi.query()" /> <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> </Page>
</template> </template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,162 +1,257 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts"> <script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui'; import type { TaskInfo } from '#/api/workflow/task/model';
import type { ComponentType } from '#/adapter/component'; import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { onMounted } from 'vue'; import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { getPopupContainer } from '@vben/utils';
import { Page, useVbenDrawer } from '@vben/common-ui'; import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import { DictEnum } from '@vben/constants'; import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category';
import { pageByCurrent } from '#/api/workflow/instance'; import { pageByCurrent } from '#/api/workflow/instance';
import { renderDict } from '#/utils/render';
import { ApprovalPanelDrawerComp } from '../components'; import { ApprovalCard, ApprovalPanel } from '../components';
import { bottomOffset } from './constant';
const formOptions: VbenFormProps<ComponentType> = { const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
commonConfig: {
labelWidth: 80, const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
componentProps: { const taskTotal = ref(0);
allowClear: true, const page = ref(1);
}, const loading = ref(false);
},
schema: [ const defaultFormData = {
{ flowName: '', // 流程定义名称
fieldName: 'category', nodeName: '', // 任务名称
component: 'TreeSelect', flowCode: '', // 流程定义编码
label: '流程分类', category: null as null | number, // 流程分类
},
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
}; };
const formData = ref(cloneDeep(defaultFormData));
onMounted(async () => { /**
const tree = await categoryTree(); * 是否已经加载全部数据 即 taskList.length === taskTotal
tableApi.formApi.updateSchema([ */
{ const isLoadComplete = computed(
fieldName: 'category', () => taskList.value.length === taskTotal.value,
componentProps: { );
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const gridOptions: VxeGridProps = { // 卡片父容器的ref
checkboxConfig: { const cardContainerRef = useTemplateRef('cardContainerRef');
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
// {
// field: 'businessTitle',
// title: '业务标题',
// formatter: ({ cellValue }) => cellValue ?? '-',
// },
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await pageByCurrent({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-myself',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({ /**
formOptions, * @param resetFields 是否清空查询参数
gridOptions, */
gridEvents: { async function reload(resetFields: boolean = false) {
cellClick: ({ row }) => { // 需要先滚动到顶部
handleOpen(row); cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({ page.value = 1;
connectedComponent: ApprovalPanelDrawerComp, currentTask.value = undefined;
}); taskTotal.value = 0;
lastSelectId.value = '';
function handleOpen(row: any) { if (resetFields) {
drawerApi.setData({ task: row, type: 'myself' }).open(); 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 - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
const { refreshTab } = useTabs();
</script> </script>
<template> <template>
<Page :auto-content-height="true"> <Page :auto-content-height="true">
<BasicTable table-title="我发起的" /> <div class="flex h-full gap-2">
<ApprovalPanelDrawer @reload="() => tableApi.query()" /> <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> </Page>
</template> </template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,183 +1,299 @@
<script setup lang="tsx"> <!-- eslint-disable no-use-before-define -->
import type { VbenFormProps } from '@vben/common-ui'; <script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import type { ComponentType } from '#/adapter/component'; import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { markRaw, onMounted } from 'vue'; import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
import { Page, useVbenDrawer } from '@vben/common-ui'; import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import { DictEnum } from '@vben/constants'; import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category'; import { categoryTree } from '#/api/workflow/category';
import { pageByTaskCopy } from '#/api/workflow/task'; import { pageByTaskCopy } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { ApprovalPanelDrawerComp, CopyComponent } from '../components'; import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper'; import { bottomOffset } from './constant';
const formOptions: VbenFormProps<ComponentType> = { const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
commonConfig: {
labelWidth: 80, const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
componentProps: { const taskTotal = ref(0);
allowClear: true, const page = ref(1);
}, const loading = ref(false);
},
schema: [ const defaultFormData = {
{ flowName: '', // 流程定义名称
fieldName: 'category', nodeName: '', // 任务名称
component: 'TreeSelect', flowCode: '', // 流程定义编码
label: '流程分类', createByIds: [] as string[], // 创建人
}, category: null as null | number, // 流程分类
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
}; };
const formData = ref(cloneDeep(defaultFormData));
onMounted(async () => { /**
const tree = await categoryTree(); * 是否已经加载全部数据 即 taskList.length === taskTotal
tableApi.formApi.updateSchema([ */
{ const isLoadComplete = computed(
fieldName: 'category', () => taskList.value.length === taskTotal.value,
componentProps: {
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
// {
// field: 'businessTitle',
// title: '业务标题',
// formatter: ({ cellValue }) => cellValue ?? '-',
// },
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
); );
// 卡片父容器的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 = [];
} }
return await pageByTaskCopy({ loading.value = true;
pageNum: page.currentPage, const resp = await pageByTaskCopy({
pageSize: page.pageSize, pageSize: 10,
...formValues, pageNum: page.value,
...formData.value,
}); });
}, taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
}, taskTotal.value = resp.total;
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-finish',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({ loading.value = false;
formOptions, // 默认选中第一个
gridOptions, if (taskList.value.length > 0) {
gridEvents: { const firstTask = taskList.value[0]!;
cellClick: ({ row }) => { currentTask.value = firstTask;
handleOpen(row); handleCardClick(firstTask);
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'readonly' }).open();
} }
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await 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> </script>
<template> <template>
<Page :auto-content-height="true"> <Page :auto-content-height="true">
<BasicTable table-title="我的抄送" /> <div class="flex h-full gap-2">
<ApprovalPanelDrawer @reload="() => tableApi.query()" /> <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> </Page>
</template> </template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -1,183 +1,299 @@
<script setup lang="tsx"> <!-- eslint-disable no-use-before-define -->
import type { VbenFormProps } from '@vben/common-ui'; <script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import type { ComponentType } from '#/adapter/component'; import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { markRaw, onMounted } from 'vue'; import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
import { Page, useVbenDrawer } from '@vben/common-ui'; import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import { DictEnum } from '@vben/constants'; import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { isArray } from 'lodash-es';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { categoryTree } from '#/api/workflow/category'; import { categoryTree } from '#/api/workflow/category';
import { pageByTaskFinish } from '#/api/workflow/task'; import { pageByTaskFinish } from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { ApprovalPanelDrawerComp, CopyComponent } from '../components'; import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { DefaultSlot } from '../components/helper'; import { bottomOffset } from './constant';
const formOptions: VbenFormProps<ComponentType> = { const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
commonConfig: {
labelWidth: 80, const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
componentProps: { const taskTotal = ref(0);
allowClear: true, const page = ref(1);
}, const loading = ref(false);
},
schema: [ const defaultFormData = {
{ flowName: '', // 流程定义名称
fieldName: 'category', nodeName: '', // 任务名称
component: 'TreeSelect', flowCode: '', // 流程定义编码
label: '流程分类', createByIds: [] as string[], // 创建人
}, category: null as null | number, // 流程分类
{
fieldName: 'flowCode',
component: 'Input',
label: '流程编码',
},
{
fieldName: 'createByIds',
defaultValue: [],
component: markRaw(DefaultSlot),
label: '申请人',
renderComponentContent: (model) => ({
default: () => (
<CopyComponent avatarSize={30} v-model:userList={model.createByIds} />
),
}),
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
}; };
const formData = ref(cloneDeep(defaultFormData));
onMounted(async () => { /**
const tree = await categoryTree(); * 是否已经加载全部数据 即 taskList.length === taskTotal
tableApi.formApi.updateSchema([ */
{ const isLoadComplete = computed(
fieldName: 'category', () => taskList.value.length === taskTotal.value,
componentProps: {
fieldNames: { label: 'label', value: 'id' },
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
// 选中后显示在输入框的值
treeNodeLabelProp: 'label',
},
},
]);
});
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
},
columns: [
{
field: 'id',
title: 'ID',
},
// {
// field: 'businessTitle',
// title: '业务标题',
// formatter: ({ cellValue }) => cellValue ?? '-',
// },
{
field: 'flowName',
title: '流程名称',
},
{
field: 'categoryName',
title: '流程分类',
},
{
field: 'flowCode',
title: '流程编码',
},
{
field: 'nodeName',
title: '当前任务',
},
{
field: 'flowStatus',
title: '流程状态',
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'createTime',
title: '创建时间',
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 转换数据
if (isArray(formValues.createByIds)) {
formValues.createByIds = (formValues.createByIds as Array<any>).map(
(item) => item.userId,
); );
// 卡片父容器的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 = [];
} }
return await pageByTaskFinish({ loading.value = true;
pageNum: page.currentPage, const resp = await pageByTaskFinish({
pageSize: page.pageSize, pageSize: 10,
...formValues, pageNum: page.value,
...formData.value,
}); });
}, taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
}, taskTotal.value = resp.total;
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
id: 'workflow-task-finish',
rowClassName: 'cursor-pointer',
};
const [BasicTable, tableApi] = useVbenVxeGrid({ loading.value = false;
formOptions, // 默认选中第一个
gridOptions, if (taskList.value.length > 0) {
gridEvents: { const firstTask = taskList.value[0]!;
cellClick: ({ row }) => { currentTask.value = firstTask;
handleOpen(row); handleCardClick(firstTask);
},
},
});
const [ApprovalPanelDrawer, drawerApi] = useVbenDrawer({
connectedComponent: ApprovalPanelDrawerComp,
});
function handleOpen(row: any) {
drawerApi.setData({ task: row, type: 'readonly' }).open();
} }
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await 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> </script>
<template> <template>
<Page :auto-content-height="true"> <Page :auto-content-height="true">
<BasicTable table-title="已办理任务" /> <div class="flex h-full gap-2">
<ApprovalPanelDrawer @reload="() => tableApi.query()" /> <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> </Page>
</template> </template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

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

View File

@@ -27,8 +27,8 @@
"scripts": { "scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build", "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build",
"build:analyze": "turbo build:analyze", "build:analyze": "turbo build:analyze",
"build:antd": "pnpm run build --filter=@vben/web-antd", "build:antd": "pnpm run build --filter=@vben/web-antd build:prod",
"build:antd:test": "pnpm run build --filter=@vben/web-antd -- --mode test", "build:antd:test": "pnpm run build --filter=@vben/web-antd build:test",
"build:docker": "./scripts/deploy/build-local-docker-image.sh", "build:docker": "./scripts/deploy/build-local-docker-image.sh",
"build:docs": "pnpm run build --filter=@vben/docs", "build:docs": "pnpm run build --filter=@vben/docs",
"build:play": "pnpm run build --filter=@vben/playground", "build:play": "pnpm run build --filter=@vben/playground",

View File

@@ -390,10 +390,10 @@ $namespace: vben;
var(--menu-item-margin-x); var(--menu-item-margin-x);
font-size: var(--menu-font-size); font-size: var(--menu-font-size);
color: var(--menu-item-color); color: var(--menu-item-color);
text-decoration: none;
white-space: nowrap; white-space: nowrap;
list-style: none; text-decoration: none;
cursor: pointer; cursor: pointer;
list-style: none;
background: var(--menu-item-background-color); background: var(--menu-item-background-color);
border: none; border: none;
border-radius: var(--menu-item-radius); border-radius: var(--menu-item-radius);
@@ -495,7 +495,7 @@ $namespace: vben;
&.is-rounded { &.is-rounded {
--menu-item-margin-x: 8px; --menu-item-margin-x: 8px;
--menu-item-collapse-margin-x: 6px; --menu-item-collapse-margin-x: 6px;
--menu-item-radius: 8px; --menu-item-radius: 6px;
} }
&.is-horizontal:not(.is-rounded) { &.is-horizontal:not(.is-rounded) {
@@ -717,8 +717,8 @@ $namespace: vben;
width: var(--menu-item-icon-size); width: var(--menu-item-icon-size);
height: var(--menu-item-icon-size); height: var(--menu-item-icon-size);
margin-right: 8px; margin-right: 8px;
text-align: center;
vertical-align: middle; vertical-align: middle;
text-align: center;
} }
} }

View File

@@ -12,15 +12,13 @@ function isAccessible(
el: Element, el: Element,
binding: DirectiveBinding<string | string[]>, binding: DirectiveBinding<string | string[]>,
) { ) {
const { accessMode, hasAccessByCodes, hasAccessByRoles } = useAccess(); const { hasAccessByCodes, hasAccessByRoles } = useAccess();
const value = binding.value; const value = binding.value;
if (!value) return; if (!value) return;
const authMethod = const authMethod =
accessMode.value === 'frontend' && binding.arg === 'role' binding.arg === 'role' ? hasAccessByRoles : hasAccessByCodes;
? hasAccessByRoles
: hasAccessByCodes;
const values = Array.isArray(value) ? value : [value]; const values = Array.isArray(value) ? value : [value];

View File

@@ -17,6 +17,10 @@ function useAccess() {
*/ */
function hasAccessByRoles(roles: string[]) { function hasAccessByRoles(roles: string[]) {
const userRoleSet = new Set(userStore.userRoles); const userRoleSet = new Set(userStore.userRoles);
// 超管的角色
if (userRoleSet.has('superadmin')) {
return true;
}
const intersection = roles.filter((item) => userRoleSet.has(item)); const intersection = roles.filter((item) => userRoleSet.has(item));
return intersection.length > 0; return intersection.length > 0;
} }

View File

@@ -141,3 +141,8 @@ TODO: 最后一条数据hover/check仍会显示边框
border-radius: var(--vxe-ui-table-border-radius) border-radius: var(--vxe-ui-table-border-radius)
var(--vxe-ui-table-border-radius) 0 0; var(--vxe-ui-table-border-radius) 0 0;
} }
/* modal/drawer里使用列配置 重置列弹窗被遮挡 */
.vxe-dynamics--modal > .vxe-modal--wrapper {
z-index: calc(var(--popup-z-index) + 1) !important;
}

View File

@@ -23,6 +23,16 @@
".vitepress/dist/**" ".vitepress/dist/**"
] ]
}, },
"@vben/web-antd#build:prod": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"@vben/web-antd#build:test": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"preview": { "preview": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": ["dist/**"] "outputs": ["dist/**"]