Vue 实现高级穿梭框 Transfer 封装
文章目录
- 01 基础信息
- 1.1. 技术栈
- 1.2. 组件设计
- a. 竖版设计稿
- b. 横版设计稿
- 02 技术方案
- (1)初定义数据
- (2)注意事项
- (3)逻辑草图
- 03 代码示例
- 3.1. 组件使用
- 3.2. 组件源码
- ./TransferPlus/index.vue
- ./TransferPlus/TransferTable.vue
01 基础信息
1.1. 技术栈
Element-UI
、Vue2
、lodash
1.2. 组件设计
需求描述:
- 【待选择列表】 接收业务的表格数据,支持选择多项并将其添加到【已添加列表】 (勾选或删除操作,两边的列表是同步的);
- 【已添加列表】支持本地分页和本地简易搜索功能(已添加的列表数据需要实时同步给业务);
a. 竖版设计稿
b. 横版设计稿
02 技术方案
(1)初定义数据
// 【待选择列表】外部传输源数据
// 【已添加列表】组件内部控制数据的分页、搜索和展示
const props = {
sourceList: [], // 源数据
columnList: [], // 表格列配置(注:字段类型均为字符串)
searchList: [], // 【已添加列表】搜索项(注:与表头对应)
refreshTableData: (param)=>{}, // 回调函数
total: 0, // 用于控制分页器
}
const state = {
targetList: [], // 目标数据
searchList: [], // 【已添加列表】搜索项
}
(2)注意事项
- 【待选择列表】翻页选择时需要记录并回显已选择的行
- 【已添加列表】删除后需要继续留在当前页,即要判断删除的是否是最后一页中只有一条的数据
- 【待选择列表】更改选择后,【已添加列表】的筛选项或是状态项是否重置?或是维持不变?
(3)逻辑草图
03 代码示例
3.1. 组件使用
外部可通过 ref 调用的方法:
clearSelection()
:清空所有选择项;setPaginationParam({pageNum,pageSize},isFetch)
:设置源表格分页器参数,若 isFetch 为 true 则会自动调用 fetchSourceList( isFetch 默认为 false );initializeComponent(isFetch)
:初始化组件,若 isFetch 为 true 则初始化后自动请求源表格数据( isFetch 默认为 false );this.$refs['transferPlus'].selectList
:若要初始化 selectList 可以使用 ref 设置(记得外面包裹 this.$nextTick);注意事项:
- 使用插槽自定义表格列时,是同时应用到两个列表中的;
- 组件会通过
selectionChange
事件告知您选择的列表结果;- 特别地,组件一开始不会默认请求源表格数据,所以您需要在使用前自行调用
fetchSourceList
获取sourceList
等来渲染组件的数据,组件只会在内部的分页状态等有更改的情况下自动调用fetchSourceList
为您刷新渲染数据;- 若
usePagination
为 true,则组件自动为您控制分页器,但您必须设置好变量(sourceTotal
)和源表格数据请求方法(fetchSourceList
),并且为了防止您初始请求的分页参数和组件内部定义的默认初始分页参数不同,您可以设置initSourcePageNum
和initSourcePageSize
来同步内外初始化参数;
<template>
<TransferPlus
ref="transferPlusRef"
:sourceList="sourceList"
:tableColumnList="tableColumnList"
usePagination
tableHeight="240"
:sourceTotal="sourceTotal"
:tableLoading="tableLoading"
@fetchSourceList="fetchSourceList"
>
<!-- "table_"后拼接的是你定义该列的prop -->
<template #table_tag="{ row, rowIndex }">{{ rowIndex + 1 }}{{row.tag}}</template>
<!-- 自定义源表格的搜索区域 -->
<template #source_search>
<el-input placeholder="请输入课程名称" v-model="queryInfo.title" class="search-input" clearable>
<el-button slot="append" icon="el-icon-search" @click="searchSourceList"></el-button>
</el-input>
</template>
</TransferPlus>
</template>
<script>
import TransferPlus from '@/components/TransferPlus'
export default {
components: { TransferPlus },
data() {
sourceList: [],
tableColumnList: [
{ label: '课程id', prop: 'id' },
{ label: '课程名称', prop: 'title' },
{ label: '课程类型', prop: 'tag' },
],
tableLoading: false,
sourceTotal: 0,
queryInfo: {
pageNum: 1,
pageSize: 10,
title: '',
tag: '',
},
}
method:{
async fetchSourceList (params={pageNum,pageSize}) {
this.tableLoading = true
const { pageNum, pageSize } = this.queryInfo
this.queryInfo = {
...this.queryInfo,
pageNum: params?.pageNum || pageNum,
pageSize: params?.pageSize || pageSize,
}
const res = await getList(this.queryInfo)
this.sourceList = res.data.list || []
this.sourceTotal = res.data.total || 0
this.tableLoading = false
},
searchSourceList() {
// 每次查询时只需要重置穿梭框的页码到 1,并配置自动调用搜索函数
this.$refs['transferPlusRef'].setPaginationParam({ pageNum: 1 }, true)
},
}
}
</script>
<style scoped>
.search-input {
margin-bottom: 12px;
width: 100%;
height: 32px;
}
</style>
实现效果图:
3.2. 组件源码
./TransferPlus/index.vue
<!--
组件使用方式如下:
<TransferPlus :sourceList="sourceList" :tableColumnList="tableColumnList" usePagination tableHeight="240" :sourceTotal="sourceTotal" :tableLoading="tableLoading" @fetchSourceList="fetchSourceList" >
<template #table_你定义该列的prop="{ columnProps }">{{ columnProps.$index + 1 }}{{columnProps.row.xxx}}</template>
</TransferPlus>
method:{
async fetchSourceList (params={pageNum,pageSize}) {
this.tableLoading = true
const res = await getList({ ...this.queryInfo, ...params })
this.sourceList = res.data.list
this.sourceTotal = res.data.total
this.tableLoading = false
}
}
外部可通过 ref 调用的方法:
1. clearSelection():清空所有选择项;
2. setPaginationParam({pageNum,pageSize},isFetch):设置源表格分页器参数,若 isFetch 为 true 则会自动调用 fetchSourceList( isFetch 默认为 false );
3. initializeComponent(isFetch):初始化组件,若 isFetch 为 true 则初始化后自动请求源表格数据( isFetch 默认为 false );
4. this.$refs['transferPlusRef'].selectList:若要初始化 selectList 可以使用 ref 设置(记得外面包裹 this.$nextTick);
注意事项:
1. 使用插槽自定义表格列时,是同时应用到两个列表中的;
2. 组件会通过 selectionChange 事件告知您选择的列表结果;
3. 特别地,组件一开始不会默认请求源表格数据,所以您需要在使用前自行调用 fetchSourceList 获取 sourceList 等来渲染组件的数据,组件只会在内部的分页状态等有更改的情况下自动调用 fetchSourceList 为您刷新渲染数据;
4. 若 usePagination 为 true,则组件自动为您控制分页器,但您必须设置好变量(sourceTotal)和源表格数据请求方法(fetchSourceList),并且为了防止您初始请求的分页参数和组件内部定义的默认初始分页参数不同,您可以设置 initSourcePageNum 和 initSourcePageSize 来同步内外初始化参数;
-->
<template>
<div :class="direction === 'horizontal' ? 'transfer-horizontal' : ''">
<!-- 【待选择列表】 -->
<div :class="['list-wrapping', { horizontal: direction === 'horizontal' }]">
<div class="wrapping-header">
<span>待选择列表</span>
<span>{{ selectLength }}/{{ sourceTotal || sourceList.length }}</span>
</div>
<div class="wrapping-content">
<!-- 自定义搜索 -->
<slot name="source_search" />
<TransferTable
ref="sourceTransferTableRef"
v-model="selectList"
:tableList="sourceList"
:tableColumnList="tableColumnList"
:tableHeight="tableHeight"
:total="sourceTotal"
:initPageNum="initSourcePageNum"
:initPageSize="initSourcePageSize"
:usePagination="usePagination"
:tableLoading="tableLoading"
:uniqueKey="uniqueKey"
:selectable="selectable"
:pagerCount="pagerCount"
@fetchTableList="handleFetchTableList"
>
<!-- 使用穿梭表格的自定义列插槽 -->
<template v-for="(item, index) in tableColumnList" :slot="`inner_table_${item.prop}`" slot-scope="slotData">
<span :key="index">
<!-- 设置新的插槽提供给消费端自定义列 -->
<slot :name="`table_${item.prop}`" :columnProps="slotData.columnProps" :row="slotData.columnProps.row" :rowIndex="slotData.columnProps.$index">
{{ slotData.columnProps.row[item.prop] || '-' }}
</slot>
</span>
</template>
</TransferTable>
</div>
</div>
<!-- 【已添加列表】 -->
<div :class="['list-wrapping', { horizontal: direction === 'horizontal' }]">
<div class="wrapping-header">
<span>已添加列表</span>
<span>{{ selectLength }}</span>
</div>
<div class="wrapping-content">
<template v-if="selectLength">
<el-input placeholder="请输入内容" v-model="searchStr" class="search-input" clearable>
<el-select slot="prepend" v-model="searchKey" placeholder="请选择" class="search-select" @change="handleSearchKeyChange" value-key="prop">
<el-option v-for="item in targetSearchList" :key="item.prop" :label="item.label" :value="item.prop"></el-option>
</el-select>
<el-button slot="append" icon="el-icon-search" @click="handleSearchStrChange"></el-button>
</el-input>
<TransferTable
ref="targetTransferTableRef"
:tableList="targetList"
:tableColumnList="tableColumnList"
:tableHeight="tableHeight"
tableType="target"
:uniqueKey="uniqueKey"
:total="targetTotal"
:usePagination="usePagination"
:pagerCount="pagerCount"
@removeSelectRow="handleRemoveSelectRow"
@fetchTableList="getTargetTableList"
>
<!-- 使用穿梭表格的自定义列插槽 -->
<template v-for="(item, index) in tableColumnList" :slot="`inner_table_${item.prop}`" slot-scope="slotData">
<span :key="index">
<!-- 设置新的插槽提供给消费端自定义列 -->
<slot :name="`table_${item.prop}`" :columnProps="slotData.columnProps" :row="slotData.columnProps.row" :rowIndex="slotData.columnProps.$index">
{{ slotData.columnProps.row[item.prop] || '-' }}
</slot>
</span>
</template>
</TransferTable>
</template>
<div class="empty-box" v-else>
<el-image class="empty-image" :src="require('@/assets/empty_images/data_empty.png')" />
</div>
</div>
</div>
</div>
</template>
<script>
import TransferTable from './TransferTable.vue'
import { throttle, differenceBy, filter, isNil, noop } from 'lodash'
export default {
components: { TransferTable },
props: {
// 源数据
sourceList: {
type: Array,
default: () => [],
},
// 表格列配置列表
tableColumnList: {
type: Array,
default: () => [], // {label,prop,align}[]
},
// 表格数据是否加载中
tableLoading: {
type: Boolean,
default: false,
},
// 表格高度
tableHeight: {
type: String | Number,
default: 240,
},
// 【已添加列表】搜索项(注:与表格列配置对应,且仅能搜索字段类型为 String)
searchList: {
type: Array,
default: () => [], // {label,prop}[]
},
// 源表格总数据的条数
sourceTotal: {
type: Number,
default: 0,
},
// 源表格初始 pageNum(用于同步消费端初始化请求时的分页参数,进而帮助控制分页器)
initSourcePageNum: {
type: Number,
default: 1,
},
// 源表格初始 pageSize(用于同步消费端初始化请求时的分页参数,进而帮助控制分页器)
initSourcePageSize: {
type: Number,
default: 10,
},
// 使用分页器
usePagination: {
type: Boolean,
default: false,
},
// 唯一标识符(便于定位到某条数据进行添加和移除操作)
uniqueKey: {
type: String,
default: 'id',
},
// 穿梭框展示方式
direction: {
type: String,
default: 'vertical', // horizontal 左右布局, vertical 上下布局
},
selectable: {
type: Function,
default: noop(),
},
// 页码按钮的数量,当总页数超过该值时会折叠(element规定:大于等于 5 且小于等于 21 的奇数)
pagerCount: {
type: Number,
default: 7,
},
},
data() {
return {
selectList: [], // 已选择的列表
targetList: [], // 已添加列表的回显数据
searchKey: '',
searchStr: '',
targetPageNum: 1,
targetPageSize: 10,
targetTotal: 10,
}
},
computed: {
targetSearchList() {
return this.searchList.length ? this.searchList : this.tableColumnList
},
selectLength() {
return this.selectList?.length || 0
},
},
watch: {
selectList(newVal) {
this.getTargetTableList()
this.$emit('selectionChange', newVal)
},
},
mounted() {
this.searchKey = this.targetSearchList[0].prop
this.targetPageNum = 1
this.targetPageSize = 10
},
methods: {
handleFetchTableList(params) {
this.$emit('fetchSourceList', params)
},
handleRemoveSelectRow(rowItem) {
this.selectList = differenceBy(this.selectList, [rowItem], this.uniqueKey)
},
handleSearchStrChange() {
// 每次查询时只需要重置穿梭框的页码到 1,并配置自动调用搜索函数
this.$refs['targetTransferTableRef'].setPaginationParam({ pageNum: 1 }, true)
},
handleSearchKeyChange() {
// 更新搜索 Key 之后,需要清空搜索字符串
this.searchStr = ''
this.$refs['targetTransferTableRef'].setPaginationParam({ pageNum: 1 }, true)
},
getTargetTableList(params = null) {
const targetTableList = filter(this.selectList, (item) => {
if (this.searchStr) {
const itemValueToString = isNil(item[this.searchKey]) ? '' : JSON.stringify(item[this.searchKey])
return itemValueToString.includes(this.searchStr)
} else {
return true
}
})
this.targetTotal = targetTableList.length
if (params) {
this.targetPageNum = params.pageNum
this.targetPageSize = params.pageSize
}
// 前端分页
const startIndex = (this.targetPageNum - 1) * this.targetPageSize
const endIndex = this.targetPageNum * this.targetPageSize
this.targetList = targetTableList.slice(startIndex, endIndex)
},
clearSelection() {
// 清空所有选择项(用于消费端设置的 ref 调用)
this.selectList = []
this.targetPageNum = 1
this.targetPageSize = 10
this.searchKey = this.targetSearchList[0].prop
},
setPaginationParam({ pageNum, pageSize }, isFetch) {
// 设置源表格分页器参数(用于消费端设置的 ref 调用)
// 若 isFetch 为 true,则自动调用消费端传进来的回调搜索方法
this.$refs['sourceTransferTableRef'].setPaginationParam({ pageNum, pageSize }, isFetch)
},
initializeComponent(isFetch) {
// 初始化组件(用于消费端设置的 ref 调用)
// 若 isFetch 为 true,则自动调用消费端传进来的回调搜索方法
this.clearSelection()
this.setPaginationParam({ pageNum: this.initSourcePageNum || 1, pageSize: this.initSourcePageSize || 10 }, isFetch)
},
},
}
</script>
<style lang="scss" scoped>
.transfer-horizontal {
display: flex;
}
.list-wrapping {
margin-bottom: 12px;
border-radius: 2px;
border: 1px solid #d9d9d9;
background: #fff;
overflow: hidden;
}
.horizontal {
flex: 1;
margin-right: 20px;
margin-bottom: 0px;
&:last-child {
margin: 0px;
}
}
.wrapping-header {
width: 100%;
padding: 10px 20px;
height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #d9d9d9;
background: #f5f5f5;
color: #333;
font-size: 14px;
line-height: 20px;
}
.wrapping-content {
padding: 12px;
width: 100%;
}
.search-input {
margin-bottom: 12px;
max-width: 500px;
height: 32px;
}
.search-select {
width: 120px;
}
.empty-box {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 40px 0px;
}
.empty-image {
width: 150px;
height: 150px;
}
:deep(.search-input .el-input-group__prepend) {
background-color: #fff;
}
:deep(.el-select .el-input .el-select__caret) {
color: #3564ff;
}
</style>
./TransferPlus/TransferTable.vue
<template>
<div>
<!-- 表格区域 -->
<el-table
ref="transferTable"
v-loading="tableLoading"
:border="true"
:data="tableList"
size="mini"
:stripe="true"
:height="tableHeight || 'auto'"
:row-class-name="getTableRowClassName"
:header-cell-style="{
background: '#F1F1F1',
}"
@select="handleSelect"
@select-all="handleSelectAll"
>
<el-table-column type="index" align="center"></el-table-column>
<el-table-column type="selection" width="50" v-if="tableType === 'source'" :selectable="selectable"></el-table-column>
<el-table-column v-for="(item, index) in tableColumnList" :key="item.prop || index" :label="item.label" :prop="item.prop" :align="item.align || 'left'" :width="item.width || 'auto'" show-overflow-tooltip>
<template #default="columnProps">
<slot :name="`inner_table_${item.prop}`" :columnProps="columnProps">
<span>{{ columnProps.row[item.prop] }}</span>
</slot>
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="70" align="center" v-if="tableType === 'target'">
<template slot-scope="scope">
<el-button @click="handleRemoveRowItem(scope.row, scope.$index)" type="text" icon="el-icon-delete" size="medium"></el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器区域 -->
<div v-if="usePagination" class="pagination-box">
<!-- 实现两侧分布的分页器布局:使用两个分页器组件 + 不同 layout 组成 -->
<el-pagination background :current-page="pageNum" :layout="layoutLeft" :page-size="pageSize" :pager-count="pagerCount" :total="total" @current-change="handleCurrentChange" @size-change="handleSizeChange" />
<el-pagination background :current-page="pageNum" :layout="layoutRight" :page-size="pageSize" :pager-count="pagerCount" :total="total" @current-change="handleCurrentChange" @size-change="handleSizeChange" />
</div>
</div>
</template>
<script>
import { differenceBy, uniqBy, noop } from 'lodash'
export default {
props: {
// 已勾选的数组
value: {
type: Array,
default: () => [],
require: true,
},
// 表格数据
tableList: {
type: Array,
default: () => [],
},
// 表格列配置列表
tableColumnList: {
type: Array,
default: () => [], // {label,prop,align}[]
},
// 表格数据是否加载中
tableLoading: {
type: Boolean,
default: false,
},
// 表格高度
tableHeight: {
type: String | Number,
default: 240,
},
// 表格数据类型
tableType: {
type: String,
default: 'source', // source 源列表,target 目标列表
},
// 【已添加列表】搜索项(注:与表格列配置对应,且仅能字段类型为 String)
searchList: {
type: Array,
default: () => [], // {label,prop,align}[]
},
// 分页后表格总数据的条数
total: {
type: Number,
default: 0,
},
// 初始 pageNum
initPageNum: {
type: Number,
default: 1,
},
// 初始 pageSize
initPageSize: {
type: Number,
default: 10,
},
// 使用分页器
usePagination: {
type: Boolean,
default: false,
},
// 唯一标识符(便于定位到某条数据进行添加和移除操作)
uniqueKey: {
type: String,
default: 'id',
},
// Function 的返回值用来决定这一行的 CheckBox 是否可以勾选
selectable: {
type: Function,
default: noop(),
},
// 页码按钮的数量,当总页数超过该值时会折叠(element规定:大于等于 5 且小于等于 21 的奇数)
pagerCount: {
type: Number,
default: 7,
},
},
data() {
return {
layoutLeft: 'total',
layoutRight: 'sizes, prev, pager, next',
pageNum: 1,
pageSize: 10,
preSelectList: [], // 上一次选择的数据(点击分页器就清空)
stashSelectList: [], // 暂存数据,便于点击页码后,还能保存前一页的数据
isNeedToggle: true, // 是否需要勾选该页已选择项(用于换页后的回显选择项)
isTableChangeData: false, // 是否是当前表格造成选择项的变化(用于同步【待选择列表】的勾选项)
}
},
computed: {
currentPageSelectList() {
const currentSelectList = []
this.stashSelectList?.forEach((item) => {
const currentRow = this.tableList?.find((row) => row[this.uniqueKey] === item[this.uniqueKey])
if (currentRow) {
currentSelectList.push(currentRow)
}
})
return currentSelectList
},
},
watch: {
value(newVal) {
this.stashSelectList = newVal || []
// 只有在其他地方修改了选择表格数据后,才刷新覆盖勾选项(当前表格修改选择项是双向绑定的,所以不需要刷新覆盖勾选项),实现精准回显和两表格的联动
if (!this.isTableChangeData) {
this.handleToggleSelection()
}
// 当暂存的选择列表为空时,需要同步更新 preSelect 为空数组,以便下次选择时进行判断是增加选择项还是减少选择项
if (!this.stashSelectList.length) {
this.preSelectList = []
}
this.isTableChangeData = false
},
tableList() {
if (this.isNeedToggle) {
this.preSelectList = this.currentPageSelectList
this.handleToggleSelection()
this.isNeedToggle = false
}
},
},
mounted() {
this.pageNum = this.initPageNum || 1
this.pageSize = this.initPageSize || 110
// 解决右侧固定操作栏错位问题
this.$nextTick(() => {
this.$refs.transferTable.doLayout()
})
this.$emit('selectionChange', [])
},
methods: {
getTableRowClassName({ rowIndex }) {
if (rowIndex % 2 == 0) {
return ''
} else {
return 'stripe-row'
}
},
fetchTableList(pageNum = 1) {
if (this.usePagination) {
// 若不是页码更改触发,则默认将 pageNum 重置为 1
this.pageNum = pageNum
const params = {
pageNum: this.pageNum,
pageSize: this.pageSize,
}
this.$emit('fetchTableList', params)
} else {
this.$emit('fetchTableList')
}
},
setPaginationParam({ pageNum, pageSize }, isFetch = false) {
// 设置分页器参数(用于消费端设置的 ref 调用)
this.pageNum = pageNum || this.pageNum
this.pageSize = pageSize || this.pageSize
this.isNeedToggle = true
if (isFetch) {
this.fetchTableList()
}
},
handleSizeChange(val) {
this.pageSize = val
this.isNeedToggle = true
this.fetchTableList()
},
handleCurrentChange(val) {
this.isNeedToggle = true
this.fetchTableList(val)
},
handleStashSelectList(isAdd = true, list = []) {
if (isAdd) {
// 暂存数组中增加,并兜底去重
this.stashSelectList = uniqBy([...this.stashSelectList, ...list], this.uniqueKey)
} else {
// 暂存数组中移除
this.stashSelectList = differenceBy(this.stashSelectList, list, this.uniqueKey)
}
this.isTableChangeData = true
this.$emit('input', this.stashSelectList)
this.$emit('selectionChange', this.stashSelectList)
},
handleSelect(selectList, row) {
// 判断是否是增加选择项
const isAddRow = this.preSelectList.length < selectList.length
this.handleStashSelectList(isAddRow, [row])
// 更新当前页记录的上次数据
this.preSelectList = [...selectList]
},
handleSelectAll(selectList) {
// 判断是否是全选(需要考虑两个数组长度相等的情况)
const isAddAll = this.preSelectList.length <= selectList.length
// 更新当前页记录的上次数据
this.handleStashSelectList(isAddAll, isAddAll ? selectList : this.preSelectList)
this.preSelectList = [...selectList]
},
handleRemoveRowItem(rowItem, rowIndex) {
const remainderPage = this.total % this.pageSize ? 1 : 0
const pageNumTotal = parseInt(this.total / this.pageSize) + remainderPage
const isLastPageOnlyOne = rowIndex === 0 && this.pageNum === pageNumTotal
// 判断删除的是否是最后一页中只有一条的数据
if (isLastPageOnlyOne && this.pageNum > 1) {
// 若是,则 pageNum 需要往前调整一页,因为删除后最后一页不存在
this.handleCurrentChange(this.pageNum - 1)
}
this.$emit('removeSelectRow', rowItem)
},
handleToggleSelection() {
this.$nextTick(() => {
// 先清除所有勾选状态
this.$refs.transferTable.clearSelection()
if (this.currentPageSelectList.length) {
// 再依次勾选当前页存在的行
this.currentPageSelectList.forEach((item) => {
this.$refs.transferTable.toggleRowSelection(item, true)
})
}
})
},
},
}
</script>
<style scoped>
/* 表格斑马自定义颜色 */
:deep(.el-table__row.stripe-row) {
background: #f9f9f9;
}
/* 表格操作栏按钮取消间距 */
:deep(.el-button) {
padding: 0px;
}
/* 表格操作栏按钮固定大小 */
:deep(.el-icon-delete::before) {
font-size: 14px !important;
}
.pagination-box {
display: flex;
justify-content: space-between;
}
</style>