Ant Design Transfer 穿梭框组件“造轮子” - zptime/blog GitHub Wiki

人的思想是千变万化的,不可能一套组件满足所有的需求,前端开发总避免不了要“造轮子”的。这次做的穿梭框就是模仿官方来写的,功能基本一样,但不可能完全一样,会在官方的基础上优化,样式也更好看了哟。

基础穿梭框

官方基础的穿梭框如下,左右两边都是列表形式,还有搜索框。

具体使用可以查看官方文档,带搜索框的穿梭框

效果

重写基础穿梭框

重写穿梭框的原因:

  1. 样式肯定是不满足 UI 要求的
  2. 功能麻烦了一点,操作完数据之后总要点一下“左移”或者“右移”才能完成
  3. 当数据很多时,组件渲染会很卡顿,反正我公司的上千条数据都会有点卡了

主要实现功能点:

  1. 头部全选
  2. 搜索功能
  3. 搜索关键词高亮显示
  4. 自动穿梭功能
  5. 虚拟滚动列表实现

实现效果

组件参数和事件

自定义参数:考虑对外暴露的参数,参数的作用,属性等 自定义事件:考虑暴露出去的回调事件

// 自定义参数
export default {
  props: {
    dataSource: {
      // 数据源
      type: Array,
      default: () => [],
    },
    targetKeys: {
      // 右侧框数据的 key 集合
      type: Array,
      default: () => [],
    },
    disabledKeys: {
      // 禁用 key 集合
      type: Array,
      default: () => [],
    },
    titles: {
      // 标题
      type: Array,
      default: () => ["可选", "已选"],
    },
    locale: {
      // 语言配置
      type: Object,
      default: () => {
        return {
          itemUnit: "项",
          sourceEmptyText: "暂无可选数据",
          targetEmptyText: "暂无已选数据",
        };
      },
    },
    searchPlaceholder: {
      type: String,
      default: "请输入搜索关键字",
    },
    showSelectAll: {
      // 是否展示全选勾选框
      type: Boolean,
      default: true,
    },
  },
};

// 自定义事件
// onChange事件:返回显示在目标框数据的 key 集合和 数据列表
this.$emit("on-change", this.sourceTargetKeys, this.targetData);

组件整体架构

整体结构是源数据框+目标数据框,而每个数据框由头部、搜索框、数据源列表三部分组成

<template>
  <!-- 基础穿梭框 -->
  <div class="m-single-common-transfer">
    <!-- 源数据展示 -->
    <div class="mct-transfer-list">
      <!-- 头部 -->
      <div class="mct-transfer-list-header"></div>
      <!-- 主体内容 -->
      <div class="mct-transfer-list-body">
        <!-- 搜索框 -->
        <div class="mct-transfer-list-search"></div>
        <!-- 源列表 -->
        <div class="mct-transfer-list-source"></div>
      </div>
    </div>

    <!-- 目标数据展示 -->
    <div class="mct-transfer-list">
      <!-- 头部 -->
      <div class="mct-transfer-list-header"></div>
      <!-- 主体内容 -->
      <div class="mct-transfer-list-body">
        <!-- 搜索框 -->
        <div class="mct-transfer-list-search"></div>
        <!-- 目标列表 -->
        <div class="mct-transfer-list-source"></div>
      </div>
    </div>
  </div>
</template>

头部(全选框,计数项,标题)

头部主要分为 3 个部分,如下以源数据框为例,目标数据框原理一样:

  1. 全选框:可进行全选和全不选的切换事件(handleSourceSelectedAll),可表示全选、部分选、全不选(sourceIsIndeterminate, sourceCheckedAll)三种状态
  2. 计数项:可以统计数据源所有项和已选项个数(sourceCheckedKeys, sourceAllKeys)
  3. 标题:可自定义展示内容(sourceTitle)
<template>
  <div class="mct-transfer-list-header">
    <!-- showSelectAll:是否展示全选勾选框和计数项 -->
    <template v-if="showSelectAll">
      <!--  sourceCheckedAll:是否全选; sourceIsIndeterminate:是否半选 -->
      <a-checkbox
        :indeterminate="sourceIsIndeterminate"
        v-model="sourceCheckedAll"
        @change="handleSourceSelectedAll"
        class="mct-transfer-list-header-checkbox"
      />
      <!-- sourceAllKeys:全部key;sourceCheckedKeys:选中key -->
      <div class="mct-transfer-list-header-selected">
        <span v-if="sourceCheckedKeys.length"
          >{{ sourceCheckedKeys.length }}/{{ sourceAllKeys.length }} {{
          locale.itemUnit }}</span
        >
        <span v-else>{{ sourceAllKeys.length }} {{ locale.itemUnit }}</span>
      </div>
    </template>
    <div class="mct-transfer-list-header-title">{{ sourceTitle }}</div>
  </div>
</template>

<script>
  import * as R from "ramda";
  export default {
    computed: {
      // 源数据菜单名
      sourceTitle() {
        let [text] = this.titles;
        return text;
      },
      // 目标数据菜单名
      targetTitle() {
        let [, text] = this.titles;
        return text;
      },
    },
    watch: {
      // 左侧 状态监测
      sourceCheckedKeys(val) {
        if (val && val.length > 0) {
          // 总半选是否开启
          this.sourceIsIndeterminate = true;

          // 总全选是否开启 - 根据选中节点的数量是否和源数据长度相等
          let allKeys = this.getAllKeys(this.sourceData);
          let allCheck = R.filter((o) => R.includes(o.key, allKeys), val);
          if (R.length(allKeys) === R.length(allCheck)) {
            // 关闭半选 开启全选
            this.sourceIsIndeterminate = false;
            this.sourceCheckedAll = true;
          } else {
            // 开启半选
            this.sourceIsIndeterminate = true;
            this.sourceCheckedAll = false;
          }
        } else {
          // 未选
          this.sourceIsIndeterminate = false;
          this.sourceCheckedAll = false;
        }
      },
    },
    methods: {
      // 源数据全选事件
      handleSourceSelectedAll(e) {
        if (this.sourceData.length === 0) {
          return;
        }
        if (e.target.checked) {
          this.sourceTargetKeys = R.uniq(
            R.concat(this.sourceTargetKeys, this.getAllKeys(this.sourceData))
          );
        } else {
          this.sourceTargetKeys = R.difference(
            this.sourceTargetKeys,
            this.getAllKeys(this.sourceData)
          );
        }
      },
    },
  };
</script>

搜索框

搜索框其实就挺简单了,主要是一个图标的变化。空值时是放大镜图标;有值时时删除图标,可以清空搜索框值

<div class="mct-transfer-list-search">
  <a-input
    v-model="sourceKeyword"
    :placeholder="searchPlaceholder"
    class="mct-transfer-list-search-input"
    @pressEnter="handleSourceSearch"
  >
    <a-icon
      slot="suffix"
      type="close-circle"
      theme="filled"
      v-if="sourceKeyword && sourceKeyword.length > 0"
      @click="sourceKeyword = ''"
    />
    <a-icon v-else slot="suffix" type="search" />
  </a-input>
</div>

数据列表实现

源数据列表和目标数据列表有相同点,也有不同点:

  1. 相同点:
  • 都采用了虚拟列表:RecycleScroller
  • 搜索关键词高亮显示:substr 截取标题显示
  • 数据源过滤:数据源根据关键词sourceKeyword过滤
  1. 不同点:
  • 源数据项包括选择框和标题
  • 源数据禁用处理:disabledKeyssourceTargetKeys
  • 目标数据显示标题和删除按钮
<template>
  <a-list
    :data-source="sourceData"
    :locale="{ emptyText: locale.sourceEmptyText }"
    class="mct-list"
  >
    <RecycleScroller
      class="mct-list-recycle"
      :items="sourceData"
      :item-size="36"
      key-field="key"
    >
      <a-list-item
        slot-scope="{ item, index }"
        :key="`source-${index}`"
        class="mct-list-item"
      >
        <a-checkbox
          :checked="item.checked"
          :disabled="item.disabled"
          class="mct-list-item-checkbox"
          @change="(e) => handleSourceSelect(e, index, item.key)"
        ></a-checkbox>
        <div :class="['mct-list-item-title', { disabled: item.disabled }]">
          <!-- 搜索关键词高亮显示 -->
          <template v-if="sourceKeyword && item.title.includes(sourceKeyword)">
            <div class="title title-other">
              <span
                >{{ item.title.substr(0, item.title.indexOf(sourceKeyword))
                }}</span
              >
              <span class="active">{{ sourceKeyword }}</span>
              <span
                >{{ item.title.substr( item.title.indexOf(sourceKeyword) +
                sourceKeyword.length ) }}</span
              >
            </div>
          </template>
        </div>
      </a-list-item>
    </RecycleScroller>
  </a-list>
</template>

<script>
  // 虚拟数据列表 RecycleScroller
  import { RecycleScroller } from "vue-virtual-scroller";
  import "vue-virtual-scroller/dist/vue-virtual-scroller.css";

  computed: {
    // 源数据列表
    sourceData() {
      let filterKeys =
        this.disabledKeys && this.disabledKeys.length
          ? R.concat(this.disabledKeys, this.sourceTargetKeys)
          : this.sourceTargetKeys;
      // 禁用数据处理
      let result = R.map(
        (o) => {
          if (R.includes(o.key, filterKeys)) {
            o.disabled = true;
            o.checked = true;
            o.title = `${o.title}(已配置)`;
          } else {
            o.disabled = false;
            o.checked = false;
          }
          return o;
        },
        // 数据源过滤
        this.sourceKeyword
          ? R.filter(
              (r) => R.includes(this.sourceKeyword, r.title),
              R.clone(this.dataSource)
            )
          : R.clone(this.dataSource)
      );
      // 全选,已选处理
      this.sourceAllKeys = this.getAllKeys(result);
      this.sourceCheckedKeys = R.filter(
        (o) => R.includes(o, this.sourceAllKeys),
        this.sourceTargetKeys
      );
      return result;
    }
  }
</script>

组件使用

<template>
  <single-transfer
    :data-source="singleDataSource"
    :target-keys="singleTargetKeys"
    @on-change="handleSingleChange"
  />
</template>

<script>
  import * as R from "ramda";
  import SingleTransfer from "@cms/component/transfer/SingleTransfer.vue";

  const mapIndexed = R.addIndex(R.map);
  const singleDataSource = mapIndexed((o, i) => {
    return {
      key: i + 1,
      title:
        i === 0 ? `选项哈哈哈哈哈哈哈哈哈哈哈哈哈嘿嘿` : `${o.title}${i + 1}`,
    };
  }, R.repeat({ key: 1, title: "选项" }, 20));

  export default {
    data() {
      return {
        singleDataSource,
        singleTargetKeys: [1, 3],
      };
    },
    components: {
      SingleTransfer,
    },
    methods: {
      handleSingleChange(targetKeys, data) {
        console.log("singleTransfer", targetKeys, data);
      },
    },
  };
</script>

<style lang="scss"></style>

最终效果

⚠️ **GitHub.com Fallback** ⚠️