zptime / blog

个人博客
0 stars 0 forks source link

基础列表穿梭框 SingleTransfer(造轮子) #2

Open zptime opened 3 years ago

zptime commented 3 years ago
<template>
  <!-- 基础列表穿梭框 SingleTransfer -->
  <div class="m-common-transfer m-single-common-transfer">
    <!-- 源数据展示 -->
    <div class="mct-transfer-list">
      <!-- 头部 -->
      <div class="mct-transfer-list-header">
        <template v-if="showSelectAll">
          <a-checkbox
            :indeterminate="sourceIsIndeterminate"
            v-model="sourceCheckedAll"
            @change="handleSourceSelectedAll"
            class="mct-transfer-list-header-checkbox"
          />
          <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>

      <!-- 主体内容 -->
      <div class="mct-transfer-list-body">
        <!-- 搜索框 -->
        <div class="mct-transfer-list-search">
          <a-input
            v-model="sourceKeyword"
            :placeholder="searchPlaceholder"
            class="mct-transfer-list-search-input"
            @pressEnter="handleSourceSearch"
          />
          <a class="mct-transfer-list-search-action">
            <a-icon
              type="close-circle"
              theme="filled"
              v-if="sourceKeyword && sourceKeyword.length > 0"
              @click="sourceKeyword = ''"
            />
            <a-icon type="search" v-else />
          </a>
        </div>

        <!-- 源列表 -->
        <div class="mct-transfer-list-source">
          <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>
                      <span v-if="showDesc" class="desc">{{ item.desc }}</span>
                    </div>
                  </template>
                  <template v-else>
                    <div class="title">
                      {{ item.title }}
                      <span v-if="showDesc" class="desc">{{ item.desc }}</span>
                    </div>
                  </template>
                </div>
              </a-list-item>
            </RecycleScroller>
          </a-list>
        </div>
      </div>
    </div>

    <!-- 目标数据展示 -->
    <div class="mct-transfer-list">
      <!-- 头部 -->
      <div class="mct-transfer-list-header">
        <template v-if="showSelectAll">
          <div class="mct-transfer-list-header-selected">
            <span v-if="targetCheckedKeys.length"
              >{{ targetCheckedKeys.length }}/{{ targetAllKeys.length }}
              {{ locale.itemUnit }}</span
            >
            <span v-else>{{ targetAllKeys.length }} {{ locale.itemUnit }}</span>
          </div>
        </template>
        <span class="mct-transfer-list-header-title">{{ targetTitle }}</span>
      </div>

      <!-- 主体内容 -->
      <div class="mct-transfer-list-body">
        <!-- 搜索框 -->
        <div class="mct-transfer-list-search">
          <a-input
            v-model="targetKeyword"
            :placeholder="searchPlaceholder"
            class="mct-transfer-list-search-input"
            @pressEnter="handleTargetSearch"
          />
          <a class="mct-transfer-list-search-action">
            <a-icon
              type="close-circle"
              theme="filled"
              v-if="targetKeyword && targetKeyword.length > 0"
              @click="targetKeyword = ''"
            />
            <a-icon type="search" v-else />
          </a>
        </div>

        <!-- 目标列表 -->
        <div class="mct-transfer-list-source">
          <a-list
            :data-source="targetData"
            :locale="{ emptyText: locale.targetEmptyText }"
            class="mct-list"
          >
            <RecycleScroller
              class="mct-list-recycle"
              :items="targetData"
              :item-size="36"
              key-field="key"
            >
              <a-list-item
                slot-scope="{ item, index }"
                :key="`target-${index}`"
                class="mct-list-item"
              >
                <div class="mct-list-item-title">
                  <template
                    v-if="targetKeyword && item.title.includes(targetKeyword)"
                  >
                    <div class="title title-other">
                      <span>{{
                        item.title.substr(0, item.title.indexOf(targetKeyword))
                      }}</span>
                      <span class="active">{{ targetKeyword }}</span>
                      <span>{{
                        item.title.substr(
                          item.title.indexOf(targetKeyword) +
                            targetKeyword.length
                        )
                      }}</span>
                      <span v-if="showDesc" class="desc">{{ item.desc }}</span>
                    </div>
                  </template>
                  <template v-else>
                    <div class="title">
                      {{ item.title }}
                      <span v-if="showDesc" class="desc">{{ item.desc }}</span>
                    </div>
                  </template>
                </div>
                <div
                  class="mct-list-item-del"
                  @click="handleTargetChange(index, item.key)"
                >
                  <img src="https://github.com/zptime/resources/blob/master/images/transfer/ic_close.svg" />
                </div>
              </a-list-item>
            </RecycleScroller>
          </a-list>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import * as R from "ramda";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";

// // 数据源数据示例
// const dataSource = [
//   { key: 1, title: "选项1", desc: "技术部" },
//   { key: 1, title: "选项1", desc: "技术部" },
// ];
// const targetkeys = [1];

export default {
  name: "MeSingleTransfer",
  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: false,
    },
    // 是否展示描述信息
    showDesc: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      sourceTargetKeys: [], // 显示在右侧框数据的 key 集合

      // 源列表
      // sourceData: [], // 数据源
      sourceAllKeys: [], // 全部key
      sourceCheckedKeys: [], // 选中key
      sourceCheckedAll: false, // 是否全选
      sourceIsIndeterminate: false, // 是否半选
      sourceKeyword: "", // 筛选关键字

      // 目标列表
      // targetData: [],
      targetAllKeys: [], // 全部key
      targetCheckedKeys: [], // 选中key
      targetKeyword: "", // 筛选关键字
    };
  },
  components: {
    RecycleScroller,
  },
  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;
    },
    // 源数据列表
    targetData() {
      let result = R.filter(
        (o) => R.includes(o.key, this.sourceTargetKeys),
        this.targetKeyword
          ? R.filter(
              (r) => R.includes(this.targetKeyword, r.title),
              R.clone(this.dataSource)
            )
          : R.clone(this.dataSource)
      );
      this.targetAllKeys = this.getAllKeys(result);
      return result;
    },
    // 源数据菜单名
    sourceTitle() {
      let [text] = this.titles;
      return text;
    },
    // 目标数据菜单名
    targetTitle() {
      let [, text] = this.titles;
      return text;
    },
  },
  watch: {
    // 选中到右侧的数据监听
    targetKeys: {
      handler: function(val) {
        this.sourceTargetKeys = val || [];
      },
      deep: true,
      immediate: true,
    },
    // 左侧 状态监测
    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: {
    // 源数据select事件
    handleSourceSelect(e, index, key) {
      let checked = e.target.checked;
      if (checked) {
        this.sourceTargetKeys = R.append(key, this.sourceTargetKeys);
      } else {
        this.sourceTargetKeys = R.filter(
          (o) => o !== key,
          this.sourceTargetKeys
        );
      }
      this.$emit("on-change", this.sourceTargetKeys, this.targetData);
    },
    // 目标数据change事件:删除
    handleTargetChange(index, key) {
      this.sourceTargetKeys = R.filter((o) => o !== key, this.sourceTargetKeys);
      this.$emit("on-change", this.sourceTargetKeys, this.targetData);
    },
    // 源数据onSearch事件
    handleSourceSearch(e) {},
    // 目标数据onSearch事件
    handleTargetSearch(e) {},
    // 源数据全选事件
    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)
        );
      }
    },
    // 获取数据源key
    getAllKeys(source) {
      if (!(source && source.length)) {
        return [];
      }
      return R.map((o) => o.key, source);
    },
  },
};
</script>

<style lang="scss">
$px: 1px;
.m-single-common-transfer {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  color: #333;
  font-size: 14px;
  line-height: 1;
  list-style: none;
  position: relative;
  display: flex;
  width: 526px;
  justify-content: space-between;

  .mct-transfer-list {
    vertical-align: middle;
    border: 1px solid #ebebeb;
    border-radius: 2px;
    width: 254px;
    height: 400px;
    overflow: hidden;

    // 头部标题
    &-header {
      width: 100%;
      height: 40 * $px;
      padding: 0 12px;
      color: #333333;
      background: #f7f9fa;
      border-radius: 2px 2px 0 0;
      display: flex;
      align-items: center;
      &-checkbox {
        margin-right: 8px;
      }
      &-selected {
        flex: 1;
        display: flex;
        align-items: center;
      }
      &-title {
        color: #808080;
      }
    }

    // 内容区
    &-body {
      position: relative;
      height: 100%;
      .ant-list-empty-text {
        margin-top: 100px;
        color: #c3c3c3;
      }
    }

    // 搜索框
    &-search {
      width: 100%;
      position: relative;
      padding-bottom: 0;
      padding: 12 * $px 12 * $px 0;

      &-input {
        padding: 0 28px 0 12px;
      }
      &-action {
        position: absolute;
        top: 12px;
        right: 12px;
        bottom: 12px;
        width: 40px;
        color: rgba(0, 0, 0, 0.25);
        line-height: 32px;
        text-align: center;
      }
    }

    // 数据列表
    &-source {
      margin: 12px 0;
      padding-left: 12px;
      height: 300px;
      overflow-y: auto;
      overflow-x: hidden;
    }
    // 目标数据列表
    .mct-list {
      &-recycle {
        height: 300px;
        padding-right: 12px;
      }
      &-item {
        position: relative;
        display: flex;
        align-items: center;
        padding: 8px 0;
        border-bottom: 0;
        &-checkbox {
          margin-right: 8px;
        }
        &-title {
          display: flex;
          flex: 1;
          min-width: 0;
          overflow: hidden;
          .title {
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
            &-other {
              font-size: 0;
              > span {
                font-size: 14px;
              }
            }
            .desc {
              font-size: 12px;
              color: #c3c3c3;
              margin-left: 5px;
            }
          }
          &.disabled,
          .disabled {
            color: #c3c3c3;
            opacity: 1;
          }
          .active {
            color: #ffad33;
          }
        }
        &-del {
          cursor: pointer;
          width: 12px;
          text-align: right;
          height: 20px;
          > img {
            width: 6px;
            height: 6px;
            position: relative;
            top: -2px;
          }
        }
      }
    }
  }

  input[disabled="disabled"] {
    opacity: 0 !important;
  }
}
</style>