Commit af06f8fb by lijiabin

【需求 25171】refactor: 优化选人组件的设计和几个交互 bug

DepartmentUserModal:
- 修复同一个人同时出现在父部门和子部门成员列表里时,勾选状态互相串、
  取消不同步的问题(node-key 改成带部门前缀保证全树唯一)
- 新增 max 限制,超出直接在弹窗内提示,不接受这次勾选
- 树行高、行间分割线、hover 光标等样式调整(改用 scoped style,
  之前链式 UnoCSS 工具类在这种组合下没有正确生效)

DeptUserPicker:
- v-model 拆成 dept-value/user-value 两个,只存 id,支持 array 或
  逗号字符串(value-type)两种格式,跟后端字段直接对齐
- 内部缓存选中详情(含头像),重新打开弹窗/编辑回显不会丢失
- 部门缺名字时自动用 getAllDept({ deptIdList }) 反查,不需要调用方
  额外传初始详情;人员没有等价的批量查询接口,保留 initial-selected
  作为可选的回显种子

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
parent fee53bb2
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
@closed="handleClosed" @closed="handleClosed"
> >
<div <div
class="h-[560px] min-h-0 flex overflow-hidden rounded-lg border border-[var(--el-border-color)] shadow-sm" class="h-[600px] min-h-0 flex overflow-hidden rounded-lg border border-[var(--el-border-color)] shadow-sm"
> >
<!-- 左侧直接展示部门和成员,成员不再依赖单独的部门详情列。 --> <!-- 左侧直接展示部门和成员,成员不再依赖单独的部门详情列。 -->
<section <section
...@@ -55,7 +55,12 @@ ...@@ -55,7 +55,12 @@
class="flex cursor-pointer items-center gap-2 rounded px-2 py-2 transition-colors hover:bg-[var(--el-color-primary-light-9)]" 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)" @click="toggleSearchUser(user)"
> >
<el-avatar shape="square" :size="28" :src="user.avatar" class="flex-shrink-0 rounded"> <el-avatar
shape="square"
:size="28"
:src="user.avatar"
class="flex-shrink-0 rounded"
>
{{ user.name.slice(0, 1) }} {{ user.name.slice(0, 1) }}
</el-avatar> </el-avatar>
<span class="min-w-0 flex-1 truncate text-sm">{{ user.name }}</span> <span class="min-w-0 flex-1 truncate text-sm">{{ user.name }}</span>
...@@ -91,7 +96,7 @@ ...@@ -91,7 +96,7 @@
:key="treeVersion" :key="treeVersion"
ref="departmentTreeRef" ref="departmentTreeRef"
lazy 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)]" class="dept-tree w-full text-sm [--el-tree-node-content-height:52px]"
node-key="key" node-key="key"
show-checkbox show-checkbox
check-strictly check-strictly
...@@ -106,18 +111,21 @@ ...@@ -106,18 +111,21 @@
<template #default="{ data }"> <template #default="{ data }">
<div class="min-w-0 flex flex-1 items-center gap-2"> <div class="min-w-0 flex flex-1 items-center gap-2">
<template v-if="data.nodeType === 'department'"> <template v-if="data.nodeType === 'department'">
<el-icon class="flex-shrink-0 text-base text-[var(--el-color-primary)]"> <el-icon class="flex-shrink-0 text-lg text-[var(--el-color-primary)]">
<FolderOpened v-if="expandedNodeKeys.includes(data.key)" /> <FolderOpened v-if="expandedNodeKeys.includes(data.key)" />
<Folder v-else /> <Folder v-else />
</el-icon> </el-icon>
<span class="truncate text-[var(--el-text-color-primary)]" :title="data.department.fullName"> <span
class="truncate text-[var(--el-text-color-primary)]"
:title="data.department.fullName"
>
{{ data.department.simpleName }} {{ data.department.simpleName }}
</span> </span>
</template> </template>
<template v-else> <template v-else>
<el-avatar <el-avatar
shape="square" shape="square"
:size="24" :size="26"
:src="data.user.avatar" :src="data.user.avatar"
class="flex-shrink-0 rounded" class="flex-shrink-0 rounded"
> >
...@@ -142,7 +150,9 @@ ...@@ -142,7 +150,9 @@
<div class="text-[15px] font-600 text-[var(--el-text-color-primary)]"> <div class="text-[15px] font-600 text-[var(--el-text-color-primary)]">
已选择{{ multiple ? '' : '(单选)' }} 已选择{{ multiple ? '' : '(单选)' }}
</div> </div>
<div class="mt-2 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]"> <div
class="mt-2 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{{ selectedDepartmentCount }} 个部门</span> <span>{{ selectedDepartmentCount }} 个部门</span>
<span>·</span> <span>·</span>
...@@ -172,22 +182,22 @@ ...@@ -172,22 +182,22 @@
</div> </div>
<el-scrollbar class="min-h-0 flex-1 pt-1"> <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-if="selectedItems.length" class="space-y-1">
<div <div
v-for="item in selectedItems" v-for="item in selectedItems"
:key="selectionKey(item)" :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" class="group flex min-w-0 items-center gap-2 rounded-md bg-white px-2 py-2 shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors hover:bg-[var(--el-color-primary-light-9)]"
> >
<el-avatar <el-avatar
v-if="item.valueType === 2" v-if="item.valueType === 2"
shape="square" shape="square"
:size="24" :size="26"
:src="item.avatar" :src="item.avatar"
class="flex-shrink-0 rounded" class="flex-shrink-0 rounded"
> >
{{ item.valueName.slice(0, 1) }} {{ item.valueName.slice(0, 1) }}
</el-avatar> </el-avatar>
<el-icon v-else class="flex-shrink-0 text-base text-[var(--el-color-primary)]"> <el-icon v-else class="flex-shrink-0 text-lg text-[var(--el-color-primary)]">
<FolderOpened /> <FolderOpened />
</el-icon> </el-icon>
<span class="min-w-0 flex-1 truncate text-sm" :title="item.valueName"> <span class="min-w-0 flex-1 truncate text-sm" :title="item.valueName">
...@@ -195,7 +205,7 @@ ...@@ -195,7 +205,7 @@
</span> </span>
<el-button <el-button
text text
class="!h-auto !p-0.5 opacity-0 transition-opacity group-hover:opacity-100" class="!h-auto !flex-shrink-0 !p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
:icon="Close" :icon="Close"
aria-label="移除选择" aria-label="移除选择"
@click="removeSelection(item)" @click="removeSelection(item)"
...@@ -225,26 +235,11 @@ ...@@ -225,26 +235,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 选部门/选人弹窗,命令式调用,不做表单场景的回显和触发按钮(那部分见 DeptUserPicker)。 * 选部门/选人弹窗的实际 UI(组织架构树 + 搜索 + 已选列表)。
* * 业务页面不要直接用这个组件,统一通过 DeptUserPicker(表单场景的封装)来用。
* 用法: * 这里只是 DeptUserPicker 内部拿来复用的弹窗实现,open()/close() 是给它调的。
* 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 { import { Check, Close, Folder, FolderOpened, Refresh, Search } from '@element-plus/icons-vue'
Check,
Close,
Folder,
FolderOpened,
Refresh,
Search,
} from '@element-plus/icons-vue'
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { push } from 'notivue' import { push } from 'notivue'
import { getAllDept, getDeptUsers } from '@/api' import { getAllDept, getDeptUsers } from '@/api'
...@@ -298,6 +293,7 @@ const expandedNodeKeys = ref<string[]>([]) ...@@ -298,6 +293,7 @@ const expandedNodeKeys = ref<string[]>([])
const searchKeyword = ref('') const searchKeyword = ref('')
const selectionMode = ref<SelectionMode>('both') const selectionMode = ref<SelectionMode>('both')
const multiple = ref(true) const multiple = ref(true)
const maxCount = ref<number | undefined>(undefined)
const userValueField = ref<UserValueField>('userId') const userValueField = ref<UserValueField>('userId')
const departmentTreeRef = useTemplateRef('departmentTreeRef') const departmentTreeRef = useTemplateRef('departmentTreeRef')
const searchWrapperRef = useTemplateRef<HTMLDivElement>('searchWrapperRef') const searchWrapperRef = useTemplateRef<HTMLDivElement>('searchWrapperRef')
...@@ -334,7 +330,16 @@ const getUserValue = (user: DepartmentUserDto) => { ...@@ -334,7 +330,16 @@ const getUserValue = (user: DepartmentUserDto) => {
if (userValueField.value === 'jobNumId') return user.jobNumId || '' if (userValueField.value === 'jobNumId') return user.jobNumId || ''
return user.userId return user.userId
} }
const getUserNodeKey = (user: DepartmentUserDto) => `user-${String(getUserValue(user))}` // 同一个人可能同时出现在父部门和子部门的成员列表里(后端数据结构决定的,不是 bug),
// 树的 node-key 必须全树唯一,所以要带上所属部门节点的 key 前缀,不能只用 userId;
// 这个 map 记录每个 userId 在树里实际出现过的所有 key,选中同步(setCheckedState)时要用。
const userNodeKeysById = new Map<string, Set<string>>()
const registerUserNodeKey = (userId: string, key: string) => {
if (!userNodeKeysById.has(userId)) userNodeKeysById.set(userId, new Set())
userNodeKeysById.get(userId)!.add(key)
}
const getUserNodeKey = (parentKey: string, user: DepartmentUserDto) =>
`${parentKey}::user-${String(getUserValue(user))}`
const normalizeSelection = ( const normalizeSelection = (
item: DepartmentUserSelection, item: DepartmentUserSelection,
...@@ -361,23 +366,31 @@ const normalizeSelection = ( ...@@ -361,23 +366,31 @@ const normalizeSelection = (
} }
// 选择项统一按类型和值字段去重,保证回显和重复展开部门时结果一致; // 选择项统一按类型和值字段去重,保证回显和重复展开部门时结果一致;
// 单选模式下选中新项会直接替换掉之前的选择,不区分部门/人员。 // 单选模式下选中新项会直接替换掉之前的选择,不区分部门/人员;
// 设了 max 的话,多选超出时直接提示、不接受这次勾选。
const addSelection = (item: DepartmentUserSelection) => { const addSelection = (item: DepartmentUserSelection) => {
if (!item.valueId) return if (!item.valueId) return
if (selectedItems.value.some((selected) => selectionKey(selected) === selectionKey(item))) return if (selectedItems.value.some((selected) => selectionKey(selected) === selectionKey(item))) return
if (!multiple.value) selectedItems.value = [] if (!multiple.value) {
selectedItems.value = []
} else if (maxCount.value && selectedItems.value.length >= maxCount.value) {
push.warning(`最多只能选择 ${maxCount.value} 个`)
return
}
selectedItems.value.push(normalizeSelection(item)) selectedItems.value.push(normalizeSelection(item))
} }
const setCheckedState = async () => { const setCheckedState = async () => {
await nextTick() await nextTick()
syncingCheckedState = true syncingCheckedState = true
departmentTreeRef.value?.setCheckedKeys( // 人员可能在树里出现在多个部门分支下,同一个 userId 要把它所有已知出现过的节点 key 都勾上,
selectedItems.value.map((item) => // 不然只会勾中其中一个分支,另一个分支看起来像没选中(其实选中状态本身没错,是勾选框没同步)。
item.valueType === 1 ? getDepartmentNodeKey(item.valueId) : `user-${item.valueId}`, const keys = selectedItems.value.flatMap((item) => {
), if (item.valueType === 1) return [getDepartmentNodeKey(item.valueId)]
) return Array.from(userNodeKeysById.get(item.valueId) ?? [])
})
departmentTreeRef.value?.setCheckedKeys(keys)
syncingCheckedState = false syncingCheckedState = false
} }
...@@ -422,16 +435,20 @@ const buildDepartmentTree = (list: DepartmentDto[]): DepartmentTreeNode[] => { ...@@ -422,16 +435,20 @@ const buildDepartmentTree = (list: DepartmentDto[]): DepartmentTreeNode[] => {
return roots return roots
} }
const createUserNodes = (users: DepartmentUserDto[]): UserTreeNode[] => const createUserNodes = (users: DepartmentUserDto[], parentKey: string): UserTreeNode[] =>
users users
.map((user) => ({ .filter((user) => !!getUserValue(user))
key: getUserNodeKey(user), .map((user) => {
nodeType: 'user' as const, const key = getUserNodeKey(parentKey, user)
user, registerUserNodeKey(String(getUserValue(user)), key)
disabled: !canSelectUser.value, return {
isLeaf: true as const, key,
})) nodeType: 'user' as const,
.filter((node) => node.key !== 'user-') user,
disabled: !canSelectUser.value,
isLeaf: true as const,
}
})
// el-tree 懒加载的 load 回调:部门下的子部门在打开弹窗时已经一次性拿到了, // el-tree 懒加载的 load 回调:部门下的子部门在打开弹窗时已经一次性拿到了,
// 这里只需要按需请求成员,再和已知的子部门合并交给 el-tree 自己管理, // 这里只需要按需请求成员,再和已知的子部门合并交给 el-tree 自己管理,
...@@ -445,7 +462,9 @@ const loadTreeNode = async (node: any, resolve: (data: TreeNode[]) => void) => { ...@@ -445,7 +462,9 @@ const loadTreeNode = async (node: any, resolve: (data: TreeNode[]) => void) => {
const data = node.data as TreeNode const data = node.data as TreeNode
const departmentChildren = const departmentChildren =
data.nodeType === 'department' ? data.children.filter((child) => child.nodeType === 'department') : [] data.nodeType === 'department'
? data.children.filter((child) => child.nodeType === 'department')
: []
if (data.nodeType !== 'department' || !canSelectUser.value || !data.department.hasUserFlag) { if (data.nodeType !== 'department' || !canSelectUser.value || !data.department.hasUserFlag) {
resolve(departmentChildren) resolve(departmentChildren)
...@@ -455,7 +474,7 @@ const loadTreeNode = async (node: any, resolve: (data: TreeNode[]) => void) => { ...@@ -455,7 +474,7 @@ const loadTreeNode = async (node: any, resolve: (data: TreeNode[]) => void) => {
try { try {
const { data: users } = await getDeptUsers({ deptId: data.department.deptId }) const { data: users } = await getDeptUsers({ deptId: data.department.deptId })
// 展开部门时优先展示直属成员,再展示下级部门,符合选人时的浏览顺序。 // 展开部门时优先展示直属成员,再展示下级部门,符合选人时的浏览顺序。
resolve([...createUserNodes(users || []), ...departmentChildren]) resolve([...createUserNodes(users || [], data.key), ...departmentChildren])
} catch (error) { } catch (error) {
console.error('获取部门成员失败:', error) console.error('获取部门成员失败:', error)
push.error('获取部门成员失败,请稍后重试') push.error('获取部门成员失败,请稍后重试')
...@@ -618,12 +637,14 @@ const resetState = () => { ...@@ -618,12 +637,14 @@ const resetState = () => {
selectedItems.value = [] selectedItems.value = []
expandedNodeKeys.value = [] expandedNodeKeys.value = []
searchKeyword.value = '' searchKeyword.value = ''
userNodeKeysById.clear()
} }
const open = (options: DepartmentUserModalOpenOptions = {}) => { const open = (options: DepartmentUserModalOpenOptions = {}) => {
resetState() resetState()
selectionMode.value = options.selectionMode ?? 'both' selectionMode.value = options.selectionMode ?? 'both'
multiple.value = options.multiple ?? true multiple.value = options.multiple ?? true
maxCount.value = options.multiple === false ? undefined : options.max
userValueField.value = options.userValueField ?? 'userId' userValueField.value = options.userValueField ?? 'userId'
selectedItems.value = (options.selected ?? []).map((item) => selectedItems.value = (options.selected ?? []).map((item) =>
normalizeSelection(item, options.userValueField ?? 'userId'), normalizeSelection(item, options.userValueField ?? 'userId'),
...@@ -653,3 +674,32 @@ const handleClosed = () => { ...@@ -653,3 +674,32 @@ const handleClosed = () => {
defineExpose<DepartmentUserModalExpose>({ open, close }) defineExpose<DepartmentUserModalExpose>({ open, close })
</script> </script>
<style scoped>
/* 这几条放到普通 CSS 里写,链式的 UnoCSS 工具类在这么多变体组合下不太可靠(试过 border-b、box-shadow 都没生效)。 */
.dept-tree :deep(.el-tree-node__content) {
box-sizing: border-box;
height: 50px;
padding-right: 8px;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 1px 0 0 var(--el-border-color-lighter);
transition: background-color 0.2s;
}
.dept-tree :deep(.el-tree-node__content:hover) {
background-color: var(--el-color-primary-light-9);
}
.dept-tree :deep(.el-tree-node__expand-icon) {
color: var(--el-text-color-primary);
}
.dept-tree :deep(.el-checkbox__inner) {
border-color: var(--el-text-color-secondary);
}
.dept-tree :deep(.el-checkbox.is-disabled) {
display: none;
}
</style>
...@@ -22,6 +22,8 @@ export interface DepartmentUserModalOpenOptions { ...@@ -22,6 +22,8 @@ export interface DepartmentUserModalOpenOptions {
selected?: DepartmentUserSelection[] selected?: DepartmentUserSelection[]
/** 是否允许多选,默认 true;单选时选中新项会自动替换掉之前的选择(不区分部门/人员)。 */ /** 是否允许多选,默认 true;单选时选中新项会自动替换掉之前的选择(不区分部门/人员)。 */
multiple?: boolean multiple?: boolean
/** 最多可选择的项数(部门+人员合计),不传不限制;超出时弹窗内直接提示,不接受本次勾选。 */
max?: number
} }
export interface DepartmentUserModalExpose { export interface DepartmentUserModalExpose {
......
...@@ -8,18 +8,44 @@ ...@@ -8,18 +8,44 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 表单场景下的选部门/选人控件,套了一层 DepartmentUserModal,替代 selectDepOrUser(企微选人)。 * 表单场景下的选部门/选人控件,v-model 直接绑 id(数组或逗号字符串,由 value-type 决定),
* 触发区完全由调用方自己写(没有默认按钮/输入框),组件只负责打开弹窗和传出拼好的 echoText。 * 跟后端字段直接对齐,业务页面不用再转换成对象数组。
* *
* 用法: * 用法一(只选部门,值是逗号字符串,直接对应后端 deptId 字段):
* <DeptUserPicker v-model="form.pushList" selection-mode="both" v-slot="{ echoText }"> * <DeptUserPicker
* <el-input readonly class="w-96" placeholder="请选择部门/人员" :model-value="echoText" /> * v-model:dept-value="form.deptId"
* selection-mode="department"
* value-type="string"
* :max="3"
* @confirm="(items) => (form.deptName = items.map((i) => i.valueName).join(','))"
* v-slot="{ echoText }"
* >
* <el-input readonly :model-value="echoText" placeholder="请选择部门" />
* </DeptUserPicker> * </DeptUserPicker>
* *
* v-model 绑的是 DepartmentUserSelection[](valueId/valueName/valueType 成对出现,不用额外查名字回显); * 用法二(部门/人员都能选,值是数组):
* 单选传 :multiple="false";只选人/只选部门用 selection-mode="user" / "department"。 * <DeptUserPicker
* v-model:dept-value="deptIds"
* v-model:user-value="userIds"
* selection-mode="both"
* :initial-selected="initialPushDetail"
* @confirm="(items) => (pushList = items)"
* v-slot="{ echoText }"
* >
* <el-input readonly :model-value="echoText" placeholder="请选择部门/人员" />
* </DeptUserPicker>
*
* 回显文案(echoText)不用页面自己拼:组件内部会缓存选过/查过的每一项完整信息(名字、头像)。
* - 部门:id 缺名字时组件会自己调 getAllDept({ deptIdList }) 反查名字,不用调用方传,
* 跟另一个项目里 deptUserModal 的 getSpecifyDept 是一个思路。
* - 人员:后端 getDeptUsers 没有“按 userId 批量查”的接口,没法反查,
* 所以人员这边编辑回显时如果没在本次会话选过,就只能显示 id;
* 如果页面本来就有现成的详情(比如后端详情接口直接返回的 pushList,自带 valueName),
* 传 initial-selected 进来能让首次回显正确显示名字,不用等组件去查(其实也查不到)。
* 如果业务上还需要把选中的名字存到某个字段(比如 deptName),在 @confirm 里自己处理就行。
*/ */
import DepartmentUserModal from '@/components/common/DepartmentUserModal/index.vue' import DepartmentUserModal from '@/components/common/DepartmentUserModal/index.vue'
import { getAllDept } from '@/api'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types' import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { DeptUserPickerProps } from './types' import type { DeptUserPickerProps } from './types'
...@@ -28,14 +54,87 @@ const { ...@@ -28,14 +54,87 @@ const {
multiple = true, multiple = true,
userValueField = 'userId', userValueField = 'userId',
disabled = false, disabled = false,
max,
valueType = 'array',
initialSelected,
} = defineProps<DeptUserPickerProps>() } = defineProps<DeptUserPickerProps>()
const modelValue = defineModel<DepartmentUserSelection[]>({ default: () => [] }) const emit = defineEmits<{
confirm: [items: DepartmentUserSelection[]]
}>()
const deptValue = defineModel<string[] | string>('deptValue', { default: () => [] })
const userValue = defineModel<string[] | string>('userValue', { default: () => [] })
const modalRef = useTemplateRef<InstanceType<typeof DepartmentUserModal>>('modalRef') const modalRef = useTemplateRef<InstanceType<typeof DepartmentUserModal>>('modalRef')
// 顿号拼接已选名字,调用方直接拿去当输入框回显文案用,不用自己再拼一遍。 // 记住选过/回显过/查过的每一项完整信息(名字、头像),key 是 `${valueType}-${valueId}`。
const echoText = computed(() => modelValue.value.map((item) => item.valueName).join('、')) // 只增不减;用 reactive 包一层是因为要让 getAllDept 查完之后异步写回缓存也能触发 echoText 重新计算。
const detailCache = reactive(new Map<string, DepartmentUserSelection>())
const cacheKey = (valueType: 1 | 2, valueId: string) => `${valueType}-${valueId}`
watch(
() => initialSelected,
(items) => {
items?.forEach((item) => {
const key = cacheKey(item.valueType, item.valueId)
// 不覆盖已有缓存:避免只有 id+名字的种子数据把本次会话里已经拿到的真实头像覆盖掉。
if (!detailCache.has(key)) detailCache.set(key, item)
})
},
{ immediate: true },
)
const toIdArray = (value: string[] | string | undefined): string[] => {
if (!value) return []
return Array.isArray(value) ? value : value.split(',').filter(Boolean)
}
const serializeIds = (ids: string[]): string[] | string =>
valueType === 'string' ? ids.join(',') : ids
const deptIds = computed(() => toIdArray(deptValue.value))
const userIds = computed(() => toIdArray(userValue.value))
// 部门缺名字时自己查一次(getAllDept 支持按 deptIdList 查),不用调用方传;
// 人员没有等价的批量查询接口,查不到,只能靠 confirm 时缓存的结果或 initial-selected。
watch(
deptIds,
async (ids) => {
const missingIds = ids.filter((id) => !detailCache.has(cacheKey(1, id)))
if (!missingIds.length) return
try {
const { data } = await getAllDept({ deptIdList: missingIds })
data?.forEach((dept) => {
const key = cacheKey(1, String(dept.deptId))
if (!detailCache.has(key)) {
detailCache.set(key, {
valueType: 1,
valueId: String(dept.deptId),
valueName: dept.fullName || dept.simpleName,
})
}
})
} catch (error) {
console.error('查询部门名称失败:', error)
}
},
{ immediate: true },
)
const resolveSelection = (ids: string[], type: 1 | 2): DepartmentUserSelection[] =>
ids.map(
(valueId) =>
detailCache.get(cacheKey(type, valueId)) ?? { valueType: type, valueId, valueName: valueId },
)
// 拼好给触发区直接展示的文案,部门在前、人员在后;没缓存到名字的项目会退化成显示 id。
const echoText = computed(() =>
[...resolveSelection(deptIds.value, 1), ...resolveSelection(userIds.value, 2)]
.map((item) => item.valueName)
.join(','),
)
const open = () => { const open = () => {
if (disabled) return if (disabled) return
...@@ -43,12 +142,19 @@ const open = () => { ...@@ -43,12 +142,19 @@ const open = () => {
selectionMode, selectionMode,
multiple, multiple,
userValueField, userValueField,
selected: modelValue.value, max,
selected: [...resolveSelection(deptIds.value, 1), ...resolveSelection(userIds.value, 2)],
}) })
} }
const handleConfirm = (items: DepartmentUserSelection[]) => { const handleConfirm = (items: DepartmentUserSelection[]) => {
modelValue.value = items items.forEach((item) => detailCache.set(cacheKey(item.valueType, item.valueId), item))
const depts = items.filter((item) => item.valueType === 1)
const users = items.filter((item) => item.valueType === 2)
deptValue.value = serializeIds(depts.map((item) => item.valueId))
userValue.value = serializeIds(users.map((item) => item.valueId))
emit('confirm', items)
} }
defineExpose({ open }) defineExpose({ open })
......
import type { SelectionMode, UserValueField } from '@/components/common/DepartmentUserModal/types' import type { SelectionMode, UserValueField } from '@/components/common/DepartmentUserModal/types'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
/** v-model 的值格式:array 是 id 数组,string 是逗号拼接的 id 字符串(跟后端字段直接对齐)。 */
export type DeptUserValueType = 'array' | 'string'
export type DeptUserPickerProps = { export type DeptUserPickerProps = {
/** 部门/人员/两者,默认 both */ /** 部门/人员/两者,默认 both */
...@@ -8,4 +12,14 @@ export type DeptUserPickerProps = { ...@@ -8,4 +12,14 @@ export type DeptUserPickerProps = {
/** 人员值字段,默认 userId */ /** 人员值字段,默认 userId */
userValueField?: UserValueField userValueField?: UserValueField
disabled?: boolean disabled?: boolean
/** 最多可选择的项数(部门+人员合计),不传不限制 */
max?: number
/** v-model:dept-value / v-model:user-value 的值格式,默认 array */
valueType?: DeptUserValueType
/**
* 编辑回显用的初始详情(valueId/valueType/valueName,最好带上 avatar),
* 页面如果本来就有(比如详情接口直接返回的 pushList),传进来就行;
* 不传的话首次回显(还没点开过弹窗前)只能看到 id。
*/
initialSelected?: DepartmentUserSelection[]
} }
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