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
......@@ -22,6 +22,8 @@ export interface DepartmentUserModalOpenOptions {
selected?: DepartmentUserSelection[]
/** 是否允许多选,默认 true;单选时选中新项会自动替换掉之前的选择(不区分部门/人员)。 */
multiple?: boolean
/** 最多可选择的项数(部门+人员合计),不传不限制;超出时弹窗内直接提示,不接受本次勾选。 */
max?: number
}
export interface DepartmentUserModalExpose {
......
......@@ -8,18 +8,44 @@
<script setup lang="ts">
/**
* 表单场景下的选部门/选人控件,套了一层 DepartmentUserModal,替代 selectDepOrUser(企微选人)。
* 触发区完全由调用方自己写(没有默认按钮/输入框),组件只负责打开弹窗和传出拼好的 echoText。
* 表单场景下的选部门/选人控件,v-model 直接绑 id(数组或逗号字符串,由 value-type 决定),
* 跟后端字段直接对齐,业务页面不用再转换成对象数组。
*
* 用法:
* <DeptUserPicker v-model="form.pushList" selection-mode="both" v-slot="{ echoText }">
* <el-input readonly class="w-96" placeholder="请选择部门/人员" :model-value="echoText" />
* 用法一(只选部门,值是逗号字符串,直接对应后端 deptId 字段):
* <DeptUserPicker
* 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>
*
* 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 { getAllDept } from '@/api'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { DeptUserPickerProps } from './types'
......@@ -28,14 +54,87 @@ const {
multiple = true,
userValueField = 'userId',
disabled = false,
max,
valueType = 'array',
initialSelected,
} = 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 echoText = computed(() => modelValue.value.map((item) => item.valueName).join('、'))
// 记住选过/回显过/查过的每一项完整信息(名字、头像),key 是 `${valueType}-${valueId}`。
// 只增不减;用 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 = () => {
if (disabled) return
......@@ -43,12 +142,19 @@ const open = () => {
selectionMode,
multiple,
userValueField,
selected: modelValue.value,
max,
selected: [...resolveSelection(deptIds.value, 1), ...resolveSelection(userIds.value, 2)],
})
}
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 })
......
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 = {
/** 部门/人员/两者,默认 both */
......@@ -8,4 +12,14 @@ export type DeptUserPickerProps = {
/** 人员值字段,默认 userId */
userValueField?: UserValueField
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