Commit fee53bb2 by lijiabin

【需求 25171】feat: 新增部门/人员选择组件,替代企微选人 API

- DepartmentUserModal:自封装选部门/选人弹窗,懒加载组织架构树 + 成员,
  支持部门/人员/两者、单选/多选、userId|account|jobNumId 三种取值字段、
  搜索下拉(超过 100 条截断展示 + 查看全部)。
- DeptUserPicker:表单场景的轻量封装,v-model 直接绑 DepartmentUserSelection[],
  触发区交给调用方自定义,用于逐步替换页面里的 selectDepOrUser(企微选人)调用。
- src/api/department 改名为 src/api/deptUser,接口不只是部门,也包含查成员/搜人。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
parent 8b8ed832
import service from '@/utils/request/index'
import type {
DepartmentDto,
DepartmentListParams,
DepartmentUserDto,
DepartmentUserListParams,
} from './types'
/** 获取扁平部门列表,由组件根据 pid 构建部门树。 */
export const getAllDept = (data: DepartmentListParams = {}) => {
return service.request<DepartmentDto[]>({
url: '/api/dept/getAllDept',
method: 'POST',
data,
})
}
/** 获取指定部门成员;传 keyword 时用于全局搜索人员。 */
export const getDeptUsers = (data: DepartmentUserListParams) => {
return service.request<DepartmentUserDto[]>({
url: '/api/dept/getDeptUsers',
method: 'POST',
data,
})
}
/** 部门树节点 */
export interface DepartmentDto {
deptId: string | number
pid: string | number
pids?: string
simpleName: string
fullName: string
hasUserFlag: number
children?: DepartmentDto[]
}
/** 部门成员 */
export interface DepartmentUserDto {
userId: string | number
name: string
account?: string
jobNumId?: string
avatar?: string
deptId: string | number
}
/** 获取部门树的查询参数 */
export interface DepartmentListParams {
deptIdList?: Array<string | number>
simpleName?: string
}
/** 获取部门成员或搜索人员的查询参数 */
export interface DepartmentUserListParams {
deptId?: string | number
keyword?: string
}
......@@ -17,6 +17,7 @@ export * from './dailyLottery'
export * from './launchCampaign'
export * from './selfMessage'
export * from './luckyWheel'
export * from './deptUser'
// 导出类型
export * from './task/types'
export * from './shop/types'
......@@ -36,3 +37,4 @@ export * from './dailyLottery/types'
export * from './launchCampaign/types'
export * from './selfMessage/types'
export * from './luckyWheel/types'
export * from './deptUser/types'
<template>
<el-dialog
v-model="visible"
title="选择部门/人员"
width="880px"
append-to-body
destroy-on-close
align-center
@closed="handleClosed"
>
<div
class="h-[560px] min-h-0 flex overflow-hidden rounded-lg border border-[var(--el-border-color)] shadow-sm"
>
<!-- 左侧直接展示部门和成员,成员不再依赖单独的部门详情列。 -->
<section
class="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden border-r border-[var(--el-border-color)] p-4"
>
<div class="mb-3 flex items-center justify-between">
<div class="text-[15px] font-600 text-[var(--el-text-color-primary)]">
组织架构
<span class="ml-1 text-xs text-[var(--el-text-color-secondary)] font-400">
{{ canSelectUser ? '· 选择部门或成员' : '· 仅可选择部门' }}
</span>
</div>
<el-button
text
size="small"
:icon="Refresh"
:loading="departmentLoading"
aria-label="刷新部门"
@click="loadDepartments"
/>
</div>
<!-- 搜索结果以下拉浮层展示,不占用/替换下方组织架构树,可以边搜边继续浏览部门。 -->
<div v-if="canSelectUser" ref="searchWrapperRef" class="relative mb-3">
<el-input
v-model="searchKeyword"
clearable
placeholder="搜索成员,输入即搜索"
:prefix-icon="Search"
@input="handleSearchInput"
@focus="handleSearchFocus"
@clear="clearSearch"
/>
<div
v-if="searchPanelVisible"
class="absolute left-0 right-0 top-[calc(100%+6px)] z-10 max-h-[360px] overflow-y-auto rounded-md border border-[var(--el-border-color)] bg-white p-1 shadow-[0_8px_24px_rgba(0,0,0,0.12)]"
>
<el-skeleton v-if="searchLoading" class="p-2" :rows="6" animated />
<template v-else-if="searchResults.length">
<div
v-for="user in visibleSearchResults"
:key="user.userId"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-2 transition-colors hover:bg-[var(--el-color-primary-light-9)]"
@click="toggleSearchUser(user)"
>
<el-avatar shape="square" :size="28" :src="user.avatar" class="flex-shrink-0 rounded">
{{ user.name.slice(0, 1) }}
</el-avatar>
<span class="min-w-0 flex-1 truncate text-sm">{{ user.name }}</span>
<span
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border transition-colors"
:class="
isUserSelected(user)
? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary)]'
: 'border-[var(--el-border-color)]'
"
>
<el-icon v-if="isUserSelected(user)" class="text-[10px] text-white">
<Check />
</el-icon>
</span>
</div>
<div
v-if="!showAllSearchResults && searchResults.length > SEARCH_PAGE_SIZE"
class="cursor-pointer px-2 py-2 text-center text-xs text-[var(--el-color-primary)] hover:underline"
@click="showAllSearchResults = true"
>
仅展示前 {{ SEARCH_PAGE_SIZE }} 个,点击查看全部 {{ searchResults.length }} 个成员
</div>
</template>
<el-empty v-else :image-size="56" description="未找到匹配的成员" />
</div>
</div>
<el-scrollbar class="min-h-0 flex-1 pr-1">
<el-skeleton v-if="departmentLoading" :rows="10" animated />
<el-tree
v-else-if="departmentTree.length"
:key="treeVersion"
ref="departmentTreeRef"
lazy
class="w-full text-sm [--el-tree-node-content-height:40px] [&_.el-checkbox.is-disabled]:!hidden [&_.el-checkbox__inner]:!border-[var(--el-text-color-secondary)] [&_.el-tree-node__content]:!box-border [&_.el-tree-node__content]:!h-[40px] [&_.el-tree-node__content]:!rounded-md [&_.el-tree-node__content]:!pr-2 [&_.el-tree-node__content]:!transition-colors [&_.el-tree-node__content:hover]:!bg-[var(--el-color-primary-light-9)] [&_.el-tree-node__expand-icon]:!text-[var(--el-text-color-primary)]"
node-key="key"
show-checkbox
check-strictly
:props="treeProps"
:load="loadTreeNode"
:default-expanded-keys="expandedNodeKeys"
:expand-on-click-node="false"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@check="handleNodeCheck"
>
<template #default="{ data }">
<div class="min-w-0 flex flex-1 items-center gap-2">
<template v-if="data.nodeType === 'department'">
<el-icon class="flex-shrink-0 text-base text-[var(--el-color-primary)]">
<FolderOpened v-if="expandedNodeKeys.includes(data.key)" />
<Folder v-else />
</el-icon>
<span class="truncate text-[var(--el-text-color-primary)]" :title="data.department.fullName">
{{ data.department.simpleName }}
</span>
</template>
<template v-else>
<el-avatar
shape="square"
:size="24"
:src="data.user.avatar"
class="flex-shrink-0 rounded"
>
{{ data.user.name.slice(0, 1) }}
</el-avatar>
<span class="min-w-0 flex-1 truncate text-[var(--el-text-color-primary)]">
{{ data.user.name }}
</span>
</template>
</div>
</template>
</el-tree>
<el-empty v-else :image-size="72" description="暂无部门数据" />
</el-scrollbar>
</section>
<!-- 右侧只负责展示已选项,保持和参考组件一致的两栏结构。 -->
<section
class="min-h-0 w-[300px] flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] p-4"
>
<div class="border-b border-[var(--el-border-color)] pb-3">
<div class="text-[15px] font-600 text-[var(--el-text-color-primary)]">
已选择{{ multiple ? '' : '(单选)' }}
</div>
<div class="mt-2 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]">
<div class="flex items-center gap-2">
<span>{{ selectedDepartmentCount }} 个部门</span>
<span>·</span>
<span>{{ selectedUserCount }} 个成员</span>
</div>
<div class="flex items-center gap-2">
<el-button
v-if="selectedDepartmentCount"
text
type="danger"
class="!h-auto !p-0 !text-xs"
@click="clearSelectionsByType(1)"
>
清除部门
</el-button>
<el-button
v-if="selectedUserCount"
text
type="danger"
class="!h-auto !p-0 !text-xs"
@click="clearSelectionsByType(2)"
>
清除成员
</el-button>
</div>
</div>
</div>
<el-scrollbar class="min-h-0 flex-1 pt-1">
<div v-if="selectedItems.length" class="divide-y divide-[var(--el-border-color)]">
<div
v-for="item in selectedItems"
:key="selectionKey(item)"
class="group flex min-w-0 items-center gap-2 rounded px-1 py-2 -mx-1 transition-colors hover:bg-white"
>
<el-avatar
v-if="item.valueType === 2"
shape="square"
:size="24"
:src="item.avatar"
class="flex-shrink-0 rounded"
>
{{ item.valueName.slice(0, 1) }}
</el-avatar>
<el-icon v-else class="flex-shrink-0 text-base text-[var(--el-color-primary)]">
<FolderOpened />
</el-icon>
<span class="min-w-0 flex-1 truncate text-sm" :title="item.valueName">
{{ item.valueName }}
</span>
<el-button
text
class="!h-auto !p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
:icon="Close"
aria-label="移除选择"
@click="removeSelection(item)"
/>
</div>
</div>
<el-empty v-else :image-size="72" description="暂未选择" />
</el-scrollbar>
</section>
</div>
<template #footer>
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--el-text-color-secondary)]">
{{ selectedItems.length ? `共选择 ${selectedItems.length} 项` : '' }}
</span>
<div class="flex gap-2">
<el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="!selectedItems.length" @click="confirmSelection">
确定
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
/**
* 选部门/选人弹窗,命令式调用,不做表单场景的回显和触发按钮(那部分见 DeptUserPicker)。
*
* 用法:
* const modalRef = useTemplateRef<InstanceType<typeof DepartmentUserModal>>('modalRef')
* modalRef.value?.open({
* selectionMode: 'both', // 'department' | 'user' | 'both',默认 both
* multiple: true, // 是否多选,默认 true;单选时选中新项会替换掉之前的选择
* userValueField: 'userId', // 人员取值字段:userId | account | jobNumId,默认 userId
* selected: prevSelection, // 回显用,已选项数组
* })
* <DepartmentUserModal ref="modalRef" @confirm="(items) => {...}" @cancel="() => {...}" />
*/
import {
Check,
Close,
Folder,
FolderOpened,
Refresh,
Search,
} from '@element-plus/icons-vue'
import { onClickOutside } from '@vueuse/core'
import { push } from 'notivue'
import { getAllDept, getDeptUsers } from '@/api'
import type { DepartmentDto, DepartmentUserDto } from '@/api/deptUser/types'
import type {
DepartmentUserModalExpose,
DepartmentUserModalOpenOptions,
DepartmentUserSelection,
SelectionMode,
UserValueField,
} from './types'
type DepartmentTreeNode = {
key: string
nodeType: 'department'
department: DepartmentDto
children: TreeNode[]
disabled: boolean
// 懒加载模式下 el-tree 靠这个字段判断要不要显示展开箭头,不能只看 children.length
// (成员是展开时才异步拉取的,初始 children 可能是空的)。
isLeaf: boolean
}
type UserTreeNode = {
key: string
nodeType: 'user'
user: DepartmentUserDto
disabled: boolean
isLeaf: true
}
type TreeNode = DepartmentTreeNode | UserTreeNode
const emit = defineEmits<{
confirm: [items: DepartmentUserSelection[]]
cancel: []
}>()
// 搜索结果一次性拿全量,但一屏渲染太多头像会卡,先只展示前 100 个,需要时再展开全部。
const SEARCH_PAGE_SIZE = 100
const visible = ref(false)
const departmentLoading = ref(false)
const searchLoading = ref(false)
const departmentTree = ref<DepartmentTreeNode[]>([])
const searchResults = ref<DepartmentUserDto[]>([])
const showAllSearchResults = ref(false)
const searchPanelVisible = ref(false)
const selectedItems = ref<DepartmentUserSelection[]>([])
const expandedNodeKeys = ref<string[]>([])
const searchKeyword = ref('')
const selectionMode = ref<SelectionMode>('both')
const multiple = ref(true)
const userValueField = ref<UserValueField>('userId')
const departmentTreeRef = useTemplateRef('departmentTreeRef')
const searchWrapperRef = useTemplateRef<HTMLDivElement>('searchWrapperRef')
const treeProps = { children: 'children', label: 'label', disabled: 'disabled', isLeaf: 'isLeaf' }
// el-tree 懒加载模式下每次重新挂载才会清空已加载的节点状态;
// 点“刷新部门”时靠改这个 key 强制重建整棵树,避免懒加载的旧状态残留。
const treeVersion = ref(0)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let syncingCheckedState = false
let suppressCancel = false
onClickOutside(searchWrapperRef, () => {
searchPanelVisible.value = false
})
const canSelectDepartment = computed(() => ['department', 'both'].includes(selectionMode.value))
const canSelectUser = computed(() => ['user', 'both'].includes(selectionMode.value))
const visibleSearchResults = computed(() =>
showAllSearchResults.value ? searchResults.value : searchResults.value.slice(0, SEARCH_PAGE_SIZE),
)
const selectedDepartmentCount = computed(
() => selectedItems.value.filter((item) => item.valueType === 1).length,
)
const selectedUserCount = computed(
() => selectedItems.value.filter((item) => item.valueType === 2).length,
)
const selectionKey = (item: Pick<DepartmentUserSelection, 'valueType' | 'valueId'>) =>
`${item.valueType}-${String(item.valueId)}`
const getDepartmentNodeKey = (deptId: string | number) => `department-${String(deptId)}`
const getUserValue = (user: DepartmentUserDto) => {
if (userValueField.value === 'account') return user.account || ''
if (userValueField.value === 'jobNumId') return user.jobNumId || ''
return user.userId
}
const getUserNodeKey = (user: DepartmentUserDto) => `user-${String(getUserValue(user))}`
const normalizeSelection = (
item: DepartmentUserSelection,
field = userValueField.value,
): DepartmentUserSelection => {
const valueId =
item.valueType === 2
? field === 'userId'
? item.userId || item.valueId
: field === 'account'
? item.account || item.valueId
: item.jobNumId || item.valueId
: item.valueId
return {
...item,
valueId: String(valueId),
valueField: item.valueType === 2 ? field : undefined,
deptId: item.deptId ? String(item.deptId) : undefined,
userId: item.userId ? String(item.userId) : undefined,
account: item.account ? String(item.account) : undefined,
jobNumId: item.jobNumId ? String(item.jobNumId) : undefined,
}
}
// 选择项统一按类型和值字段去重,保证回显和重复展开部门时结果一致;
// 单选模式下选中新项会直接替换掉之前的选择,不区分部门/人员。
const addSelection = (item: DepartmentUserSelection) => {
if (!item.valueId) return
if (selectedItems.value.some((selected) => selectionKey(selected) === selectionKey(item))) return
if (!multiple.value) selectedItems.value = []
selectedItems.value.push(normalizeSelection(item))
}
const setCheckedState = async () => {
await nextTick()
syncingCheckedState = true
departmentTreeRef.value?.setCheckedKeys(
selectedItems.value.map((item) =>
item.valueType === 1 ? getDepartmentNodeKey(item.valueId) : `user-${item.valueId}`,
),
)
syncingCheckedState = false
}
const removeSelection = (item: DepartmentUserSelection) => {
selectedItems.value = selectedItems.value.filter(
(selected) => selectionKey(selected) !== selectionKey(item),
)
void setCheckedState()
}
const clearSelectionsByType = (valueType: 1 | 2) => {
selectedItems.value = selectedItems.value.filter((item) => item.valueType !== valueType)
void setCheckedState()
}
const buildDepartmentTree = (list: DepartmentDto[]): DepartmentTreeNode[] => {
const nodeMap = new Map<string, DepartmentTreeNode>()
const roots: DepartmentTreeNode[] = []
list.forEach((department) => {
nodeMap.set(String(department.deptId), {
key: getDepartmentNodeKey(department.deptId),
nodeType: 'department',
department,
children: [],
disabled: !canSelectDepartment.value,
isLeaf: false,
})
})
nodeMap.forEach((node) => {
const parent = nodeMap.get(String(node.department.pid))
if (parent && node.key !== parent.key) parent.children.push(node)
else roots.push(node)
})
// 既没有下级部门、也没有成员(或当前模式不选人)的部门才是真正的叶子节点。
nodeMap.forEach((node) => {
node.isLeaf = !node.children.length && (!canSelectUser.value || !node.department.hasUserFlag)
})
return roots
}
const createUserNodes = (users: DepartmentUserDto[]): UserTreeNode[] =>
users
.map((user) => ({
key: getUserNodeKey(user),
nodeType: 'user' as const,
user,
disabled: !canSelectUser.value,
isLeaf: true as const,
}))
.filter((node) => node.key !== 'user-')
// el-tree 懒加载的 load 回调:部门下的子部门在打开弹窗时已经一次性拿到了,
// 这里只需要按需请求成员,再和已知的子部门合并交给 el-tree 自己管理,
// 不要再手动改 data.children —— 之前那么做会导致某些节点的展开箭头偶尔点了没反应。
// el-tree 懒加载的 Node 类型是内部私有类型,这里用 any 接一下,避免为了对齐它的类型声明引入脆弱的深层导入路径。
const loadTreeNode = async (node: any, resolve: (data: TreeNode[]) => void) => {
if (node.level === 0) {
resolve(departmentTree.value)
return
}
const data = node.data as TreeNode
const departmentChildren =
data.nodeType === 'department' ? data.children.filter((child) => child.nodeType === 'department') : []
if (data.nodeType !== 'department' || !canSelectUser.value || !data.department.hasUserFlag) {
resolve(departmentChildren)
return
}
try {
const { data: users } = await getDeptUsers({ deptId: data.department.deptId })
// 展开部门时优先展示直属成员,再展示下级部门,符合选人时的浏览顺序。
resolve([...createUserNodes(users || []), ...departmentChildren])
} catch (error) {
console.error('获取部门成员失败:', error)
push.error('获取部门成员失败,请稍后重试')
resolve(departmentChildren)
} finally {
await setCheckedState()
}
}
const loadDepartments = async () => {
departmentLoading.value = true
try {
const { data } = await getAllDept()
departmentTree.value = buildDepartmentTree(data || [])
expandedNodeKeys.value = departmentTree.value.slice(0, 1).map((node) => node.key)
// 强制重建 el-tree,避免上一次懒加载展开的节点状态串到新数据上。
treeVersion.value += 1
} catch (error) {
console.error('获取部门树失败:', error)
push.error('获取部门树失败,请稍后重试')
} finally {
departmentLoading.value = false
await setCheckedState()
}
}
const handleNodeExpand = (data: TreeNode) => {
if (!expandedNodeKeys.value.includes(data.key)) {
expandedNodeKeys.value.push(data.key)
}
}
const handleNodeCollapse = (data: TreeNode) => {
expandedNodeKeys.value = expandedNodeKeys.value.filter((key) => key !== data.key)
}
const createUserSelection = (user: DepartmentUserDto): DepartmentUserSelection | null => {
const valueId = getUserValue(user)
if (!valueId) return null
return {
valueType: 2,
valueId: String(valueId),
valueName: user.name,
valueField: userValueField.value,
userId: String(user.userId),
account: user.account ? String(user.account) : undefined,
jobNumId: user.jobNumId ? String(user.jobNumId) : undefined,
avatar: user.avatar,
deptId: String(user.deptId),
}
}
const createSelectionFromNode = (data: TreeNode): DepartmentUserSelection | null => {
if (data.nodeType === 'department') {
return {
valueType: 1,
valueId: String(data.department.deptId),
valueName: data.department.fullName || data.department.simpleName,
}
}
return createUserSelection(data.user)
}
const handleNodeCheck = (data: TreeNode, checkState: { checkedKeys: Array<string | number> }) => {
if (syncingCheckedState) return
const selection = createSelectionFromNode(data)
if (!selection) return
const checked = checkState.checkedKeys.some((key) => String(key) === data.key)
if (checked) addSelection(selection)
else {
selectedItems.value = selectedItems.value.filter(
(selected) => selectionKey(selected) !== selectionKey(selection),
)
}
// 单选模式下勾选新项会替换掉旧的选择,这里同步一下勾选框,把旧项的勾去掉。
void setCheckedState()
}
const isUserSelected = (user: DepartmentUserDto) => {
const valueId = getUserValue(user)
if (!valueId) return false
return selectedItems.value.some(
(item) => item.valueType === 2 && item.valueId === String(valueId),
)
}
// 搜索下拉里的成员是点一下切换选中状态,同时要同步左侧组织架构树的勾选框。
const toggleSearchUser = (user: DepartmentUserDto) => {
const selection = createUserSelection(user)
if (!selection) return
if (selectedItems.value.some((item) => selectionKey(item) === selectionKey(selection))) {
selectedItems.value = selectedItems.value.filter(
(item) => selectionKey(item) !== selectionKey(selection),
)
} else {
addSelection(selection)
}
void setCheckedState()
}
const searchUsers = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) return
searchLoading.value = true
try {
const { data } = await getDeptUsers({ keyword })
searchResults.value = data || []
} catch (error) {
console.error('搜索成员失败:', error)
push.error('搜索成员失败,请稍后重试')
} finally {
searchLoading.value = false
}
}
// 输入搜索增加短暂防抖,避免用户连续输入时频繁请求成员接口。
const handleSearchInput = () => {
if (searchTimer) clearTimeout(searchTimer)
if (!searchKeyword.value.trim()) {
searchPanelVisible.value = false
searchResults.value = []
return
}
searchPanelVisible.value = true
showAllSearchResults.value = false
searchLoading.value = true
searchTimer = setTimeout(() => {
void searchUsers()
}, 300)
}
// 重新聚焦搜索框时,如果已经有结果就直接把下拉浮层带回来,不用重新请求。
const handleSearchFocus = () => {
if (searchKeyword.value.trim() && searchResults.value.length) {
searchPanelVisible.value = true
}
}
const clearSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchKeyword.value = ''
searchPanelVisible.value = false
searchResults.value = []
showAllSearchResults.value = false
}
const resetState = () => {
if (searchTimer) clearTimeout(searchTimer)
departmentTree.value = []
searchResults.value = []
showAllSearchResults.value = false
searchPanelVisible.value = false
selectedItems.value = []
expandedNodeKeys.value = []
searchKeyword.value = ''
}
const open = (options: DepartmentUserModalOpenOptions = {}) => {
resetState()
selectionMode.value = options.selectionMode ?? 'both'
multiple.value = options.multiple ?? true
userValueField.value = options.userValueField ?? 'userId'
selectedItems.value = (options.selected ?? []).map((item) =>
normalizeSelection(item, options.userValueField ?? 'userId'),
)
suppressCancel = false
visible.value = true
void loadDepartments()
}
const close = () => {
visible.value = false
}
const confirmSelection = () => {
suppressCancel = true
emit(
'confirm',
selectedItems.value.map((item) => normalizeSelection(item)),
)
close()
}
const handleClosed = () => {
if (!suppressCancel) emit('cancel')
suppressCancel = false
}
defineExpose<DepartmentUserModalExpose>({ open, close })
</script>
export type SelectionMode = 'department' | 'user' | 'both'
export type UserValueField = 'userId' | 'account' | 'jobNumId'
/** 选人弹窗对外返回的统一选择项。 */
export interface DepartmentUserSelection {
valueType: 1 | 2
valueId: string
valueName: string
valueField?: UserValueField
avatar?: string
deptId?: string
userId?: string
account?: string
jobNumId?: string
}
/** 命令式打开弹窗时的配置,selected 用于表单或表格编辑回显。 */
export interface DepartmentUserModalOpenOptions {
selectionMode?: SelectionMode
userValueField?: UserValueField
selected?: DepartmentUserSelection[]
/** 是否允许多选,默认 true;单选时选中新项会自动替换掉之前的选择(不区分部门/人员)。 */
multiple?: boolean
}
export interface DepartmentUserModalExpose {
open: (options?: DepartmentUserModalOpenOptions) => void
close: () => void
}
<template>
<!-- 触发区完全交给调用方自己写(长度、placeholder 等都自己控制),这里只负责点击打开弹窗。 -->
<div class="inline-block" :class="{ 'cursor-not-allowed': disabled }" @click="open">
<slot :echo-text="echoText" />
</div>
<DepartmentUserModal ref="modalRef" @confirm="handleConfirm" />
</template>
<script setup lang="ts">
/**
* 表单场景下的选部门/选人控件,套了一层 DepartmentUserModal,替代 selectDepOrUser(企微选人)。
* 触发区完全由调用方自己写(没有默认按钮/输入框),组件只负责打开弹窗和传出拼好的 echoText。
*
* 用法:
* <DeptUserPicker v-model="form.pushList" selection-mode="both" v-slot="{ echoText }">
* <el-input readonly class="w-96" placeholder="请选择部门/人员" :model-value="echoText" />
* </DeptUserPicker>
*
* v-model 绑的是 DepartmentUserSelection[](valueId/valueName/valueType 成对出现,不用额外查名字回显);
* 单选传 :multiple="false";只选人/只选部门用 selection-mode="user" / "department"。
*/
import DepartmentUserModal from '@/components/common/DepartmentUserModal/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { DeptUserPickerProps } from './types'
const {
selectionMode = 'both',
multiple = true,
userValueField = 'userId',
disabled = false,
} = defineProps<DeptUserPickerProps>()
const modelValue = defineModel<DepartmentUserSelection[]>({ default: () => [] })
const modalRef = useTemplateRef<InstanceType<typeof DepartmentUserModal>>('modalRef')
// 顿号拼接已选名字,调用方直接拿去当输入框回显文案用,不用自己再拼一遍。
const echoText = computed(() => modelValue.value.map((item) => item.valueName).join('、'))
const open = () => {
if (disabled) return
modalRef.value?.open({
selectionMode,
multiple,
userValueField,
selected: modelValue.value,
})
}
const handleConfirm = (items: DepartmentUserSelection[]) => {
modelValue.value = items
}
defineExpose({ open })
</script>
import type { SelectionMode, UserValueField } from '@/components/common/DepartmentUserModal/types'
export type DeptUserPickerProps = {
/** 部门/人员/两者,默认 both */
selectionMode?: SelectionMode
/** 是否多选,默认 true;单选时选中新项会自动替换掉之前的选择 */
multiple?: boolean
/** 人员值字段,默认 userId */
userValueField?: UserValueField
disabled?: boolean
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment