feat: [bpm][ele] 用户选择弹窗,部门选择弹窗组件迁移

pull/270/head
jason 2025-11-21 22:12:24 +08:00
parent ff8187bcb0
commit dee1764556
5 changed files with 715 additions and 45 deletions

View File

@ -9,6 +9,7 @@ import type { SystemUserApi } from '#/api/system/user';
import { ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
@ -26,8 +27,9 @@ import {
ElTooltip,
} from 'element-plus';
// import { DeptSelectModal, UserSelectModal } from '#/components/select-modal';
import { ImageUpload } from '#/components/upload';
import { DeptSelectModal } from '#/views/system/dept/components';
import { UserSelectModal } from '#/views/system/user/components';
const props = defineProps({
categoryList: {
@ -44,15 +46,15 @@ const props = defineProps({
},
});
// const [UserSelectModalComp, userSelectModalApi] = useVbenModal({
// connectedComponent: UserSelectModal,
// destroyOnClose: true,
// });
const [UserSelectModalComp, userSelectModalApi] = useVbenModal({
connectedComponent: UserSelectModal,
destroyOnClose: true,
});
// const [DeptSelectModalComp, deptSelectModalApi] = useVbenModal({
// connectedComponent: DeptSelectModal,
// destroyOnClose: true,
// });
const [DeptSelectModalComp, deptSelectModalApi] = useVbenModal({
connectedComponent: DeptSelectModal,
destroyOnClose: true,
});
const formRef = ref(); //
const modelData = defineModel<any>(); //
@ -125,21 +127,22 @@ function openStartUserSelect() {
selectedUsers.value = selectedStartUsers.value.map(
(user) => user.id,
) as number[];
// userSelectModalApi.setData({ userIds: selectedUsers.value }).open();
userSelectModalApi.setData({ userIds: selectedUsers.value }).open();
}
/** 打开部门选择 */
function openStartDeptSelect() {
// deptSelectModalApi.setData({ selectedList: selectedStartDepts.value }).open();
deptSelectModalApi.setData({ selectedList: selectedStartDepts.value }).open();
}
// /** */
// function handleDeptSelectConfirm(depts: SystemDeptApi.Dept[]) {
// modelData.value = {
// ...modelData.value,
// startDeptIds: depts.map((d) => d.id),
// };
// }
/** 处理部门选择确认 */
function handleDeptSelectConfirm(depts: SystemDeptApi.Dept[]) {
selectedStartDepts.value = depts;
modelData.value = {
...modelData.value,
startDeptIds: depts.map((d) => d.id),
};
}
/** 打开管理员选择 */
function openManagerUserSelect() {
@ -147,32 +150,32 @@ function openManagerUserSelect() {
selectedUsers.value = selectedManagerUsers.value.map(
(user) => user.id,
) as number[];
// userSelectModalApi.setData({ userIds: selectedUsers.value }).open();
userSelectModalApi.setData({ userIds: selectedUsers.value }).open();
}
// /** */
// function handleUserSelectConfirm(userList: SystemUserApi.User[]) {
// modelData.value =
// currentSelectType.value === 'start'
// ? {
// ...modelData.value,
// startUserIds: userList.map((u) => u.id),
// }
// : {
// ...modelData.value,
// managerUserIds: userList.map((u) => u.id),
// };
// }
/** 处理用户选择确认 */
function handleUserSelectConfirm(userList: SystemUserApi.User[]) {
modelData.value =
currentSelectType.value === 'start'
? {
...modelData.value,
startUserIds: userList.map((u) => u.id),
}
: {
...modelData.value,
managerUserIds: userList.map((u) => u.id),
};
}
// /** */
// function handleUserSelectClosed() {
// selectedUsers.value = [];
// }
/** 用户选择弹窗关闭 */
function handleUserSelectClosed() {
selectedUsers.value = [];
}
// /** */
// function handleUserSelectCancel() {
// selectedUsers.value = [];
// }
/** 用户选择弹窗取消 */
function handleUserSelectCancel() {
selectedUsers.value = [];
}
/** 处理发起人类型变化 */
function handleStartUserTypeChange(value: number) {
@ -285,7 +288,12 @@ defineExpose({ validate });
</ElSelect>
</ElFormItem>
<ElFormItem label="流程图标">
<ImageUpload v-model:value="modelData.icon" />
<ImageUpload
v-model:value="modelData.icon"
:show-description="false"
:width="120"
:height="120"
/>
</ElFormItem>
<ElFormItem label="流程描述" prop="description">
<ElInput v-model="modelData.description" type="textarea" clearable />
@ -424,19 +432,19 @@ defineExpose({ validate });
</ElForm>
<!-- 用户选择弹窗 -->
<!-- <UserSelectModalComp
<UserSelectModalComp
class="w-3/5"
v-model:value="selectedUsers"
:multiple="true"
@confirm="handleUserSelectConfirm"
@closed="handleUserSelectClosed"
@cancel="handleUserSelectCancel"
/> -->
/>
<!-- 部门选择对话框 -->
<!-- <DeptSelectModalComp
<DeptSelectModalComp
class="w-3/5"
:check-strictly="true"
@confirm="handleDeptSelectConfirm"
/> -->
/>
</div>
</template>

View File

@ -0,0 +1,141 @@
// TODO @
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import { nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { ElCard, ElCol, ElRow, ElTree } from 'element-plus';
import { getSimpleDeptList } from '#/api/system/dept';
defineOptions({ name: 'DeptSelectModal' });
const props = withDefaults(
defineProps<{
//
cancelText?: string;
// checkable
checkStrictly?: boolean;
//
confirmText?: string;
//
multiple?: boolean;
//
title?: string;
}>(),
{
cancelText: '取消',
checkStrictly: false,
confirmText: '确认',
multiple: true,
title: '部门选择',
},
);
const emit = defineEmits<{
confirm: [deptList: SystemDeptApi.Dept[]];
}>();
//
const deptTree = ref<any[]>([]);
// ID
const selectedDeptIds = ref<number[]>([]);
//
const deptData = ref<SystemDeptApi.Dept[]>([]);
// Tree
const treeRef = ref();
//
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
// ID
const selectedIds: number[] = props.checkStrictly
? treeRef.value?.getCheckedKeys() || []
: selectedDeptIds.value;
const deptArray = deptData.value.filter((dept) =>
selectedIds.includes(dept.id!),
);
emit('confirm', deptArray);
//
await modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
deptTree.value = [];
selectedDeptIds.value = [];
return;
}
//
const data = modalApi.getData();
if (!data) {
return;
}
modalApi.lock();
try {
deptData.value = await getSimpleDeptList();
deptTree.value = handleTree(deptData.value);
// DOM
await nextTick();
//
if (data.selectedList?.length) {
const selectedIds = data.selectedList
.map((dept: SystemDeptApi.Dept) => dept.id)
.filter((id: number) => id !== undefined);
selectedDeptIds.value = selectedIds;
treeRef.value.setCheckedKeys(selectedIds);
}
} finally {
modalApi.unlock();
}
},
destroyOnClose: true,
});
/** 处理选中状态变化 */
function handleCheck(
_data: any,
{ checkedKeys }: { checkedKeys: (number | string)[] },
) {
// checkedKeys number
const keys = checkedKeys.map((key) =>
typeof key === 'string' ? Number(key) : key,
);
if (props.multiple) {
selectedDeptIds.value = keys;
} else {
//
const lastSelectedId = keys[keys.length - 1];
if (lastSelectedId) {
selectedDeptIds.value = [lastSelectedId];
treeRef.value?.setCheckedKeys([lastSelectedId]);
}
}
}
</script>
<template>
<Modal :title="title" key="dept-select-modal" class="w-3/5">
<ElRow class="h-full">
<ElCol :span="24">
<ElCard class="h-full">
<ElTree
v-if="deptTree.length > 0"
ref="treeRef"
:data="deptTree"
:props="{ label: 'name', children: 'children' }"
:check-strictly="checkStrictly"
:default-expand-all="true"
show-checkbox
check-on-click-node
node-key="id"
@check="handleCheck"
/>
</ElCard>
</ElCol>
</ElRow>
</Modal>
</template>

View File

@ -0,0 +1,2 @@
// TODO @xingyu【待讨论】是不是把 user select 放到 user 目录的 components 下dept select 放到 dept 目录的 components 下
export { default as DeptSelectModal } from './dept-select-modal.vue';

View File

@ -0,0 +1 @@
export { default as UserSelectModal } from './user-select-modal.vue';

View File

@ -0,0 +1,518 @@
<script lang="ts" setup>
// TODO @
// TODO @xingyu systeminfra components
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
ElButton,
ElCol,
ElInput,
ElMessage,
ElPagination,
ElRow,
ElTransfer,
ElTree,
} from 'element-plus';
import { getSimpleDeptList } from '#/api/system/dept';
import { getUserPage } from '#/api/system/user';
//
interface DeptTreeNode {
id: string;
label: string;
children?: DeptTreeNode[];
name: string;
}
defineOptions({ name: 'UserSelectModal' });
withDefaults(
defineProps<{
cancelText?: string;
confirmText?: string;
multiple?: boolean;
title?: string;
value?: number[];
}>(),
{
title: '选择用户',
multiple: true,
value: () => [],
confirmText: '确定',
cancelText: '取消',
},
);
const emit = defineEmits<{
cancel: [];
closed: [];
confirm: [value: SystemUserApi.User[]];
'update:value': [value: number[]];
}>();
//
const deptTree = ref<any[]>([]);
const deptList = ref<SystemDeptApi.Dept[]>([]);
const expandedKeys = ref<string[]>([]);
const selectedDeptId = ref<number>();
const deptSearchKeys = ref('');
//
const userList = ref<SystemUserApi.User[]>([]); //
const selectedUserIds = ref<number[]>([]);
//
const [Modal, modalApi] = useVbenModal({
onCancel: handleCancel,
onClosed: handleClosed,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetData();
return;
}
//
const data = modalApi.getData();
if (!data) {
return;
}
modalApi.lock();
try {
//
const deptData = await getSimpleDeptList();
deptList.value = deptData;
const treeData = handleTree(deptData);
deptTree.value = treeData.map((node) => processDeptNode(node));
expandedKeys.value = deptTree.value.map((node) => node.id);
//
await loadUserData(1, leftListState.value.pagination.pageSize);
//
if (data.userIds?.length) {
selectedUserIds.value = data.userIds;
// TODO ID
const { list } = await getUserPage({
pageNo: 1,
pageSize: 100, // 使
userIds: data.userIds,
});
// 使 Map ID key
const userMap = new Map(userList.value.map((user) => [user.id, user]));
list.forEach((user) => {
if (!userMap.has(user.id)) {
userMap.set(user.id, user);
}
});
userList.value = [...userMap.values()];
updateRightListData();
}
modalApi.open();
} finally {
modalApi.unlock();
}
},
destroyOnClose: true,
});
//
const leftListState = ref({
searchValue: '',
dataSource: [] as SystemUserApi.User[],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
});
//
const rightListState = ref({
searchValue: '',
dataSource: [] as SystemUserApi.User[],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
});
// Transfer
const transferDataSource = computed(() => {
// 使 Map
const userMap = new Map<number, any>();
//
for (const user of leftListState.value.dataSource) {
if (user.id) {
userMap.set(user.id, user);
}
}
//
for (const user of rightListState.value.dataSource) {
if (user.id && !userMap.has(user.id)) {
userMap.set(user.id, user);
}
}
// Transfer
return [...userMap.values()].map((user) => ({
key: user.id!,
label: `${user.nickname} (${user.username})`,
disabled: false,
}));
});
//
const filteredDeptTree = computed(() => {
if (!deptSearchKeys.value) return deptTree.value;
const filterNode = (node: any, depth = 0): any => {
//
if (depth > 100) return null;
//
const name = node?.name?.toLowerCase();
const search = deptSearchKeys.value.toLowerCase();
//
if (name?.includes(search)) {
return {
...node,
children: node.children,
};
}
//
if (node.children) {
const filteredChildren = node.children
.map((child: any) => filterNode(child, depth + 1))
.filter(Boolean);
if (filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
};
}
}
return null;
};
return deptTree.value.map((node: any) => filterNode(node)).filter(Boolean);
});
//
async function loadUserData(pageNo: number, pageSize: number) {
try {
const { list, total } = await getUserPage({
pageNo,
pageSize,
deptId: selectedDeptId.value,
username: leftListState.value.searchValue || undefined,
});
leftListState.value.dataSource = list;
leftListState.value.pagination.total = total;
leftListState.value.pagination.current = pageNo;
leftListState.value.pagination.pageSize = pageSize;
//
const newUsers = list.filter(
(user) => !userList.value.some((u) => u.id === user.id),
);
if (newUsers.length > 0) {
userList.value.push(...newUsers);
}
} finally {
//
}
}
//
function updateRightListData() {
// 使 Set ID
const uniqueSelectedIds = new Set(selectedUserIds.value);
//
const selectedUsers = userList.value.filter((user) =>
uniqueSelectedIds.has(user.id!),
);
//
const filteredUsers = rightListState.value.searchValue
? selectedUsers.filter((user) =>
user.nickname
.toLowerCase()
.includes(rightListState.value.searchValue.toLowerCase()),
)
: selectedUsers;
// 使 Set
rightListState.value.pagination.total = new Set(
filteredUsers.map((user) => user.id),
).size;
//
const { current, pageSize } = rightListState.value.pagination;
const startIndex = (current - 1) * pageSize;
const endIndex = startIndex + pageSize;
rightListState.value.dataSource = filteredUsers.slice(startIndex, endIndex);
}
//
async function handleLeftPaginationChange(page: number) {
await loadUserData(page, leftListState.value.pagination.pageSize);
}
//
function handleRightPaginationChange(page: number) {
rightListState.value.pagination.current = page;
updateRightListData();
}
//
function handleUserChange(
value: (number | string)[],
_direction: string,
_movedKeys: (number | string)[],
) {
// 使 Set ID number
selectedUserIds.value = [...new Set(value.map(Number))];
emit('update:value', selectedUserIds.value);
updateRightListData();
}
//
function resetData() {
userList.value = [];
selectedUserIds.value = [];
//
selectedDeptId.value = undefined;
//
selectedUserIds.value = [];
leftListState.value = {
searchValue: '',
dataSource: [],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
};
rightListState.value = {
searchValue: '',
dataSource: [],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
};
}
//
function handleDeptSearch(value: string) {
deptSearchKeys.value = value;
//
if (value) {
const getAllKeys = (nodes: any[]): string[] => {
const keys: string[] = [];
for (const node of nodes) {
keys.push(node.id);
if (node.children) {
keys.push(...getAllKeys(node.children));
}
}
return keys;
};
expandedKeys.value = getAllKeys(deptTree.value);
} else {
//
expandedKeys.value = deptTree.value.map((node) => node.id);
}
}
//
async function handleDeptSelect(node: any) {
// ID
const newDeptId = node.id ? Number(node.id) : undefined;
selectedDeptId.value =
newDeptId === selectedDeptId.value ? undefined : newDeptId;
//
const { pageSize } = leftListState.value.pagination;
leftListState.value.pagination.current = 1;
await loadUserData(1, pageSize);
}
//
function handleConfirm() {
if (selectedUserIds.value.length === 0) {
ElMessage.warning('请选择用户');
return;
}
emit(
'confirm',
userList.value.filter((user) => selectedUserIds.value.includes(user.id!)),
);
modalApi.close();
}
//
function handleCancel() {
emit('cancel');
modalApi.close();
//
setTimeout(() => {
resetData();
}, 300);
}
//
function handleClosed() {
emit('closed');
resetData();
}
//
function processDeptNode(node: any): DeptTreeNode {
return {
id: String(node.id),
label: `${node.name} (${node.id})`,
name: node.name,
children: node.children?.map((child: any) => processDeptNode(child)),
};
}
</script>
<template>
<Modal key="user-select-modal" class="w-3/5" :title="title">
<ElRow :gutter="16">
<ElCol :span="6">
<div class="h-[500px] overflow-auto rounded border">
<div class="border-b p-2">
<ElInput
v-model="deptSearchKeys"
placeholder="搜索部门"
clearable
@input="handleDeptSearch"
/>
</div>
<ElTree
:data="filteredDeptTree"
:expand-on-click-node="false"
:default-expanded-keys="expandedKeys"
:current-node-key="
selectedDeptId ? String(selectedDeptId) : undefined
"
node-key="id"
highlight-current
@node-click="handleDeptSelect"
>
<template #default="{ node }">
<span>{{ node.label }}</span>
</template>
</ElTree>
</div>
</ElCol>
<ElCol :span="18">
<ElTransfer
v-model="selectedUserIds"
:data="transferDataSource"
:titles="['未选', '已选']"
filterable
filter-placeholder="搜索用户"
@change="handleUserChange"
>
<template #default="{ option }">
<span>{{ option.label }}</span>
</template>
</ElTransfer>
<div class="mt-2 flex justify-between">
<ElPagination
v-model:current-page="leftListState.pagination.current"
v-model:page-size="leftListState.pagination.pageSize"
:total="leftListState.pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@current-change="handleLeftPaginationChange"
/>
<ElPagination
v-model:current-page="rightListState.pagination.current"
v-model:page-size="rightListState.pagination.pageSize"
:total="rightListState.pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@current-change="handleRightPaginationChange"
/>
</div>
</ElCol>
</ElRow>
<template #footer>
<ElButton @click="handleCancel">{{ cancelText }}</ElButton>
<ElButton
type="primary"
:disabled="selectedUserIds.length === 0"
@click="handleConfirm"
>
{{ confirmText }}
</ElButton>
</template>
</Modal>
</template>
<style lang="scss" scoped>
:deep(.el-transfer) {
display: flex;
align-items: center;
justify-content: space-between;
height: 450px;
}
:deep(.el-transfer-panel) {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
}
:deep(.el-transfer-panel__header) {
flex-shrink: 0;
}
:deep(.el-transfer-panel__filter) {
flex-shrink: 0;
padding: 8px;
}
:deep(.el-transfer-panel__body) {
flex: 1;
overflow: auto;
}
:deep(.el-transfer-panel__list) {
height: auto !important;
}
:deep(.el-transfer__buttons) {
padding: 0 8px;
}
</style>