基于vue、ts、element-plus的可编辑table项穿梭框组件

1,440 阅读6分钟

前言

  1. 项目中需要一个可自定义配置的穿梭框
  2. 内容为table,且table项可以编辑
  3. 穿梭框可以整体disabled
  4. 可选普通穿梭框,可选table穿梭框

穿梭框

6905d54927a3183c3c3a0a955e8fddd7.gif

配置项

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,
  },
},

左右移动

  1. 首先遍历已选中的数组,如果sourceArray数组不含有item项,则将sourceArray的此item项删除并添加当前item项到destinationArray数组中。
  2. 如果是移除操作则恢复原数据默认排序,这是就需要初始页面时记录数组的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"
            >移除 &lt</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;
  }