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 remark: string
loginDate: Date loginDate: Date
createTime: Date createTime: Date
disabled?: boolean
} }
// 查询用户管理列表 // 查询用户管理列表
@ -22,6 +23,11 @@ export const getUserPage = (params: PageParam) => {
return request.get({ url: '/system/user/page', params }) 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) => { export const getUser = (id: number) => {
return request.get({ url: '/system/user/get?id=' + id }) return request.get({ url: '/system/user/get?id=' + id })

View File

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

View File

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

View File

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