前言
- 项目中需要一个可自定义配置的穿梭框
- 内容为table,且table项可以编辑
- 穿梭框可以整体disabled
- 可选普通穿梭框,可选table穿梭框
穿梭框
配置项
vue3
const props = defineProps({
modelValue: { // table右侧数据
type: Array,
default: () => []
},
type: { // 穿梭框类型 default:el-transfer; table: table穿梭框
type: String,
default: "default",
},
disabled: { // 穿梭框禁用事件
type: Boolean,
default: false,
}
});
vue2
props: {
// 最后选中的参数
value: {
type: Array,
default: () => [],
},
// 穿梭框模式: default -> 默认穿梭框 table -> 表格穿梭框
type: {
type: String,
default: 'default',
},
// 左侧表格数据
data: {
type: Array,
default: () => [],
},
// 左侧表格项
column: {
type: Array,
default: () => [],
},
// 对齐方式
align: {
type: String,
},
// 斑马纹
stripe: {
type: Boolean,
default: true,
},
// 边框
border: {
type: Boolean,
default: true,
},
// 表格高度
height: {
type: String,
default: '400px',
},
// 禁用判断
disabled: {
type: Boolean,
default: false,
},
// 自定义列表标题
titles: {
type: Array,
default: () => ['列表1', '列表2']
},
// 左侧多选禁用函数{Function}:Boolean true -> 取消禁用 false -> 打开禁用
leftSelectDisabled: {
type: Function,
default: () => true,
},
// 右侧多选禁用函数{Function}:Boolean true -> 取消禁用 false -> 打开禁用
rightSelectDisabled: {
type: Function,
default: () => true,
},
},
左右移动
- 首先遍历已选中的数组,如果
sourceArray数组不含有item项,则将sourceArray的此item项删除并添加当前item项到destinationArray数组中。 - 如果是移除操作则恢复原数据默认排序,这是就需要初始页面时记录数组的index(见下一标题)
/**
* @description: 将选中的元素从源数组中移除,并添加到目标数组中
* @param {String} type add -> 新增 del -> 删除
* @param {Array} sourceArray 原数组
* @param {Array} destinationArray 目标数组
* @param {Array} selectedItems 选中项
* @return {*}
*/
const moveItems = (type:string,sourceArray:any[],destinationArray:any[],selectedItems:any[]) => {
for(let item of selectedItems) {
const index = sourceArray.indexOf(item);
if(index !== -1) {
sourceArray.splice(index, 1);
destinationArray.push(item);
}
}
// 移除时保证默认排序
if(type == "del") {
destinationArray.sort((a,b) => a.index - b.index);
}
// 触发双向绑定,将不需要的参数剔除(index,show)
emits("update:modelValue", destinationArray.map(el => {
const { index, show, ...newObj } = el;
return newObj;
}))
}
// 添加
const add = () => {
if(leftSelect.value.length > 0) {
moveItems("add", leftTable.value, rightTable.value, leftSelect.value);
}
}
// 删除
const del = () => {
if(rightSelect.value.length >0) {
moveItems("del", rightTable.value, leftTable.value, rightSelect.value);
}
}
初始化赋值
通过props传递配置项,此时需要添加默认数据给左右侧table表
vue3
左侧
interface Data {
name?: string
}
const leftTable = ref<any[]>(
props.data.map((el: Data, index: number) => {
return {
...el,
index, // 用于移除后恢复排序
show: false // 用于控制是否编辑
}
})
)
右侧
这一步初始值赋值为了达到双向绑定的结果表现在右侧数据中
const rightTable = ref<any[]>(props.modelValue);
vue2
左侧
computed: {
leftTable() {
let list = [];
if (this.value.length > 0) {
list = this.data.filter(el => this.value.every(item => item.prop_id !== el.prop_id));
} else {
list = this.data;
}
return list.map((el, index) => {
return {
...el,
index,
show: false
};
});
},
leftLoading() {
if (this.leftTable.length > 0 || this.rightTable.length > 0) {
setTimeout(() => {
return false;
}, 300);
} else {
return true;
}
}
}
表格
配置项
表格配置
const props = defineProps({
data: { // 默认左侧表格数据
type: Array,
default: () => [],
},
column: { // 表格配置项
type: Array as PropType<Column[]>,
default: () => [],
},
align: { // 全局表格对齐方式
type: String,
},
stripe: { // 表格斑马纹
type: Boolean,
default: true,
},
border: { // 表格边框
type: Boolean,
default: true,
},
height: { // 表格高度
type: String,
default: "400px",
},
});
表格项配置
prop: "对应列内容的字段值", 必须
label: "对应列内容的字段名", 必须
width: "列宽度",
minWidth: "列最小宽度",
align: "列对齐方式,如果有全局align则用全局,否则用当前配置的align",
leftSlot: {"左侧插槽"
render: "插槽name"
},
rightSlot: {"右侧插槽"
render: "插槽name"
}
父组件用法
穿梭框模板
<template>
<TransferTable
v-model="transfer_data"
type="table"
disabled
:data="tansferData"
:column="columns"
>
...
</TransferTable>
</template>
可配置table项模板
<template>
<TransferTable
v-model="transfer_data"
type="table"
:data="transferData"
:column="columns"
>
<template #name="{ row }">
<div v-if="!row.show" @click="row.show = true" class="pointer">
<p v-if="row.name">
{{ row.name }}
</p>
<el-icon v-else><EditPen /></el-icon>
</div>
<div class="input-box" v-else>
<el-input
v-model="row.name"
placeholder="请输入姓名"
clearable
></el-input>
<el-button
type="primary"
size="small"
@click="row.show = false"
>提交</el-button>
</div>
</template>
<template #leftStatus="{ row }">
<el-switch
v-model="row.status"
:activeValue="1"
:inactiveValue="2"
></el-switch>
</template>
<template #rightStatus="{ row }">
<el-switch
v-model="row.status"
:activeValue="1"
:inactiveValue="2"
disabled
></el-switch>
</template>
</TransferTable>
</template>
配置项中show用来控制是否需要编辑此项,例如需要输入则点击之后,该项变为el-input,这里通过v-if=!row.show和@click="row.show = true"来进行控制,如果当前项等于空时,则用EditPanicon图标来占位。
父组件table项数据
const columns = [
{
label: "姓名",
prop: "name",
leftSlot: {
render: "name",
},
},
{
label: "性别",
prop: "sex",
},
{
label: "状态",
prop: "status",
leftSlot: {
render: "leftStatus",
},
rightSlot: {
render: "rightStatus",
},
},
]
自定义配置插槽设计
首先需要判断是否需要使用自定义插槽v-if=item.rightSlot或者v-if=item.leftSlot
不需要的话则默认展示prop所绑定的数据(el-table默认)。
这里用到了左右两个table,所以需要先锁定位置,一开始想到的方案分两个插槽,分别代表左右table,但因为el-table-column有默认的default的插槽来自定义配置,所以这样设计就会报错,原因是出现了两个插槽。
换用现在这种方式完美解决报错。
<el-table-column
v-for="(item, index) in props.column"
:key="index"
...
>
<template
v-if="item.rightSlot"
#default="{ row, column, $index }"
>
<slot
:name="item.rightSlot.render"
:row="row"
:column="column"
:index="$index"
></slot>
</template>
</el-table-column>
全部代码
<template>
<div class="transfer-table">
<div v-if="props.type === 'default'" class="comp-default">
<el-transfer v-model="rightTable" :data="leftTable"></el-transfer>
</div>
<div v-else-if="props.type === 'table'" class="comp-table" :class="{ disabled: props.disabled }">
<div class="transfer-left">
<div class="transfer-top">
<div>
<span>未选 </span>
<span>{{ `${leftSelect.length} / ${leftTable.length}` }}</span>
</div>
</div>
<div class="transfer-main">
<el-table
:height="props.height"
:data="leftTable"
:stripe="props.stripe"
:border="props.border"
@selection-change="selectLeftChange"
>
<el-table-column
type="selection"
width="55"
:selectable="() => props.disabled === true ? false : true"
/>
<el-table-column
v-for="(item, index) in props.column"
:key="index"
:prop="item.prop"
:label="item.label"
:width="item.width"
:minWidth="item.width"
:align="props.align || item.align"
>
<template
v-if="item.leftSlot"
#default="{ row, column, $index }"
>
<slot
:name="item.leftSlot.render"
:row="row"
:column="column"
:index="$index"
></slot>
</template>
</el-table-column>
</el-table>
</div>
<div class="transfer-bottom">
<span>总条数:{{ leftTable.length }}</span>
</div>
</div>
<div class="transfer-btn">
<div class="btn-add">
<el-button
type="primary"
size="small"
@click="add"
:disabled="leftSelect.length > 0 ? false : true"
>添加 ></el-button
>
</div>
<div class="btn-del">
<el-button
type="primary"
size="small"
@click="del"
:disabled="rightSelect.length > 0 ? false : true"
>移除 <</el-button
>
</div>
</div>
<div class="transfer-right">
<div class="transfer-top">
<div>
<span>已选 </span>
<span>{{ `${rightSelect.length} / ${rightTable.length}` }}</span>
</div>
<div>
<el-button link type="primary" :disabled="props.disabled" @click="clearRight">清除</el-button>
</div>
</div>
<div class="transfer-main">
<el-table
height="400px"
:data="rightTable"
:stripe="props.stripe"
:border="props.border"
@selection-change="selectRightChange"
>
<el-table-column
type="selection"
width="55"
:selectable="() => props.disabled === true ? false : true"
/>
<el-table-column
v-for="(item, index) in props.column"
:key="index"
:prop="item.prop"
:label="item.label"
:width="item.width"
:minWidth="item.width"
:align="props.align || item.align"
>
<template
v-if="item.rightSlot"
#default="{ row, column, $index }"
>
<slot
:name="item.rightSlot.render"
:row="row"
:column="column"
:index="$index"
></slot>
</template>
</el-table-column>
</el-table>
</div>
<div class="transfer-bottom">
<span>总条数:{{ rightTable.length }}</span>
</div>
</div>
</div>
</div>
</template>
setup语法糖
import {
ref,
PropType,
} from "vue";
interface Column {
prop: string;
label: string;
width?: string;
minWidth?: string;
align?: string;
leftSlot?: {
render: string;
};
rightSlot?: {
render: string;
};
}
interface Data {
name?: string;
}
const emits = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
type: {
type: String,
default: "default",
},
data: {
type: Array,
default: () => [],
},
column: {
type: Array as PropType<Column[]>,
default: () => [],
},
align: {
type: String,
},
stripe: {
type: Boolean,
default: true,
},
border: {
type: Boolean,
default: true,
},
height: {
type: String,
default: "400px",
},
disabled: {
type: Boolean,
default: false,
},
});
// 初始值赋值index用来移除恢复默认排序,show用来控制编辑表格项
const leftTable = ref<any[]>(
props.data.map((el: Data, index: number) => {
return {
...el,
index,
show: false,
};
})
);
// 这一步初始值赋值为了达到双向绑定的结果表现在右侧数据中
const rightTable = ref<any[]>(props.modelValue);
const leftSelect = ref<number[]>([]);
const rightSelect = ref<number[]>([]);
// 将选中的元素从源数组中移除,并添加到目标数组中
const moveItems = (
type: string,
sourceArray: any[],
destinationArray: any[],
selectedItems: any[]
) => {
for (let item of selectedItems) {
const index = sourceArray.indexOf(item);
if (index !== -1) {
sourceArray.splice(index, 1);
destinationArray.push(item);
}
}
// 移除时保证默认排序
if (type == "del") {
destinationArray.sort((a, b) => a.index - b.index);
}
// 触发双向绑定
emits(
"update:modelValue",
destinationArray.map((el) => {
const { index, show, ...newObj } = el;
return newObj;
})
);
};
const add = () => {
if (leftSelect.value.length > 0) {
moveItems("add", leftTable.value, rightTable.value, leftSelect.value);
}
};
const del = () => {
if (rightSelect.value.length > 0) {
moveItems("del", rightTable.value, leftTable.value, rightSelect.value);
}
};
const selectLeftChange = (val: any[]) => {
leftSelect.value = val;
};
const selectRightChange = (val: any[]) => {
rightSelect.value = val;
};
// 一键清除右侧table
const clearRight = () => {
for(let i = 0; i <= rightTable.value.length; i++) {
moveItems('del', rightTable.value, leftTable.value, rightTable.value)
}
}
.transfer-container {
display: grid;
grid-auto-flow: column;
grid-template-columns: 650px 70px 450px;
grid-gap: 10px;
.btn {
display: grid;
align-content: center;
grid-gap: 10px;
}
.left-header,
.right-header {
height: 40px;
padding: 0 15px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f5f7fa;
border-top: 1px solid #eee;
border-left: 1px solid #eee;
border-right: 1px solid #eee;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
.top-note {
font-size: 16px;
}
.length-num {
margin-left: 5px;
color: #909399;
}
}
}
.disabled {
opacity: 0.6;
cursor: not-allowed;
}
::v-deep .el-date-editor.el-input,
.el-date-editor.el-input__inner {
width: 200px;
}