feat(system): 优化用户选择 UserSelectV2 布局,多选支持、默认选中当前用户支持、禁选支持、默认部门支持,可替代项目所有位置,可移除原 UserSelectForm、UserSelect,避免一次性查询所有用户

pull/877/head
preschooler 2026-05-05 12:35:31 +08:00
parent 5e937d797d
commit 536e54062e
4 changed files with 189 additions and 65 deletions

View File

@ -15,6 +15,7 @@ export interface UserVO {
remark: string
loginDate: Date
createTime: Date
disabled?: boolean
}
// 查询用户管理列表
@ -22,6 +23,11 @@ export const getUserPage = (params: PageParam) => {
return request.get({ url: '/system/user/page', params })
}
// 查询用户管理列表
export const getUserList = (ids: number[]) => {
return request.get({ url: '/system/user/list', params: { ids: ids.join(',') } })
}
// 查询用户详情
export const getUser = (id: number) => {
return request.get({ url: '/system/user/get?id=' + id })

View File

@ -10,28 +10,25 @@
reset() 清空选中状态供外部重置按钮调用
-->
<template>
<div>
<el-input
v-model="filterText"
class="mb-15px"
clearable
:placeholder="filterPlaceholder"
>
<div class="h-full">
<el-input v-model="filterText" class="p-[15px]" clearable :placeholder="filterPlaceholder">
<template #prefix>
<Icon icon="ep:search" />
</template>
</el-input>
<el-tree
ref="treeRef"
:data="deptList"
:expand-on-click-node="false"
:filter-node-method="filterNode"
:props="defaultProps"
default-expand-all
highlight-current
node-key="id"
@node-click="handleNodeClick"
/>
<el-scrollbar class="!h-[calc(100%-32px-30px)]">
<el-tree
ref="treeRef"
:data="deptList"
:expand-on-click-node="false"
:filter-node-method="filterNode"
:props="defaultProps"
default-expand-all
highlight-current
node-key="id"
@node-click="handleNodeClick"
/>
</el-scrollbar>
</div>
</template>
@ -98,7 +95,11 @@ const reset = () => {
treeRef.value?.setCurrentKey(undefined)
}
defineExpose({ reset })
const setCurrent = (deptId: number) => {
treeRef.value?.setCurrentKey(deptId)
}
defineExpose({ reset, setCurrent })
/** 初始化 */
onMounted(async () => {

View File

@ -6,31 +6,41 @@
Props:
multiple true 多选checkboxfalse 单选radio默认 true
deptId 部门 ID
Events:
selected(rows: UserVO[]) 确认选择后触发单选时数组长度为 1
Expose:
open(selectedIds?: number[]) 打开弹窗可传入已选 ID 用于预选高亮
-->
<template>
<Dialog title="人员选择" v-model="dialogVisible" width="80%">
<el-row :gutter="20">
<Dialog :title="title" v-model="dialogVisible" width="80%" align-center append-to-body>
<el-row class="h-[calc(100vh-196px)]" :gutter="15">
<!-- 左侧部门树 -->
<el-col :span="5" :xs="24">
<ContentWrap class="h-full">
<el-col class="h-full" :span="5" :xs="24">
<ContentWrap class="h-full" :body-style="{ height: '100%', '--el-card-padding': '0px' }">
<DeptTreeSelect ref="deptTreeRef" @node-click="handleDeptNodeClick" />
</ContentWrap>
</el-col>
<!-- 右侧搜索表单 + 用户表格 -->
<el-col :span="19" :xs="24">
<el-col class="h-full overflow-auto" :span="19" :xs="24">
<ContentWrap>
<el-form :inline="true" :model="queryParams" label-width="85px">
<el-form class="-mb-[15px]" :inline="true" :model="queryParams" label-width="72px">
<el-form-item label="用户名称">
<el-input
v-model="queryParams.username"
placeholder="请输入用户名称"
clearable
@keyup.enter="handleQuery"
class="!w-220px"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="用户昵称">
<el-input
v-model="queryParams.nickname"
placeholder="请输入用户昵称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="手机号码">
@ -39,7 +49,7 @@
placeholder="请输入手机号码"
clearable
@keyup.enter="handleQuery"
class="!w-220px"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态">
@ -47,7 +57,7 @@
v-model="queryParams.status"
placeholder="请选择状态"
clearable
class="!w-220px"
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@ -68,9 +78,13 @@
</el-form>
</ContentWrap>
<!-- 数据表格单选 radio / 多选 checkbox 统一在一个 table -->
<ContentWrap>
<ContentWrap
class="h-[calc(100%-var(--content-wrap-padding,10px)*2-var(--content-wrap-margin,15px)*2-32px*2-3px*2-2px)] !mb-0"
:body-style="{ height: '100%', padding: 'var(--content-wrap-padding,10px)' }"
>
<el-table
ref="tableRef"
class="!h-[calc(100%-32px-30px+var(--content-wrap-padding,10px))]"
v-loading="loading"
:data="list"
:stripe="true"
@ -85,6 +99,7 @@
<el-table-column
v-if="multiple"
type="selection"
:selectable="selectable"
:reserve-selection="true"
width="50"
align="center"
@ -96,10 +111,12 @@
v-model="selectedRadioId"
:value="row.id"
class="radio-no-label"
:disabled="row.disabled"
@change="handleRadioChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="用户编号" align="center" prop="id" width="150" />
<el-table-column label="用户名称" align="center" prop="username" width="150" />
<el-table-column label="用户昵称" align="left" prop="nickname" min-width="150" />
<el-table-column label="部门" align="center" prop="deptName" width="150" />
@ -109,6 +126,13 @@
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180"
/>
</el-table>
<Pagination
:total="total"
@ -131,27 +155,32 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as UserApi from '@/api/system/user'
import DeptTreeSelect from '@/views/system/dept/components/DeptTreeSelect.vue'
import { dateFormatter } from '@/utils/formatTime'
defineOptions({ name: 'UserSelectDialogV2' })
const props = withDefaults(
defineProps<{
title?: string
multiple?: boolean // true checkboxfalse radio
deptId?: number // ID
}>(),
{
title: '人员选择',
multiple: true
}
)
const message = useMessage()
const emit = defineEmits<{
selected: [rows: UserApi.UserVO[]]
selected: [rows: UserApi.UserVO[], activityId?: any]
}>()
const dialogVisible = ref(false) //
const loading = ref(false) //
const list = ref<UserApi.UserVO[]>([]) //
const total = ref(0) //
const activityId = ref()
// ==================== ====================
const deptTreeRef = ref() // Ref
@ -168,6 +197,12 @@ const selectedRows = ref<UserApi.UserVO[]>([]) // 多选模式:选中行
const selectedRadioId = ref<number>() // ID
const currentRadioRow = ref<UserApi.UserVO>() //
const preSelectedIds = ref<number[]>([]) // ID
const preDisabledIds = ref<number[]>([]) // ID
/** 多选:是否可以选中 */
const selectable = (row: UserApi.UserVO) => {
return !preDisabledIds.value.includes(row.id)
}
/** 多选checkbox 变化 */
const handleSelectionChange = (rows: UserApi.UserVO[]) => {
@ -183,6 +218,9 @@ const handleRadioChange = (row: UserApi.UserVO) => {
/** 单击行:单选模式下点击整行即选中(降低操作成本),多选不处理(避免和 dblclick 冲突) */
const handleRowClick = (row: UserApi.UserVO) => {
if (row.disabled) {
return
}
if (props.multiple) {
return
}
@ -192,6 +230,9 @@ const handleRowClick = (row: UserApi.UserVO) => {
/** 双击行:多选模式切换勾选,单选模式直接确认 */
const handleRowDblClick = (row: UserApi.UserVO) => {
if (row.disabled) {
return
}
if (props.multiple) {
tableRef.value?.toggleRowSelection(row)
return
@ -206,6 +247,7 @@ const queryParams = reactive({
pageNo: 1, //
pageSize: 10, //
username: undefined as string | undefined, //
nickname: undefined as string | undefined, //
mobile: undefined as string | undefined, //
status: CommonStatusEnum.ENABLE as number | undefined, //
deptId: undefined as number | undefined // ID
@ -218,6 +260,9 @@ const getList = async () => {
const data = await UserApi.getUserPage(queryParams)
list.value = data.list
total.value = data.total
list.value.forEach((row) => {
row.disabled = preDisabledIds.value.includes(row.id)
})
await nextTick()
applyPreSelection()
} finally {
@ -273,13 +318,13 @@ const confirmSelect = () => {
message.warning('请至少选择一条数据')
return
}
emit('selected', selectedRows.value)
emit('selected', selectedRows.value, activityId.value)
} else {
if (!currentRadioRow.value) {
message.warning('请选择一条数据')
return
}
emit('selected', [currentRadioRow.value])
emit('selected', [currentRadioRow.value], activityId.value)
}
dialogVisible.value = false
}
@ -287,24 +332,29 @@ const confirmSelect = () => {
// ==================== ====================
/** 打开弹窗,可传入已选 ID 用于预选高亮 */
const open = async (selectedIds?: number[]) => {
const open = async (selectedIds?: number[], disabledIds?: number[], _activityId?: any) => {
preDisabledIds.value = disabledIds ?? []
activityId.value = _activityId
dialogVisible.value = true
// +
queryParams.username = undefined
queryParams.mobile = undefined
queryParams.status = CommonStatusEnum.ENABLE
queryParams.deptId = undefined
queryParams.deptId = props.deptId
queryParams.pageNo = 1
//
selectedRows.value = []
selectedRadioId.value = undefined
currentRadioRow.value = undefined
preSelectedIds.value = selectedIds ?? []
preSelectedIds.value = (selectedIds ?? []).filter((id) => !preDisabledIds.value.includes(id))
// +
await nextTick()
deptTreeRef.value?.reset()
tableRef.value?.clearSelection()
await getList()
if (queryParams.deptId) {
deptTreeRef.value?.setCurrent(queryParams.deptId)
}
}
defineExpose({ open })
</script>

View File

@ -3,15 +3,19 @@
对齐 MdVendorSelect 架构模式
交互显示为只读 el-input点击打开弹窗单选模式进行选择
交互显示为只读 el-input点击打开弹窗进行选择
Props:
modelValue 绑定的用户 IDv-model
defaultCurrentUser 默认选中当前用户
multiple 默认 false
disabled 是否禁用
disabledIds 禁用的用户 ID
clearable 是否允许清空鼠标悬停时显示清除图标
placeholder 占位文字
deptId 部门 ID
Events:
update:modelValue v-model 更新
change(item) 选中用户变化时触发传递完整 UserVO清空时为 undefined
change(item | items) 选中用户变化时触发传递完整 UserVO清空时为 undefined | []
-->
<template>
<div
@ -22,13 +26,15 @@
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<el-tooltip :disabled="!selectedItem" placement="top" :show-after="500">
<el-tooltip :disabled="selectedItems.length === 0" placement="top" :show-after="500">
<template #content>
<div v-if="selectedItem" class="leading-6">
<div>用户名称{{ selectedItem.username }}</div>
<div>用户昵称{{ selectedItem.nickname }}</div>
<div>部门{{ (selectedItem as any).deptName || '-' }}</div>
<div>手机号码{{ selectedItem.mobile || '-' }}</div>
<div v-if="selectedItems.length > 0" class="flex gap-[10px]">
<div v-for="selectedItem in selectedItems" :key="selectedItem.id" class="leading-6">
<div>用户名称{{ selectedItem.username }}</div>
<div>用户昵称{{ selectedItem.nickname }}</div>
<div>部门{{ (selectedItem as any).deptName || '-' }}</div>
<div>手机号码{{ selectedItem.mobile || '-' }}</div>
</div>
</div>
</template>
<el-input
@ -42,13 +48,20 @@
</el-tooltip>
</div>
<!-- 弹窗必须放在 div 外部否则弹窗内的点击事件会冒泡到 div 触发 handleClick -->
<UserSelectDialogV2 ref="dialogRef" :multiple="false" @selected="handleSelected" />
<!-- Dialog append-to-body 即可-->
<UserSelectDialogV2
ref="dialogRef"
:multiple="multiple"
:deptId="deptId"
@selected="handleSelected"
/>
</template>
<script setup lang="ts">
import * as UserApi from '@/api/system/user'
import { Search, CircleClose } from '@element-plus/icons-vue'
import UserSelectDialogV2 from './UserSelectDialogV2.vue'
import { useUserStoreWithOut } from '@/store/modules/user'
// div + DialogVue attrs
// div class / style
@ -58,12 +71,17 @@ defineOptions({ name: 'UserSelectV2', inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: number // ID
modelValue?: number | number[] // ID
defaultCurrentUser?: boolean //
multiple?: boolean //
disabled?: boolean //
disabledIds?: number[] // ID
clearable?: boolean //
placeholder?: string //
deptId?: number // ID
}>(),
{
defaultCurrentUser: false,
disabled: false,
clearable: true,
placeholder: '请选择用户'
@ -71,19 +89,19 @@ const props = withDefaults(
)
const emit = defineEmits<{
'update:modelValue': [value: number | undefined]
change: [item: UserApi.UserVO | undefined]
'update:modelValue': [value: number | number[] | undefined]
change: [item: UserApi.UserVO | UserApi.UserVO[] | undefined]
}>()
const dialogRef = ref() // Ref
const hovering = ref(false) //
// ==================== ====================
const selectedItem = ref<UserApi.UserVO | undefined>() //
const selectedItems = ref<UserApi.UserVO[]>([]) //
/** 输入框显示文本:展示用户昵称,保持简洁 */
const displayLabel = computed(() => {
return selectedItem.value?.nickname ?? ''
return selectedItems.value.map((item) => item.nickname || item.username).join('、')
})
/** 是否显示清除图标 */
@ -96,17 +114,21 @@ const suffixIcon = computed(() => {
return showClear.value ? CircleClose : Search
})
/** 根据 ID 单条查询用户信息(用于编辑回显) */
const resolveItemById = async (id: number | undefined) => {
if (id == null) {
selectedItem.value = undefined
/** 根据 ID 查询用户信息(用于编辑回显) */
const resolveItemById = async (id: number | number[] | undefined) => {
if (id === null || id === undefined) {
selectedItems.value = []
return
}
if (selectedItem.value?.id === id) {
const ids: number[] = Array.isArray(id) ? id : [id]
if (
selectedItems.value.length === ids.length &&
selectedItems.value.every((item) => ids.includes(item.id))
) {
return
}
try {
selectedItem.value = await UserApi.getUser(id)
selectedItems.value = await UserApi.getUserList(ids)
} catch (e) {
console.error('[UserSelectV2] resolveItemById failed:', e)
}
@ -118,7 +140,7 @@ watch(
(val) => {
resolveItemById(val)
},
{ immediate: true }
{ deep: true, immediate: true }
)
// ==================== ====================
@ -132,14 +154,23 @@ const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (showClear.value && target.closest('.el-input__suffix')) {
e.stopPropagation()
selectedItem.value = undefined
emit('update:modelValue', undefined)
emit('change', undefined)
selectedItems.value = []
if (props.multiple) {
emit('update:modelValue', [])
emit('change', [])
} else {
emit('update:modelValue', undefined)
emit('change', undefined)
}
return
}
// ID
const selectedIds = props.modelValue != null ? [props.modelValue] : []
dialogRef.value.open(selectedIds)
const selectedIds = props.multiple
? props.modelValue || []
: props.modelValue !== null && props.modelValue !== undefined
? [props.modelValue]
: []
dialogRef.value.open(selectedIds, props.disabledIds)
}
/** 弹窗选中回调 */
@ -147,11 +178,47 @@ const handleSelected = (rows: UserApi.UserVO[]) => {
if (!rows || rows.length === 0) {
return
}
const item = rows[0]
selectedItem.value = item
emit('update:modelValue', item.id)
emit('change', item)
selectedItems.value = rows
if (props.multiple) {
emit(
'update:modelValue',
rows.map((item) => item.id)
)
emit('change', rows)
} else {
const item = rows[0]
emit('update:modelValue', item.id)
emit('change', item)
}
}
/** 检查是否有有效的预设值 */
const hasValidPresetValue = (): boolean => {
const value = props.modelValue as any
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
onMounted(() => {
/** 默认选中当前用户 */
if (props.defaultCurrentUser && !hasValidPresetValue()) {
const userStore = useUserStoreWithOut()
const user = userStore.getUser
const currentUserId = user?.id
if (currentUserId) {
if (props.multiple) {
emit('update:modelValue', [currentUserId])
} else {
emit('update:modelValue', currentUserId)
}
}
}
})
</script>
<style lang="scss" scoped>