虚拟下拉树组件封装

80 阅读2分钟
<script setup lang="ts">
import { TreeNode } from "element-plus/es/components/tree-v2/src/types";
import { TreeNodeData } from "element-plus/es/components/tree/src/tree.type";
import { getTreeLabelValue, findParents } from "@/utils/tree";
import { isAllEmpty } from "@pureadmin/utils";

import { onMounted, nextTick, PropType, reactive, ref, watch } from "vue";

interface PropsIter {
  value: string;
  label: string;
  children: string;
  disabled?: string;
}

const TreeProps: PropsIter = {
  value: "id",
  label: "name",
  children: "children"
};

interface TreeIter {
  id: string;
  name: string;
  children?: TreeIter[];
}

const props = defineProps({
  // 组件绑定的options
  options: {
    type: Array as PropType<TreeIter[]>,
    required: true
  },
  // 配置选项
  keyProps: {
    type: Object as PropType<PropsIter>
  },
  // 双向绑定值
  modelValue: {
    type: [String, Number, Array<any>]
  },
  // 组件样式宽
  width: {
    type: String,
    default: ""
  },
  // 空占位字符
  placeholder: {
    type: String,
    default: "请选择"
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false
  },
  // 是否多选
  multiple: {
    type: Boolean,
    default: false
  },
  // 是否筛选
  filterable: {
    type: Boolean,
    default: true
  },
  // 是否清空
  clearable: {
    type: Boolean,
    default: true
  },
  checkStrictly: {
    type: Boolean,
    default: true
  },
  className: {
    type: String,
    default: ""
  }
});

const emits = defineEmits(["update:modelValue", "change"]);

const uniqueClass = "dm-tree-select-v2-" + new Date().getTime();

const select = reactive({
  value: undefined,
  currentNodeKey: "",
  currentNodeLabel: ""
});

const treeSelect = ref<HTMLElement | null>(null);

const treeHeight = ref(26);

function getHeightFirst() {
  let currHeight = props.options.length * 26 || 0;
  let height = currHeight > 240 ? 240 : currHeight < 26 ? 26 : currHeight;
  return height;
}

const nodeClick = (data: TreeNodeData, _node: TreeNode) => {
  if (!props.multiple) {
    select.value = data[props.keyProps.value];
    (treeSelect.value as any).blur();
    emits("update:modelValue", select.value);
    nextTick(() => {
      emits("change", select.value);
    });
  }
};

const hancleCheck = (data: TreeNodeData, info) => {
  select.value = info.checkedNodes.map(item => item[props.keyProps.value]);
  emits("update:modelValue", select.value);
  nextTick(() => {
    emits("change", select.value);
  });
};

const translate = value => {
  let needProps = props.keyProps || TreeProps;
  return getTreeLabelValue(
    props.options,
    { label: needProps.label, value: needProps.value },
    value,
    needProps.children
  );
};

// select 筛选方法 treeV2 refs
const treeV2: any = ref<HTMLElement | null>(null);

const selectFocus = () => {
  const ids = findParents(props.options, props.modelValue).map(
    item => item[props.keyProps.value]
  );
  treeV2.value.setExpandedKeys(ids);
};

const selectFilter = (query: string) => {
  treeV2.value.filter(query);
  initHeight();
  setTimeout(() => {
    // 默认不展开全部
    if (isAllEmpty(query) && isAllEmpty(props.modelValue)) {
      treeV2.value.setExpandedKeys([]);
    } else {
      // 避免影响多选的情况
      if (!Array.isArray(props.modelValue)) {
        selectFocus();
      }
    }
  });
};

// ztree-v2 筛选方法
const treeFilter = (query: string, node: TreeNode) => {
  if (!isAllEmpty(query)) {
    return node[props.keyProps.label]?.indexOf(query) !== -1;
  } else {
    return true;
  }
};

// 直接清空选择数据
const clearSelected = (flag = false) => {
  select.currentNodeKey = "";
  select.currentNodeLabel = "";

  if (!props.multiple) {
    select.value = "";
    treeV2.value.setCurrentKey(select.value);
  } else {
    select.value = [];
    treeV2.value.setCheckedKeys(select.value);
  }
  if (flag) {
    emits("update:modelValue", undefined);
    emits("change", undefined);
  }
};

const removeTag = tagValue => {
  select.value = select.value.filter(item => item !== tagValue);
  emits("update:modelValue", select.value);
  nextTick(() => {
    emits("change", select.value);
  });
};

// setCurrent通过select.value 设置下拉选择tree 显示绑定的v-model值
const setCurrent = () => {
  if (!props.multiple) {
    select.currentNodeKey = select.value;
    treeV2.value.setCurrentKey(select.value);
    select.currentNodeLabel = translate(select.value);
  } else {
    select.currentNodeKey = select.value.join(",");
    treeV2.value.setCheckedKeys(select.value);
    select.currentNodeLabel = select.value
      .map(item => translate(item))
      .join(",");
  }
};

// 动态加载操作列的高度
function initHeight() {
  setTimeout(() => {
    // 初始高度
    let height = 0;
    let Columns = document.querySelector(
      "." + uniqueClass + " .el-vl__window"
    )?.children;
    if (Columns && Columns.length > 0) {
      for (let i = 0; i <= Columns.length - 1; i++) {
        height += (Columns[i] as HTMLElement).offsetHeight;
      }
      treeHeight.value =
        height > 240 ? 240 : height < 26 ? getHeightFirst() : height;
    }
  }, 20);
}

// 监听外部清空数据源 清空组件数据
watch(
  () => props.modelValue,
  v => {
    if (isAllEmpty(v) && select.currentNodeKey !== "") {
      clearSelected();
    }
    // 动态赋值
    if (!isAllEmpty(v)) {
      select.value = v;
      setCurrent();
    }
  }
);

watch(
  () => props.options,
  () => {
    if (!isAllEmpty(props.modelValue)) {
      select.value = props.modelValue;
      setCurrent();
    }
  }
);

onMounted(() => {
  nextTick(() => {
    if (!isAllEmpty(props.modelValue)) {
      select.value = props.modelValue;
      setCurrent();
    }
  });
});
</script>

<template>
  <div
    class="dm-tree-select-v2"
    :style="
      props.width && {
        width: props.width.includes('px') ? props.width : props.width + 'px'
      }
    "
  >
    <el-select
      v-model="select.value"
      :disabled="props.disabled"
      :clearable="props.clearable"
      :filterable="props.filterable"
      :multiple="props.multiple"
      ref="treeSelect"
      popper-class="dm-tree-select-v2__poper"
      :placeholder="props.placeholder"
      :filter-method="selectFilter"
      @clear="clearSelected(true)"
      @remove-tag="removeTag"
      @focus="selectFocus"
    >
      <template #label v-if="!multiple">
        <span>{{ select.currentNodeLabel }} </span>
      </template>
      <template #label="{ value }" v-else>
        <span>{{ translate(value) }}</span>
      </template>
      <el-option
        :value="select.currentNodeKey"
        :label="select.currentNodeLabel"
      >
        <el-tree-v2
          id="tree_v2"
          ref="treeV2"
          :data="props.options"
          :props="props.keyProps || TreeProps"
          :height="treeHeight"
          :current-node-key="select.currentNodeKey"
          :filter-method="treeFilter"
          :show-checkbox="multiple"
          :check-strictly="props.checkStrictly"
          :class="[uniqueClass, props.className]"
          @node-click="nodeClick"
          @check="hancleCheck"
          @node-expand="initHeight"
          @node-collapse="initHeight"
        >
          <template #default="{ node }">
            <slot name="default" :node="node" />
            <slot>
              <span>{{ node.label }}</span>
            </slot>
          </template>
        </el-tree-v2>
      </el-option>
    </el-select>
  </div>
</template>
<style lang="scss">
.dm-tree-select-v2__poper {
  .el-tree-node {
    position: relative !important;
    height: 0 !important;
  }
}
</style>

<style lang="scss" scoped>
.dm-tree-select-v2 {
  width: 214px;
}

.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
  height: auto;
  max-height: 274px;
  padding: 0;
  overflow: hidden;
  overflow-y: auto;
}

ul li :deep(.el-tree .el-tree-node__content) {
  height: auto;
  padding: 0 20px;
  font-weight: normal !important;
}

.el-tree-node__label {
  font-weight: normal;
}

.el-tree :deep(.is-current .el-tree-node__label) {
  color: #409eff;
  font-weight: 700;
}

.el-tree :deep(.is-current .el-tree-node__children .el-tree-node__label) {
  color: #606266;
  font-weight: normal;
}

.el-select {
  width: 100% !important;
}

.el-select-dropdown.is-multiple .el-select-dropdown__item.is-selected:after {
  background-color: transparent;
}
</style>