先上效果图
多选未选中状态:

多选已选中状态:

单选未选中状态:

单选已选中状态:

废话不多说直接贴代码!
组件代码(template部分):
<template>
<div class="select-people-container">
<!-- 单选模式的输入框显示 -->
<div v-if="!multiple" class="people-input" @click="handleOpen">
<el-input :model-value="singleSelectedName" readonly :placeholder="placeholder" :disabled="disabled">
<template #suffix>
<el-icon class="cursor-pointer text-primary">
<User />
</el-icon>
</template>
</el-input>
</div>
<!-- 多选模式的标签显示 -->
<div v-else class="people-tags" @click="handleOpen">
<div class="tags-container" :class="{ 'disabled': disabled, 'has-tags': selectedPeople.length > 0 }">
<el-tag v-for="person in selectedPeople" :key="person.userId" size="default" closable type="primary" effect="light"
@close="handleRemovePerson(person)" @click.stop>
<el-icon class="mr-1"><User /></el-icon>
{{ person.nickName }}
</el-tag>
<span v-if="selectedPeople.length === 0" class="placeholder">
<el-icon class="mr-1"><Plus /></el-icon>
{{ placeholder }}
</span>
<el-icon class="suffix-icon">
<ArrowDown />
</el-icon>
</div>
</div>
<!-- 选人对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="80%" :before-close="handleCancel" append-to-body
destroy-on-close class="select-people-dialog-wrapper">
<div class="select-people-dialog">
<el-row :gutter="24">
<!-- 左侧部门树 -->
<el-col :span="6" class="dept-tree-col">
<div class="section-card">
<div class="section-header">
<el-icon class="header-icon"><OfficeBuilding /></el-icon>
<span class="header-title">组织架构</span>
</div>
<div class="search-wrapper">
<el-input v-model="deptSearchText" placeholder="搜索部门" clearable size="default"
@input="handleDeptFilter">
<template #prefix>
<el-icon class="search-icon">
<Search />
</el-icon>
</template>
</el-input>
</div>
<div class="tree-wrapper">
<el-tree ref="deptTreeRef" v-loading="deptLoading" :data="deptOptions"
:props="{ label: 'label', children: 'children' }" node-key="id" :filter-node-method="filterDeptNode"
:expand-on-click-node="false" highlight-current default-expand-all @node-click="handleDeptClick">
<template #default="{ node, data }">
<span class="tree-node">
<el-icon class="node-icon">
<OfficeBuilding />
</el-icon>
<span class="node-label">{{ node.label }}</span>
</span>
</template>
</el-tree>
</div>
</div>
</el-col>
<!-- 中间人员列表 -->
<el-col :span="12" class="people-list-col">
<div class="section-card">
<div class="section-header">
<div class="header-left">
<el-icon class="header-icon"><User /></el-icon>
<span class="header-title">人员列表</span>
<el-badge :value="peopleTotal" class="ml-2" type="info" />
</div>
<div class="header-right">
<el-input v-model="peopleSearchText" placeholder="搜索姓名或手机号" clearable size="default"
class="search-input" @input="handlePeopleSearch">
<template #prefix>
<el-icon class="search-icon">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<div class="table-wrapper">
<el-table ref="peopleTableRef" v-loading="peopleLoading" :data="peopleList"
size="default" :highlight-current-row="!multiple" row-key="userId"
@current-change="handleSingleSelect" @selection-change="handleMultipleSelect"
class="people-table" :height="null">
<el-table-column v-if="multiple" type="selection" width="55" :reserve-selection="true" />
<el-table-column type="index" width="60" label="序号" />
<el-table-column prop="nickName" label="姓名" min-width="80">
<template #default="{ row }">
<div class="user-info">
<el-avatar :size="32" class="mr-2">
<el-icon><User /></el-icon>
</el-avatar>
<span class="user-name">{{ row.nickName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="phonenumber" label="手机号" min-width="110">
<template #default="{ row }">
<div class="phone-info">
<el-icon class="mr-1"><Phone /></el-icon>
{{ row.phonenumber || '-' }}
</div>
</template>
</el-table-column>
<el-table-column prop="deptName" label="部门" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">{{ row.deptName || '-' }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination v-show="peopleTotal > 0" v-model:current-page="peopleQuery.pageNum"
v-model:page-size="peopleQuery.pageSize" :total="peopleTotal"
layout="total, sizes, prev, pager, next, jumper" :page-sizes="[10, 20, 50, 100]"
@size-change="handlePeopleSizeChange" @current-change="handlePeopleCurrentChange"
background size="small" />
</div>
</div>
</el-col>
<!-- 右侧已选人员 -->
<el-col :span="6" class="selected-people-col">
<div class="section-card">
<div class="section-header">
<div class="header-left">
<el-icon class="header-icon"><Check /></el-icon>
<span class="header-title">已选人员</span>
<el-badge :value="tempSelectedPeople.length" class="ml-2" type="primary" />
</div>
<el-button link size="small" @click="handleClearAll" class="clear-btn">
<el-icon class="mr-1"><Delete /></el-icon>
清空
</el-button>
</div>
<div class="selected-list">
<el-scrollbar>
<div v-for="person in tempSelectedPeople" :key="person.userId" class="selected-item">
<div class="person-info">
<el-avatar :size="28" class="person-avatar">
<el-icon><User /></el-icon>
</el-avatar>
<div class="person-details">
<div class="person-name">{{ person.nickName }}</div>
<div class="person-dept">{{ person.deptName }}</div>
</div>
</div>
<el-button link size="small" @click="handleRemoveFromSelected(person)" class="remove-btn">
<el-icon>
<Close />
</el-icon>
</el-button>
</div>
<div v-if="tempSelectedPeople.length === 0" class="empty-selected">
<el-icon class="empty-icon"><User /></el-icon>
<div class="empty-text">暂无选中人员</div>
<div class="empty-desc">请从左侧选择人员</div>
</div>
</el-scrollbar>
</div>
</div>
</el-col>
</el-row>
</div>
<template #footer>
<div class="dialog-footer">
<div class="footer-info">
<el-icon class="info-icon"><InfoFilled /></el-icon>
<span>已选择 {{ tempSelectedPeople.length }} 人</span>
</div>
<div class="footer-actions">
<el-button @click="handleCancel" size="default">
<el-icon class="mr-1"><Close /></el-icon>
取消
</el-button>
<el-button type="primary" @click="handleConfirm" size="default">
<el-icon class="mr-1"><Check /></el-icon>
确定
</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
</template>
组件代码(script部分):
<script setup lang="ts" name="SelectPeople">
import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vue'
import { ElMessage } from 'element-plus'
import {
User, Search, OfficeBuilding, Delete, Plus, ArrowDown,
Phone, Check, Close, InfoFilled
} from '@element-plus/icons-vue'
import { listUser, deptTreeSelect } from '@/api/system/user'
import { UserVO, UserQuery } from '@/api/system/user/types'
import { DeptVO } from '@/api/system/dept/types'
// Props定义
interface Props {
modelValue?: UserVO | UserVO[] | string | string[] // 支持多种数据格式
multiple?: boolean // 是否多选
placeholder?: string // 占位符
disabled?: boolean // 是否禁用
title?: string // 对话框标题
limit?: number // 多选时的最大选择数量
deptId?: string | number // 指定部门ID,只显示该部门下的人员
excludeUserIds?: (string | number)[] // 排除的用户ID列表
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
placeholder: '请选择人员',
disabled: false,
title: '',
limit: 0,
deptId: '',
excludeUserIds: () => []
})
// Emits定义
const emit = defineEmits<{
'update:modelValue': [value: UserVO | UserVO[] | string | string[] | undefined]
'change': [value: UserVO | UserVO[], people: UserVO[]]
}>()
const { proxy } = getCurrentInstance()!
// 响应式数据
const dialogVisible = ref(false)
const deptLoading = ref(false)
const peopleLoading = ref(false)
const deptSearchText = ref('')
const peopleSearchText = ref('')
// 部门相关
const deptTreeRef = ref()
const deptOptions = ref<DeptVO[]>([])
const currentDeptId = ref<string | number>('')
// 人员相关
const peopleTableRef = ref()
const peopleList = ref<UserVO[]>([])
const peopleTotal = ref(0)
const peopleQuery = reactive<UserQuery>({
pageNum: 1,
pageSize: 20,
nickName: '',
phonenumber: '',
deptId: ''
})
// 选中的人员
const selectedPeople = ref<UserVO[]>([]) // 当前实际选中的人员
const tempSelectedPeople = ref<UserVO[]>([]) // 对话框中临时选中的人员
// 方法定义(在监听器之前定义)
const initSelectedPeople = (value: any) => {
if (!value) {
selectedPeople.value = []
return
}
if (props.multiple) {
if (Array.isArray(value)) {
// 如果是用户对象数组
if (value.length > 0 && typeof value[0] === 'object') {
selectedPeople.value = value as UserVO[]
} else {
// 如果是用户ID数组,需要根据ID获取用户信息
selectedPeople.value = []
// 这里可以根据需要调用API获取用户详细信息
}
}
} else {
if (typeof value === 'object') {
selectedPeople.value = [value as UserVO]
} else {
// 如果是用户ID,需要根据ID获取用户信息
selectedPeople.value = []
}
}
}
const emitChange = () => {
let value: any
if (props.multiple) {
value = selectedPeople.value
} else {
value = selectedPeople.value.length > 0 ? selectedPeople.value[0] : undefined
}
emit('update:modelValue', value)
emit('change', value, selectedPeople.value)
}
const loadDeptTree = async () => {
try {
deptLoading.value = true
const res = await deptTreeSelect()
deptOptions.value = res.data
// 如果指定了部门ID,设置为当前选中
if (props.deptId) {
currentDeptId.value = props.deptId
nextTick(() => {
deptTreeRef.value?.setCurrentKey(props.deptId)
})
}
} catch (error) {
console.error('加载部门树失败:', error)
ElMessage.error('加载部门信息失败')
} finally {
deptLoading.value = false
}
}
const setTableSelection = () => {
if (props.multiple && peopleTableRef.value) {
// 多选模式:设置复选框选中状态
peopleList.value.forEach(person => {
const isSelected = tempSelectedPeople.value.some(p => p.userId === person.userId)
peopleTableRef.value.toggleRowSelection(person, isSelected)
})
} else {
// 单选模式:设置当前行
if (tempSelectedPeople.value.length > 0) {
const selected = tempSelectedPeople.value[0]
const person = peopleList.value.find(p => p.userId === selected.userId)
if (person) {
peopleTableRef.value?.setCurrentRow(person)
}
}
}
}
const loadPeopleList = async () => {
try {
peopleLoading.value = true
// 设置查询参数
const query = { ...peopleQuery }
if (currentDeptId.value) {
query.deptId = currentDeptId.value
}
if (props.deptId) {
query.deptId = props.deptId
}
const res = await listUser(query)
let list = res.rows || []
// 排除指定的用户
if (props.excludeUserIds.length > 0) {
list = list.filter(user => !props.excludeUserIds.includes(user.userId))
}
peopleList.value = list
peopleTotal.value = res.total || 0
// 设置表格选中状态
nextTick(() => {
setTableSelection()
})
} catch (error) {
console.error('加载人员列表失败:', error)
ElMessage.error('加载人员信息失败')
} finally {
peopleLoading.value = false
}
}
// 计算属性
const dialogTitle = computed(() => {
return props.title || `人员选择${props.multiple ? '(多选)' : '(单选)'}`
})
const singleSelectedName = computed(() => {
if (!props.multiple && selectedPeople.value.length > 0) {
const person = selectedPeople.value[0]
return person.nickName
}
return ''
})
// 监听器
watch(() => props.modelValue, (newVal) => {
initSelectedPeople(newVal)
}, { immediate: true, deep: true })
watch(() => props.deptId, (newVal) => {
if (newVal) {
currentDeptId.value = newVal
peopleQuery.deptId = newVal
}
})
// 事件处理方法
const handleOpen = () => {
if (props.disabled) return
// 复制当前选中的人员到临时变量
tempSelectedPeople.value = [...selectedPeople.value]
dialogVisible.value = true
// 加载数据
loadDeptTree()
loadPeopleList()
}
const handleDeptClick = (data: DeptVO) => {
currentDeptId.value = data.id
peopleQuery.deptId = data.id
peopleQuery.pageNum = 1
loadPeopleList()
}
const handleDeptFilter = (value: string) => {
deptTreeRef.value?.filter(value)
}
const filterDeptNode = (value: string, data: any) => {
if (!value) return true
// 支持label或deptName属性
const name = data.label || data.deptName || ''
return name.includes(value)
}
const handlePeopleSearch = () => {
peopleQuery.pageNum = 1
if (peopleSearchText.value) {
// 简单判断是否为手机号
if (/^\d+$/.test(peopleSearchText.value)) {
peopleQuery.phonenumber = peopleSearchText.value
peopleQuery.nickName = ''
} else {
peopleQuery.nickName = peopleSearchText.value
peopleQuery.phonenumber = ''
}
} else {
peopleQuery.nickName = ''
peopleQuery.phonenumber = ''
}
loadPeopleList()
}
const handleSingleSelect = (currentRow: UserVO) => {
if (!props.multiple && currentRow) {
tempSelectedPeople.value = [currentRow]
}
}
const handleMultipleSelect = (selection: UserVO[]) => {
if (props.multiple) {
// 检查数量限制
if (props.limit > 0 && selection.length > props.limit) {
ElMessage.warning(`最多只能选择 ${props.limit} 个人员`)
// 移除超出的选择
nextTick(() => {
const excess = selection.slice(props.limit)
excess.forEach(person => {
peopleTableRef.value.toggleRowSelection(person, false)
})
})
return
}
tempSelectedPeople.value = selection
}
}
const handlePeopleSizeChange = (size: number) => {
peopleQuery.pageSize = size
peopleQuery.pageNum = 1
loadPeopleList()
}
const handlePeopleCurrentChange = (page: number) => {
peopleQuery.pageNum = page
loadPeopleList()
}
const handleRemoveFromSelected = (person: UserVO) => {
const index = tempSelectedPeople.value.findIndex(p => p.userId === person.userId)
if (index > -1) {
tempSelectedPeople.value.splice(index, 1)
// 同时更新表格选中状态
nextTick(() => {
peopleTableRef.value?.toggleRowSelection(person, false)
})
}
}
const handleRemovePerson = (person: UserVO) => {
const index = selectedPeople.value.findIndex(p => p.userId === person.userId)
if (index > -1) {
selectedPeople.value.splice(index, 1)
emitChange()
}
}
const handleClearAll = () => {
tempSelectedPeople.value = []
// 清空表格选中状态
nextTick(() => {
if (props.multiple) {
peopleTableRef.value?.clearSelection()
} else {
peopleTableRef.value?.setCurrentRow()
}
})
}
const handleConfirm = () => {
if (!props.multiple && tempSelectedPeople.value.length === 0) {
ElMessage.warning('请选择一个人员')
return
}
selectedPeople.value = [...tempSelectedPeople.value]
emitChange()
dialogVisible.value = false
}
const handleCancel = () => {
// 恢复原来的选择
tempSelectedPeople.value = [...selectedPeople.value]
dialogVisible.value = false
}
// 对外暴露的方法
defineExpose({
open: handleOpen,
clear: () => {
selectedPeople.value = []
emitChange()
}
})
</script>
组件代码(style部分)
<style lang="scss" scoped>
.select-people-container {
width: 100%;
}
.people-input {
:deep(.el-input) {
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
input {
cursor: pointer;
}
}
.text-primary {
color: var(--el-color-primary);
}
}
.people-tags {
.tags-container {
min-height: 40px;
border: 2px solid var(--el-border-color-light);
border-radius: 8px;
padding: 8px 40px 8px 12px;
position: relative;
cursor: pointer;
transition: all 0.3s ease;
background: var(--el-fill-color-blank);
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
&.disabled {
background-color: var(--el-disabled-bg-color);
cursor: not-allowed;
opacity: 0.6;
&:hover {
border-color: var(--el-border-color-light);
box-shadow: none;
}
}
&.has-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.placeholder {
color: var(--el-text-color-placeholder);
font-size: 14px;
display: flex;
align-items: center;
}
.suffix-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--el-text-color-regular);
transition: transform 0.3s ease;
}
&:hover .suffix-icon {
transform: translateY(-50%) rotate(180deg);
}
}
}
:deep(.select-people-dialog-wrapper) {
.el-dialog {
border-radius: 16px;
overflow: hidden;
}
.el-dialog__header {
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: white;
padding: 20px 24px;
margin: 0;
.el-dialog__title {
font-size: 18px;
font-weight: 600;
}
.el-dialog__headerbtn {
.el-dialog__close {
color: white;
font-size: 18px;
&:hover {
color: var(--el-color-primary-light-7);
}
}
}
}
.el-dialog__body {
padding: 24px;
background: var(--el-bg-color-page);
}
.el-dialog__footer {
padding: 20px 24px;
background: var(--el-fill-color-blank);
border-top: 1px solid var(--el-border-color-light);
}
}
.select-people-dialog {
.section-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
height: 520px;
display: flex;
flex-direction: column;
&:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
}
.section-header {
background: linear-gradient(135deg, var(--el-fill-color-light) 0%, var(--el-fill-color-blank) 100%);
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
.header-left {
display: flex;
align-items: center;
}
.header-icon {
font-size: 16px;
color: var(--el-color-primary);
margin-right: 8px;
}
.header-title {
font-weight: 600;
font-size: 15px;
color: var(--el-text-color-primary);
}
.search-input {
width: 220px;
}
.clear-btn {
color: var(--el-color-danger);
font-weight: 500;
&:hover {
background: var(--el-color-danger-light-9);
}
}
}
.search-wrapper {
padding: 16px 20px;
background: var(--el-fill-color-blank);
flex-shrink: 0;
.search-icon {
color: var(--el-text-color-regular);
}
}
.tree-wrapper {
padding: 0 20px 20px;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
:deep(.el-tree) {
background: transparent;
flex: 1;
overflow: auto;
.el-tree-node {
margin-bottom: 2px;
.el-tree-node__content {
height: 36px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: var(--el-color-primary-light-9);
}
&.is-current {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 500;
}
}
}
}
.tree-node {
display: flex;
align-items: center;
width: 100%;
.node-icon {
font-size: 14px;
color: var(--el-color-primary);
margin-right: 6px;
}
.node-label {
font-size: 14px;
color: var(--el-text-color-primary);
}
}
}
.table-wrapper {
padding: 0 20px;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.people-table {
border-radius: 8px;
overflow: hidden;
flex: 1;
min-height: 0;
:deep(.el-table) {
height: 100% !important;
}
:deep(.el-table__body-wrapper) {
overflow: auto;
}
:deep(.el-table__header) {
background: var(--el-fill-color-light);
th {
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
font-weight: 600;
}
}
:deep(.el-table__row) {
transition: all 0.3s ease;
&:hover {
background: var(--el-color-primary-light-9);
}
}
.user-info {
display: flex;
align-items: center;
.user-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
}
.phone-info {
display: flex;
align-items: center;
color: var(--el-text-color-regular);
font-size: 13px;
}
}
}
.pagination-wrapper {
padding: 16px 20px;
display: flex;
justify-content: center;
background: var(--el-fill-color-blank);
border-top: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
.selected-list {
padding: 20px;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
:deep(.el-scrollbar) {
flex: 1;
}
.selected-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.person-info {
display: flex;
align-items: center;
flex: 1;
.person-avatar {
margin-right: 10px;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
.person-details {
.person-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
font-size: 14px;
}
.person-dept {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.remove-btn {
color: var(--el-color-danger);
opacity: 0.7;
transition: all 0.3s ease;
&:hover {
opacity: 1;
background: var(--el-color-danger-light-9);
}
}
}
.empty-selected {
text-align: center;
padding: 60px 20px;
color: var(--el-text-color-placeholder);
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.empty-icon {
font-size: 48px;
color: var(--el-color-info-light-5);
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
margin-bottom: 8px;
color: var(--el-text-color-regular);
}
.empty-desc {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
.footer-info {
display: flex;
align-items: center;
color: var(--el-text-color-regular);
font-size: 14px;
.info-icon {
margin-right: 6px;
color: var(--el-color-primary);
}
}
.footer-actions {
display: flex;
gap: 12px;
.el-button {
min-width: 80px;
border-radius: 6px;
font-weight: 500;
transition: all 0.3s ease;
&:not(.el-button--primary):hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
&.el-button--primary {
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
border: none;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--el-color-primary), 0.4);
}
}
}
}
}
// 响应式设计
@media (max-width: 1200px) {
:deep(.select-people-dialog-wrapper) {
.el-dialog {
width: 90% !important;
}
}
}
// 动画效果
.selected-item {
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// 滚动条美化
:deep(.el-scrollbar__bar) {
&.is-vertical {
.el-scrollbar__thumb {
background: var(--el-color-primary-light-5);
border-radius: 4px;
}
}
}
</style>
父组件调用方法(直接贴图吧):



5744

被折叠的 条评论
为什么被折叠?



