# 相关链接

# vue常用模板

点击查看代码
<template>

</template>

<script>
export default {
    components: {},
    props: {},
    data() {
        return {};
    },
    computed: {},
    created() {},
    mounted() {},
    methods: {},
    watch: {},
};
</script>

<style scoped>

</style>

或者在你的vscode中添加下面的代码片段:

点击查看代码
{
	"Print to console": {
		"prefix": "vue",
		"body": [
			"<template>\n",
			"</template>\n",
			"<script>",
			"export default {",
			"    components: {},",
			"    props: {},",
			"    data() {",
			"        return {};",
			"    },",
			"    computed: {},",
			"    created() {},",
			"    mounted() {},",
			"    methods: {},",
			"    watch: {},",
			"};",
			"</script>\n",
			"<style scoped>\n",
			"</style>\n",
		],
		"description": "vue模板"
	}
}

# 水波纹指令

可能你还想看看这个: https://www.30secondsofcode.org/react/s/ripple-button/ (opens new window)

点击查看代码
/* @/directive/waves/index.js */
import './waves.css'

const context = '@@wavesContext'

function handleClick(el, binding) {
  function handle(e) {
    const customOpts = Object.assign({}, binding.value)
    const opts = Object.assign({
      ele: el, // 波纹作用元素
      type: 'hit', // hit 点击位置扩散 center中心点扩展
      color: 'rgba(0, 0, 0, 0.15)' // 波纹颜色
    },
    customOpts
    )
    const target = opts.ele
    if (target) {
      target.style.position = 'relative'
      target.style.overflow = 'hidden'
      const rect = target.getBoundingClientRect()
      let ripple = target.querySelector('.waves-ripple')
      if (!ripple) {
        ripple = document.createElement('span')
        ripple.className = 'waves-ripple'
        ripple.style.height = ripple.style.width = Math.max(rect.width, rect.height) + 'px'
        target.appendChild(ripple)
      } else {
        ripple.className = 'waves-ripple'
      }
      switch (opts.type) {
        case 'center':
          ripple.style.top = rect.height / 2 - ripple.offsetHeight / 2 + 'px'
          ripple.style.left = rect.width / 2 - ripple.offsetWidth / 2 + 'px'
          break
        default:
          ripple.style.top =
            (e.pageY - rect.top - ripple.offsetHeight / 2 - document.documentElement.scrollTop ||
              document.body.scrollTop) + 'px'
          ripple.style.left =
            (e.pageX - rect.left - ripple.offsetWidth / 2 - document.documentElement.scrollLeft ||
              document.body.scrollLeft) + 'px'
      }
      ripple.style.backgroundColor = opts.color
      ripple.className = 'waves-ripple z-active'
      return false
    }
  }

  if (!el[context]) {
    el[context] = {
      removeHandle: handle
    }
  } else {
    el[context].removeHandle = handle
  }

  return handle
}

export default {
  bind(el, binding) {
    el.addEventListener('click', handleClick(el, binding), false)
  },
  update(el, binding) {
    el.removeEventListener('click', el[context].removeHandle, false)
    el.addEventListener('click', handleClick(el, binding), false)
  },
  unbind(el) {
    el.removeEventListener('click', el[context].removeHandle, false)
    el[context] = null
    delete el[context]
  }
}
点击查看代码
/* @/directive/waves/waves.css */
.waves-ripple {
    position: absolute;
    border-radius: 100%;
    background-color: rgba(0, 0, 0, 0.15);
    background-clip: padding-box;
    pointer-events: none;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
    -webkit-transform: scale(0);
    -ms-transform: scale(0);
    transform: scale(0);
    opacity: 1;
}

.waves-ripple.z-active {
    opacity: 0;
    -webkit-transform: scale(2);
    -ms-transform: scale(2);
    transform: scale(2);
    -webkit-transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
    transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
    transition: opacity 1.2s ease-out, transform 0.6s ease-out;
    transition: opacity 1.2s ease-out, transform 0.6s ease-out, -webkit-transform 0.6s ease-out;
}

全局注册

import Vue from "vue"

import waves from '@/directive/waves/index.js'
Vue.directive('waves', waves)

在组件中局部注册

import waves from "@/directive/waves/index.js";

directives: {
  waves:waves
}

# 让数字动起来

点击查看代码
<template>
  <span></span>
</template>

<script>
import { CountUp } from "countup.js";// npm i countup.js
export default {
  data() {
    return {
      numAnim: null
    };
  },
  props: {
    endVal: {
      type: Number,
      default: 2020
    },
    /**
     * startVal?: number; // number to start at (0)
     * decimalPlaces?: number; // number of decimal places (0)
     * duration?: number; // animation duration in seconds (2)
     * useGrouping?: boolean; // example: 1,000 vs 1000 (true)
     * useEasing?: boolean; // ease animation (true)
     * smartEasingThreshold?: number; // smooth easing for large numbers above this if useEasing (999)
     * smartEasingAmount?: number; // amount to be eased for numbers above threshold (333)
     * separator?: string; // grouping separator (',')
     * decimal?: string; // decimal ('.')
     * easingFn?: (t: number, b: number, c: number, d: number) => number;
     * formattingFn?: (n: number) => string; // this function formats result
     * prefix?: string; // text prepended to result
     * suffix?: string; // text appended to result
     * numerals?: string[]; // numeral glyph substitution
     */
    options: {
      type: Object
    }
  },
  mounted() {
    this.initCountUp();
  },
  methods: {
    initCountUp() {
      this.numAnim = new CountUp(this.$el, this.endVal, this.options);
      this.numAnim.start();
    }
  },
  watch: {
    endVal(value) {
      this.numAnim.update(value);
    }
  }
};
</script>

# Sticky 滚动吸顶

代码来自这里 (opens new window)

点击查看代码
<template>
  <div :style="{height:height+'px',zIndex:zIndex}">
    <div
      :class="className"
      :style="{top:(isSticky ? stickyTop +'px' : ''),zIndex:zIndex,position:position,width:width,height:height+'px'}"
    >
      <slot>
        <div>sticky</div>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Sticky',
  props: {
    stickyTop: {
      type: Number,
      default: 0
    },
    zIndex: {
      type: Number,
      default: 1
    },
    className: {
      type: String,
      default: ''
    }
  },
  data() {
    return {
      active: false,
      position: '',
      width: undefined,
      height: undefined,
      isSticky: false
    }
  },
  mounted() {
    this.height = this.$el.getBoundingClientRect().height
    window.addEventListener('scroll', this.handleScroll)
    window.addEventListener('resize', this.handleResize)
  },
  activated() {
    this.handleScroll()
  },
  destroyed() {
    window.removeEventListener('scroll', this.handleScroll)
    window.removeEventListener('resize', this.handleResize)
  },
  methods: {
    sticky() {
      if (this.active) {
        return
      }
      this.position = 'fixed'
      this.active = true
      this.width = this.width + 'px'
      this.isSticky = true
    },
    handleReset() {
      if (!this.active) {
        return
      }
      this.reset()
    },
    reset() {
      this.position = ''
      this.width = 'auto'
      this.active = false
      this.isSticky = false
    },
    handleScroll() {
      const width = this.$el.getBoundingClientRect().width
      this.width = width || 'auto'
      const offsetTop = this.$el.getBoundingClientRect().top
      if (offsetTop < this.stickyTop) {
        this.sticky()
        return
      }
      this.handleReset()
    },
    handleResize() {
      if (this.isSticky) {
        this.width = this.$el.getBoundingClientRect().width + 'px'
      }
    }
  }
}
</script>

# Backtop 回到顶部

复制的饿了么 Backtop (opens new window) 组件,用的时候自己写样式和动画

点击查看代码
<template>
  <div
    v-if="visible"
    @click.stop="handleClick"
    :style="{
      right: styleRight,
      bottom: styleBottom,
    }"
    class="backtop"
  >
    <slot></slot>
  </div>
</template>

<script>
import { throttle } from "throttle-debounce";//npm i throttle-debounce

export default {
  props: {
    visibilityHeight: {
      type: Number,
      default: 200,
    },
    target: [String],
    right: {
      type: Number,
      default: 40,
    },
    bottom: {
      type: Number,
      default: 40,
    },
  },

  data() {
    return {
      el: null,
      container: null,
      visible: false,
    };
  },

  computed: {
    styleBottom() {
      return `${this.bottom}px`;
    },
    styleRight() {
      return `${this.right}px`;
    },
  },

  mounted() {
    this.init();
    this.throttledScrollHandler = throttle(300, this.onScroll);
    this.container.addEventListener("scroll", this.throttledScrollHandler);
  },

  methods: {
    init() {
      this.container = document;
      this.el = document.documentElement;
      if (this.target) {
        this.el = document.querySelector(this.target);
        if (!this.el) {
          throw new Error(`target is not existed: ${this.target}`);
        }
        this.container = this.el;
      }
    },
    onScroll() {
      const scrollTop = this.el.scrollTop;
      this.visible = scrollTop >= this.visibilityHeight;
    },
    handleClick(e) {
      this.scrollToTop();
      this.$emit("click", e);
    },
    scrollToTop() {
      let el = this.el;
      let step = 0;
      let interval = setInterval(() => {
        if (el.scrollTop <= 0) {
          clearInterval(interval);
          return;
        }
        step += 15;
        el.scrollTop -= step;
      }, 20);
    },
  },

  beforeDestroy() {
    this.container.removeEventListener("scroll", this.throttledScrollHandler);
  },
};
</script>

# 让 model 值传递多次

点击查看代码
<template>
  <div>
    <input v-model="code"/>
  </div>
</template>

<script>
export default {
    props: {
      value: {
        type: String,
        required: true
      }
    },
    computed: {
      code: {
        get() {
          return this.value;
        },
        set(val) {
          this.$emit("input", val);
        }
      }
    },
};
</script>

# 饿了么上传组件修改为上传base64

只处理了转成base64的功能

点击查看代码
<template>
  <el-upload
    class="avatar-uploader"
    action="#"
    :show-file-list="false"
    :auto-upload="false"
    :on-change="onChange"
  >
    <img v-if="value" :src="value" class="avatar" />
    <i v-else class="el-icon-plus avatar-uploader-icon"></i>
  </el-upload>
</template>

<script>
export default {
  props: {
    value: {
      type: String,
      required: true
    }
  },
  methods: {
    onChange() {
      var _this = this;
      var event = event || window.event;
      var _file = event.target.files[0];
      var reader = new FileReader();
      //转base64
      reader.onload = function(e){
        _this.$emit("input", e.target.result);
      };
      reader.readAsDataURL(_file);
    }
  }
};
</script>

<style scoped>
.avatar-uploader >>> .el-upload {
  position: relative;
  overflow: hidden;
  border: 1px dashed #d9d9d9;
  border-radius: 6px;
  cursor: pointer;
}
.avatar-uploader >>> .el-upload:hover {
  border-color: #409eff;
}
.avatar-uploader-icon {
  width: 88px;
  height: 88px;
  line-height: 88px;
  text-align: center;
  font-size: 28px;
  color: #8c939d;
}
.avatar {
  display: block;
  width: 88px;
  height: 88px;
}
</style>

# 防止异步按钮被疯狂点击

TIP

类似这个功能的实现方式应该有很多种,目前想到的有,后端对接口做限制,前端可以使用防抖函数,或者自己写个定时器控制loading状态。 但我认为最合理的应该还是在异步回调中再恢复loading的状态。

点击查看代码
<template>
  <el-button
    v-bind="$attrs"
    v-on="$listeners"
    :loading="loading"
    @click="event"
  >
    <slot></slot>
  </el-button>
</template>

<script>
import "zone.js"; // npm i zone.js

export default {
  props: {
    click: {
      type: Function,
      default: () => {},
    },
  },
  data() {
    return {
      loading: false,
    };
  },
  methods: {
    event() {
      let self = this;
      /**
       * loading 改变时会触发dom的更新
       * 将它放在 ZoneDom 下修改而不监听它
       * 否则会出现死循环
       * */
      let ZoneDom = window.Zone.current.fork({});
      window.Zone.current
        .fork({
          // 当有异步操作时触发
          onScheduleTask(delegate, currentZone, targetZone, task) {
            ZoneDom.run(() => {
              self.loading = true;
            });
            return delegate.scheduleTask(targetZone, task);
          },
          onHasTask(delegate, current, target, hasTaskState) {
            if (!hasTaskState.macroTask && !hasTaskState.microTask) {
              ZoneDom.run(() => {
                self.loading = false;
              });
            }
          },
        })
        .run(() => {
          if (!self.loading) {
            self.click();
          }
        });
    },
  },
};
</script>

# 瀑布流组件

点击查看代码
<template>
  <div>
    <div class="container" :style="{ height: containerHeight + 'px' }">
      <div
        v-for="i in item"
        class="item"
        :key="i[itemkey]"
        :style="{
          width: colsWidth + 'px',
          height: i.imgHeight + 'px',
          left: i.left + 'px',
          top: i.top + 'px',
        }"
      >
        <img :src="i[srcKey]" />
      </div>
    </div>
    <myLoading :show="loading" :height="loadingHeight" />
  </div>
</template>

<script>
let elementResizeDetectorMaker = require("element-resize-detector");
let erdUltraFast = elementResizeDetectorMaker({
  strategy: "scroll", //<- For ultra performance.
});
import myLoading from "@/components/my-loading";

export default {
  components: { myLoading },
  props: {
    // 图片路径
    srcKey: {
      type: String,
      default: "imgSrc",
    },
    // 列宽度
    colsWidth: {
      type: Number,
      default: 240,
    },
    // 图片加载失败时的默认图片地址
    errSrc: {
      type: String,
      default: ``,
    },
    // 列表渲染的 key ,默认为 srcKey
    idKey: {
      type: String,
      default: "",
    },
    /**
     * 默认是等所有img预加载完之后再渲染,但是这给用户的体验会很慢,所有我还加了个loading
     * 如果你的数据的顺序不重要,可以设置此参数为true,他会让用户更快看到图片,但是会打乱数据的顺序,同时去掉loading功能
     * 更好的方式是传入的数据中就包含有 imgHeight(图片高度),这样会跳过预加载环节
     *  */
    fastWay: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      item: [], // 实际渲染的瀑布流数据
      itemkey: this.idKey || this.srcKey, // 渲染时的key值
      loading: false, // 控制loading状态
      containerHeight: 0, // 父容器的高度
      column: 0, // 总列数
    };
  },
  computed: {
    // loading模块高度
    loadingHeight() {
      return this.item.length ? "100px" : "500px";
    },
  },
  mounted() {
    this.calcColumn();
    // 容器宽度改变时重新布局
    erdUltraFast.listenTo(this.$el, this.calcElement);
  },
  methods: {
    // 用来向item中添加数据
    addItemData(items) {
      let index = 0; // 用来记录已经预加载完成的个数
      // 出口
      let exit = (i, index) => {
        if (this.fastWay) {
          this.vnodeItem(i, index);
        } else {
          if (index >= items.length) {
            this.loading = false;
            items.forEach((item, index) => {
              this.vnodeItem(item, index + 1);
            });
          }
        }
      };

      items.forEach((item) => {
        if (!this.fastWay) {
          this.loading = true;
        }
        // 如果高度已知,就不需要预加载
        if (item.imgHeight) {
          index++;
          exit(item, index);
        } else {
          let oImg = new Image();
          oImg.src = item[this.srcKey];
          // 预加载完成时
          oImg.onload = () => {
            index++;
            item.imgHeight = (oImg.height / oImg.width) * this.colsWidth;
            exit(item, index);
          };
          // 加载期间发生错误时
          oImg.onerror = () => {
            index++;
            item[this.srcKey] = this.errSrc;
            item.imgHeight = this.colsWidth;
            exit(item, index);
          };
        }
      });
    },

    // 计算单个的位置
    vnodeItem(item, index) {
      if (this.item.length < this.column) {
        item.top = 0;
        item.left = ((index - 1) % this.column) * this.colsWidth;
        item.cols = index; // 所在的列
      } else {
        for (let i = 1; i <= this.column; i++) {
          let colsHeight = this.calcColsHeight(i);
          if (!item.top || colsHeight < item.top) {
            item.top = colsHeight;
            item.left = (i - 1) * this.colsWidth;
            item.cols = i;
          }
        }
      }

      this.item.push(item);
      // 在这里重新计算父容器的高度
      this.calcContainerHeight();
    },

    // 容器宽度改变时重新布局
    calcElement() {
      this.calcColumn();
      let list = Array.from(this.item);
      this.item = [];
      list.forEach((item, index) => {
        item.cols = undefined;
        item.top = undefined;
        item.left = undefined;
        this.vnodeItem(item, index + 1);
      });
    },

    // 计算 container 的高度
    calcContainerHeight() {
      this.containerHeight = 0; // 初始化父容器高度
      for (let i = 1; i <= this.column; i++) {
        let colsHeight = this.calcColsHeight(i);
        if (!this.containerHeight || this.containerHeight < colsHeight) {
          this.containerHeight = colsHeight;
        }
      }
    },

    // 计算某一列的高度
    calcColsHeight(cols) {
      let colsHeight = 0;
      this.item
        .filter((item) => item.cols === cols)
        .forEach((item) => {
          colsHeight += item.imgHeight;
        });
      return colsHeight;
    },
    
    // 计算当前宽度下能排的列数
    calcColumn() {
      let elWidth = this.$el.offsetWidth; // 获取容器的宽度
      this.column = Math.floor(elWidth / this.colsWidth); // 当前容器最多能放几列
    },

    // 清空item
    delItem() {
      this.item = [];
      this.containerHeight = 0;
    },
  },
  beforeDestroy() {
    erdUltraFast.removeListener(this.$el, this.calcElement);
  },
};
</script>

<style scoped>
.container {
  position: relative;
}
.item {
  position: absolute;
  box-sizing: border-box;
  padding: 10px;
  animation: show-card-data 0.4s;
  transition: left 0.6s, top 0.6s;
  transition-delay: 0.1s;
}
.item > img {
  width: 100%;
}
@keyframes show-card-data {
  0% {
    -webkit-transform: scale(0.5);
    transform: scale(0.5);
  }
  100% {
    -webkit-transform: scale(1);
    transform: scale(1);
  }
}
</style>

TIP

通过refs使用addItemData方法添加数据,通过delItem清空数据

# 基于element-ui的图片预览组件

点击查看代码
<!-- main.vue -->
<template>
  <transition name="viewer-fade">
    <ImageViewer
      v-if="visible"
      :url-list="urlList"
      :z-index="zIndex"
      :initial-index="initialIndex"
      :append-to-body="appendToBody"
      :mask-closable="maskClosable"
      :on-close="closeViewer"
    />
  </transition>
</template>

<script>
import ImageViewer from "element-ui/packages/image/src/image-viewer";

export default {
  components: { ImageViewer },
  props: {
    urlList: {
      type: Array,
      default: () => [],
    },
    zIndex: {
      type: Number,
      default: 2000,
    },
    initialIndex: {
      type: Number,
      default: 0,
    },
    appendToBody: {
      type: Boolean,
      default: true,
    },
    maskClosable: {
      type: Boolean,
      default: true,
    },
  },
  data() {
    return {
      visible: false,
    };
  },
  methods: {
    closeViewer() {
      this.visible = false;
    },
  },
};
</script>
import Vue from "vue";
import Main from "./main.vue";

let instance;
const MainConstructor = Vue.extend(Main);

const ImageViewer = function(options = {}) {
  //   if (Vue.prototype.$isServer) return; // 是否运行在服务器

  instance = new MainConstructor({
    propsData: options,
  });

  instance.$mount();
  document.body.appendChild(instance.$el);

  instance.visible = true;

  return instance;
};

export default ImageViewer;
// main.js
import ImageViewer from "@/components/ImageViewer/index"; // 图片预览组件
Vue.prototype.$imageViewer = ImageViewer;
<!-- 用法 -->
<template>
  <el-button @click="imgViewer">点击预览</el-button>
</template>

<script>
export default {
  methods: {
    imgViewer() {
      this.$imageViewer({
        urlList: [
          "https://caihai123.com/Dribbble/lists/preview_teaser.png",
          "https://caihai123.com/Dribbble/lists/news_teaser.png",
        ],
      });
    },
  },
};
</script>

# 网页水印

TIP

参考组件 WaterMark (opens new window),我只是将它由react变成了vue

点击查看代码
<template>
  <div class="watermark-wrapper" style="position:relative">
    <slot />
    <div class="watermark" :class="markClassName" :style="watermarkStyle"></div>
  </div>
</template>

<script>
export default {
  props: {
    // 水印宽度
    width: {
      type: Number,
      default: 120,
    },
    // 水印高度
    height: {
      type: Number,
      default: 64,
    },
    // 水印绘制时,旋转的角度,单位 °
    rotate: {
      type: Number,
      default: -22,
    },
    // 图片源,建议导出 2 倍或 3 倍图,优先使用图片渲染水印
    image: {
      type: String,
      default: "",
    },
    // 追加的水印元素的 z-index
    zIndex: {
      type: Number,
      default: 9,
    },
    // 水印文字内容
    content: {
      type: String,
      default: "",
    },
    // 水印文字颜色
    fontColor: {
      type: String,
      default: "rgba(0,0,0,.15)",
    },
    // 文字大小
    fontSize: {
      type: [String, Number],
      default: 16,
    },

    /////////////以下属于高级参数/////////////

    // 水印层的样式
    markStyle: {
      type: Object,
      default: () => {},
    },
    // 水印层的类名
    markClassName: {
      type: String,
      default: "",
    },
    // 水印之间的水平间距
    gapX: {
      type: Number,
      default: 212,
    },
    // 水印之间的垂直间距
    gapY: {
      type: Number,
      default: 222,
    },
    // 水印在 canvas 画布上绘制的水平偏移量, 正常情况下,水印绘制在中间位置, 即 offsetTop = gapX / 2
    offsetLeft: {
      type: Number,
      default: 0,
    },
    // 水印在 canvas 画布上绘制的垂直偏移量,正常情况下,水印绘制在中间位置, 即 offsetTop = gapY / 2
    offsetTop: {
      type: Number,
      default: 0,
    },
    /** 文字样式 */
    fontStyle: {
      type: String,
      default: "normal",
      validator: function(value) {
        // 这个值必须匹配下列字符串中的一个
        return ["none", "normal", "italic", "oblique"].indexOf(value) !== -1;
      },
    },
    // 文字粗细
    fontWeight: {
      type: [String, Number],
      default: "normal",
      validator: function(value) {
        // 这个值必须匹配下列字符串中的一个
        return (
          ["normal", "light", "weight"].indexOf(value) !== -1 ||
          typeof value === "number"
        );
      },
    },
    // 字体
    fontFamily: {
      type: String,
      default: "sans-serif",
    },
  },
  data() {
    return {
      img: null,
    };
  },
  computed: {
    base64Url() {
      const canvas = document.createElement("canvas");
      const ctx = canvas.getContext("2d");
      const ratio = this.getPixelRatio(ctx);

      const canvasWidth = `${(this.gapX + this.width) * ratio}px`;
      const canvasHeight = `${(this.gapY + this.height) * ratio}px`;
      const canvasOffsetLeft = this.offsetLeft || this.gapX / 2;
      const canvasOffsetTop = this.offsetTop || this.gapY / 2;

      canvas.setAttribute("width", canvasWidth);
      canvas.setAttribute("height", canvasHeight);

      if (ctx) {
        // 旋转字符 rotate
        ctx.translate(canvasOffsetLeft * ratio, canvasOffsetTop * ratio);
        ctx.rotate((Math.PI / 180) * Number(this.rotate));
        const markWidth = this.width * ratio;
        const markHeight = this.height * ratio;

        if (this.img) {
          ctx.drawImage(this.img, 0, 0, markWidth, markHeight);
          return canvas.toDataURL();
        } else if (this.content) {
          const markSize = Number(this.fontSize) * ratio;
          ctx.font = `${this.fontStyle} normal ${this.fontWeight} ${markSize}px/${markHeight}px ${this.fontFamily}`;
          ctx.fillStyle = this.fontColor;
          ctx.fillText(this.content, 0, 0);
          return canvas.toDataURL();
        }
      } else {
        // eslint-disable-next-line no-console
        console.error("当前环境不支持Canvas");
      }

      return "";
    },
    watermarkStyle() {
      return {
        zIndex: this.zIndex,
        position: "absolute",
        left: "0px",
        top: "0px",
        width: "100%",
        height: "100%",
        backgroundSize: `${this.gapX + this.width}px`,
        pointerEvents: "none",
        backgroundRepeat: "repeat",
        backgroundImage: `url('${this.base64Url}')`,
        ...this.markStyle,
      };
    },
  },
  watch: {
    image: {
      handler() {
        if (this.image) {
          const img = new Image();
          img.crossOrigin = "anonymous";
          img.referrerPolicy = "no-referrer";
          img.src = this.image;
          img.onload = () => {
            this.img = img;
          };
        } else {
          this.img = null;
        }
      },
      immediate: true,
    },
  },
  methods: {
    getPixelRatio(context) {
      if (!context) {
        return 1;
      }
      const backingStore =
        context.backingStorePixelRatio ||
        context.webkitBackingStorePixelRatio ||
        context.mozBackingStorePixelRatio ||
        context.msBackingStorePixelRatio ||
        context.oBackingStorePixelRatio ||
        context.backingStorePixelRatio ||
        1;
      return (window.devicePixelRatio || 1) / backingStore;
    },
  },
};
</script>

# el-tree-select

TIP

只能在ElEment中使用,也是为了弥补element2中没有此组件的问题

点击查看代码
<template>
    <div
      ref="reference"
      v-clickoutside="() => toggleDropDownVisible(false)"
      :class="[
        realSize && `el-tree-select--${realSize}`,
        { 'is-disabled': isDisabled },
      ]"
      class="el-tree-select"
      @click="() => toggleDropDownVisible()"
      @mouseenter="inputHover = true"
      @mouseleave="inputHover = false"
    >
      <el-input
        ref="input"
        v-model="inputValue"
        :disabled="isDisabled"
        :size="realSize"
        :validate-event="false"
        :clearable="false"
        :readonly="filterable && !config.multiple ? false : true"
        :placeholder="presentTags.length ? '' : presentText || placeholder"
        :class="{ 'is-focus': dropDownVisible }"
        @focus="handleFocus"
        @blur="handleBlur"
        @input="filterHandler"
      >
        <template slot="suffix">
          <i
            v-if="clearBtnVisible"
            key="clear"
            class="el-input__icon el-icon-circle-close el-input__clear"
            @click.stop="handleClear"
          ></i>
          <i
            v-else
            key="arrow-down"
            :class="[
              'el-input__icon',
              'el-icon-arrow-down',
              dropDownVisible && 'is-reverse',
            ]"
            @click.stop="toggleDropDownVisible()"
          ></i>
        </template>
      </el-input>
  
      <div v-if="config.multiple" class="el-tree-select__tags">
        <el-tag
          v-for="tag in presentTags"
          :key="tag.key"
          type="info"
          :size="tagSize"
          :closable="tag.closable"
          disable-transitions
          @close="deleteTag(tag.key)"
        >
          <span class="ellipsis">{{ tag.label }}</span>
        </el-tag>
      </div>
  
      <transition name="el-zoom-in-top" @after-leave="handleDropdownLeave">
        <div
          v-show="dropDownVisible"
          ref="popper"
          :class="['el-popper', 'el-cascader__dropdown', popperClass]"
          :style="{
            minWidth: inputWidth + 'px',
            padding: '8px 4px',
            boxSizing: 'border-box',
          }"
        >
          <el-input
            v-if="config.multiple && filterable"
            v-model="inputMultipleValue"
            size="mini"
            placeholder="输入关键字搜索"
            style="margin-bottom:6px"
            @input="filterHandler"
          >
            <i slot="prefix" class="el-input__icon el-icon-search"></i>
          </el-input>
          <div
            v-loading="loading"
            element-loading-spinner="el-icon-loading"
            class="tree--select--loading--box"
          >
            <el-scrollbar :wrap-style="[{ maxHeight: listHeight + 'px' }]">
              <MyTree
                ref="tree"
                v-model="treeValue"
                :data="options"
                :row-key="rowKey"
                :props="config"
                :filter-node-method="filterNodeMethod"
                :lazy="lazy"
                :load="load"
                @loaded="treeLoaded"
                @selected="toggleDropDownVisible(false)"
              />
            </el-scrollbar>
          </div>
        </div>
      </transition>
    </div>
  </template>
  
  <script>
  import Clickoutside from "element-ui/src/utils/clickoutside";
  import Popper from "element-ui/src/utils/vue-popper";
  import { isDef } from "element-ui/src/utils/shared";
  import {
    addResizeListener,
    removeResizeListener,
  } from "element-ui/src/utils/resize-event";
  import MyTree from "./tree.vue";
  import { debounce } from "throttle-debounce";
  
  const InputSizeMap = {
    medium: 36,
    small: 32,
    mini: 28,
  };
  
  const PopperMixin = {
    name: "TreeSelect",
    props: {
      placement: {
        type: String,
        default: "bottom-start",
      },
      appendToBody: Popper.props.appendToBody,
      visibleArrow: {
        type: Boolean,
        default: true,
      },
      arrowOffset: Popper.props.arrowOffset,
      offset: Popper.props.offset,
      boundariesPadding: Popper.props.boundariesPadding,
      popperOptions: Popper.props.popperOptions,
      transformOrigin: Popper.props.transformOrigin,
    },
    methods: Popper.methods,
    data: Popper.data,
    beforeDestroy: Popper.beforeDestroy,
  };
  
  export default {
    components: { MyTree },
    directives: { Clickoutside },
    mixins: [PopperMixin],
    inject: {
      elForm: {
        default: "",
      },
      elFormItem: {
        default: "",
      },
    },
    props: {
      value: {
        type: [String, Array],
        required: true,
      },
      // 树的数据
      options: {
        type: Array,
        default: () => [],
      },
      props: {
        type: Object,
        default: () => ({}),
      },
      // 是否禁用
      disabled: {
        type: Boolean,
        default: false,
      },
      // 是否显示清空小图标
      clearable: {
        type: Boolean,
        default: false,
      },
      // 是否可搜索选项
      filterable: {
        type: Boolean,
        default: false,
      },
      // 多选模式下是否折叠Tag
      collapseTags: {
        type: Boolean,
        default: false,
      },
      placeholder: {
        type: String,
        default: "请选择",
      },
      popperClass: {
        type: String,
        default: "",
      },
      // eslint-disable-next-line vue/require-default-prop
      size: String,
      listHeight: {
        type: Number,
        default: 256,
      },
      rowKey: {
        type: String,
        required: true,
      },
      filterNodeMethod: {
        type: Function,
        default: (value, data) => {
          if (!value) return true;
          return data.label.indexOf(value) !== -1;
        },
      },
      debounce: {
        type: Number,
        default: 300,
      },
      // 是否是懒加载,需要配合load使用,为true是options将失效
      lazy: {
        type: Boolean,
        default: false,
      },
      // 懒加载函数,需要配合lazy使用,
      load: {
        type: Function,
        default: (node, resolve) => resolve([]),
      },
      loading: {
        type: Boolean,
        default: false,
      },
    },
    data() {
      return {
        dropDownVisible: false,
        inputHover: false,
        inputWidth: 0,
        presentText: "",
        presentTags: [],
        inputValue: "",
        inputInitialHeight: 0,
        inputMultipleValue: "",
      };
    },
    computed: {
      treeValue: {
        get() {
          return this.value;
        },
        set(val) {
          this.$emit("input", val);
        },
      },
      realSize() {
        const _elFormItemSize = (this.elFormItem || {}).elFormItemSize;
        return this.size || _elFormItemSize || (this.$ELEMENT || {}).size;
      },
      tagSize() {
        return ["small", "mini"].indexOf(this.realSize) > -1 ? "mini" : "small";
      },
      isDisabled() {
        return this.disabled || (this.elForm || {}).disabled;
      },
      clearBtnVisible() {
        return (
          this.clearable &&
          !this.isDisabled &&
          this.inputHover &&
          isDef(this.value) &&
          this.value &&
          this.value.length > 0
        );
      },
      config() {
        const defaultProps = {
          multiple: false, // 是否多选
          checkStrictly: true, // 是否严格的遵守父子节点不互相关联
        };
        return {
          ...defaultProps,
          ...this.props,
        };
      },
    },
    watch: {
      value() {
        this.computePresentContent();
      },
      presentTags(val, oldVal) {
        if (this.config.multiple && (val.length || oldVal.length)) {
          this.$nextTick(this.updateStyle);
        }
      },
      presentText(val) {
        this.inputValue = val;
      },
    },
    created() {
      // eslint-disable-next-line vue/no-undef-properties
      this.filterHandler = debounce(this.debounce, (value) => {
        if (this.filterable) {
          this.$refs["tree"].filter(value.trim());
          this.toggleDropDownVisible(true);
        }
      });
    },
    mounted() {
      const { input } = this.$refs;
      if (input && input.$el) {
        this.inputInitialHeight =
          input.$el.offsetHeight || InputSizeMap[this.realSize] || 40;
      }
  
      if (isDef(this.value)) {
        setTimeout(() => {
          this.computePresentContent();
        }, 0);
      }
      addResizeListener(this.$el, this.handleResize);
      addResizeListener(this.$el, this.updateStyle);
    },
    beforeDestroy() {
      removeResizeListener(this.$el, this.handleResize);
      removeResizeListener(this.$el, this.updateStyle);
    },
    methods: {
      // 切换popper展开收起状态
      toggleDropDownVisible(visible) {
        if (this.isDisabled) return;
  
        const { dropDownVisible } = this;
        // eslint-disable-next-line no-param-reassign
        visible = isDef(visible) ? visible : !dropDownVisible;
        if (visible !== dropDownVisible) {
          this.dropDownVisible = visible;
          if (visible) {
            this.$nextTick(() => {
              this.updatePopper();
            });
          } else {
            if (this.filterable && !this.config.multiple) {
              setTimeout(() => {
                // 延时筛选的重置,不然可能会打扰用户选择
                this.$refs["tree"].filter();
              }, 500);
            }
          }
        }
      },
  
      updatePopper() {
        // eslint-disable-next-line vue/no-undef-properties
        const popperJS = this.popperJS;
        if (popperJS) {
          popperJS.update();
          if (popperJS._popper) {
            popperJS._popper.style.zIndex = 4000; // 大部分dialog都是3001
          }
        } else {
          // eslint-disable-next-line vue/no-undef-properties
          this.createPopper();
        }
      },
  
      // 计算当前显示的内容
      computePresentContent() {
        this.$nextTick(() => {
          const { tree } = this.$refs;
          if (this.config.multiple) {
            this.computePresentTags();
          } else {
            this.presentText = tree.getCurrentLables() || this.value;
          }
        });
      },
  
      // 计算多选的tags
      computePresentTags() {
        const { collapseTags } = this;
        const { tree } = this.$refs;
        const labelList =
          tree.getCurrentLables() ||
          this.value.map((key) => ({ key, label: key }));
  
        const tags = [];
  
        const genTag = (tag) => ({
          key: tag.value,
          label: tag.label,
          closable: true,
        });
  
        if (labelList.length) {
          const [first, ...rest] = labelList;
          const restCount = rest.length;
          tags.push(genTag(first));
  
          if (restCount) {
            if (collapseTags) {
              tags.push({
                key: -1,
                label: `+ ${restCount}`,
                closable: false,
              });
            } else {
              rest.forEach((node) => tags.push(genTag(node)));
            }
          }
        }
  
        this.presentTags = tags;
      },
  
      // 懒加载之后重新计算显示的内容
      treeLoaded() {
        this.$nextTick(() => {
          this.computePresentContent();
        });
      },
  
      handleDropdownLeave() {
        // eslint-disable-next-line vue/no-undef-properties
        this.doDestroy();
      },
  
      handleResize() {
        this.inputWidth = this.$refs["reference"].getBoundingClientRect().width;
      },
  
      // 删除单个tag
      deleteTag(key) {
        let { value } = this;
        if (typeof value === "string") value = [value];
        this.$emit(
          "input",
          value.filter((id) => id !== key)
        );
      },
  
      updateStyle() {
        const { $el, inputInitialHeight } = this;
        if (this.$isServer || !$el) return;
  
        const inputInner = $el.querySelector(".el-input__inner");
  
        if (!inputInner) return;
  
        const tags = $el.querySelector(".el-tree-select__tags");
  
        if (tags) {
          const offsetHeight = Math.round(tags.getBoundingClientRect().height);
          const height = `${Math.max(offsetHeight + 6, inputInitialHeight)}px`;
          inputInner.style.height = height;
          if (this.dropDownVisible) {
            this.updatePopper();
          }
        }
      },
      // input 获得焦点时
      handleFocus(e) {
        if (this.filterable && !this.config.multiple) this.inputValue = "";
        this.$emit("focus", e);
      },
      // input失去焦点时
      handleBlur(e) {
        this.inputValue = this.presentText;
        this.$emit("blur", e);
      },
      // 点击清除图标
      handleClear() {
        this.presentText = "";
        this.inputValue = "";
        this.$emit("input", this.config.multiple ? [] : "");
      },
    },
  };
  </script>
  
  <style>
  .el-tree-select {
    display: inline-block;
    position: relative;
    font-size: 14px;
    line-height: 40px;
  }
  .el-tree-select--medium {
    font-size: 14px;
    line-height: 36px;
  }
  .el-tree-select .el-input {
    cursor: pointer;
  }
  .el-tree-select:not(.is-disabled):hover .el-input__inner {
    cursor: pointer;
    border-color: #c0c4cc;
  }
  .el-tree-select .el-input .el-input__inner {
    text-overflow: ellipsis;
  }
  .el-tree-select .el-input .el-icon-arrow-down {
    transition: transform 0.3s;
    font-size: 14px;
  }
  .el-tree-select .el-input .el-icon-arrow-down.is-reverse {
    transform: rotateZ(180deg);
  }
  .el-tree-select .el-input.is-focus .el-input__inner {
    border-color: #2b7bfb;
  }
  .el-tree-select-item.selected {
    color: #2b7bfb;
    font-weight: bold;
  }
  
  .el-tree-select__tags {
    position: absolute;
    left: 0;
    right: 30px;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    flex-wrap: wrap;
    line-height: normal;
    text-align: left;
    box-sizing: border-box;
    cursor: pointer;
  }
  .el-tree-select__tags .el-tag {
    display: inline-flex;
    align-items: center;
    max-width: 100%;
    margin: 2px 0 2px 6px !important;
    text-overflow: ellipsis;
    background: #f0f2f5;
  }
  .el-tree-select__tags .el-tag:not(.is-hit) {
    border-color: transparent;
  }
  
  .tree--select--loading--box .el-loading-spinner {
    margin-top: -8px;
  }
  </style>
  
<template>
    <div class="tree">
      <TreeItem
        v-for="child in childNodes"
        :key="child.id"
        :node="child"
        :show-checkbox="config.multiple"
      />
      <div v-if="isEmpty" class="el-tree__empty-block">
        <span class="el-tree__empty-text">暂无数据</span>
      </div>
    </div>
  </template>
  
  <script>
  import TreeItem from "./TreeItem";
  
  // 节点构造函数
  let nodeIdSeed = 0;
  class Node {
    constructor(options) {
      this.value = options.value;
      this.data = options.data;
      this.parent = options.parent;
      this.tree = options.tree;
      this.id = this.tree.rowKey ? this.data[this.tree.rowKey] : nodeIdSeed++;
      this.checked = 0; // 选中状态 0:未选中 1:已选中 2:半选
      this.loading = false; // 是否在加载中,懒加载时使用
  
      this.visible = true; // 是否显示,过滤时使用
      this.expanded = false; // 是否展开
  
      // 设置节点层级
      this.level = this.parent ? this.parent.level + 1 : 1;
  
      // 是否是根节点
      this.isLeaf = (() => {
        const { children = [] } = this.data;
        if (this.tree.lazy) {
          if (typeof this.data.isLeaf === "boolean") {
            return this.data.isLeaf;
          } else {
            return false;
          }
        } else {
          return !(children.length > 0);
        }
      })();
  
      this.loaded = !this.tree.lazy; // 是否已经加载过了
  
      // 根节点
      const { children } = this.data;
      this.initChildNodes(children);
  
      // 需要在根节点创建之后再执行,阅读此函数代码时也需要记住这一点
      this.updateValue();
  
      this.tree.nodesMap[this.id] = this;
    }
  
    // 初始化value值或者说是根据value值设置选中状态
    updateValue() {
      let { value } = this;
      const { multiple, checkStrictly } = this.tree.config;
      if (multiple) {
        if (typeof value === "string") value = [value];
        this.checked = value.some((key) => this.id === key) ? 1 : 0;
        if (!checkStrictly) {
          // 父子相互关联
          const parentNodeAll = this.findParentNodeAll();
          const childrenNodeAll = this.findChildrenNodeAll();
          if (
            parentNodeAll.some((node) => value.some((key) => key === node.id))
          ) {
            // 如果父节点被选中则自己也需要选中
            this.checked = 1;
          } else if (
            // 注意:一定要判断childrenNodeAll是否为空,否则为空时会返回true,在这里是不允许的
            childrenNodeAll.length > 0 &&
            childrenNodeAll.every((node) => node.checked === 1)
          ) {
            this.checked = 1;
          } else if (childrenNodeAll.some((node) => node.checked === 1)) {
            this.checked = 2;
          }
        }
      } else {
        this.checked = value === this.id ? 1 : 0;
        if (!checkStrictly) {
          // 父子相互关联
          const parentNodeAll = this.findParentNodeAll();
          const childrenNodeAll = this.findChildrenNodeAll();
          if (parentNodeAll.some((node) => node.id === value)) {
            // 如果父节点被选中则自己也需要选中
            this.checked = 1;
          } else if (
            // 注意:一定要判断childrenNodeAll是否为空,否则为空时会返回true,在这里是不允许的
            childrenNodeAll.length > 0 &&
            childrenNodeAll.every((node) => node.checked === 1)
          ) {
            // 如果子节点全被选中则自己也需要选中,判断子节点的状态时可以使用checked判断,因为此时子组件的initValue函数已经执行完成
            this.checked = 1;
          } else if (childrenNodeAll.some((node) => node.checked === 1)) {
            // 如果子组件被选中了部分
            this.checked = 2;
          }
        }
      }
    }
  
    // 初始胡根节点
    initChildNodes(children) {
      this.childNodes = children
        ? children.map(
            (item) =>
              new Node({
                value: this.value,
                data: item,
                parent: this,
                tree: this.tree,
              })
          )
        : undefined;
    }
  
    getLabels() {
      if (this.tree.config.checkStrictly) {
        return this.data.label;
      } else {
        const parentNodeAll = this.findParentNodeAll();
        const nodeAll = [...parentNodeAll, this];
        return nodeAll.map((item) => item.data.label).join(" / ");
      }
    }
  
    // 查找所有下级节点
    findChildrenNodeAll() {
      const nodeList = [];
      (this.childNodes || []).forEach((item) => {
        nodeList.push(item);
        nodeList.push(...item.findChildrenNodeAll());
      });
      return nodeList;
    }
  
    // 查找所有父级点
    findParentNodeAll() {
      const nodeList = [];
      let { parent } = this;
      while (parent) {
        nodeList.push(parent);
        // eslint-disable-next-line prefer-destructuring
        parent = parent.parent;
      }
      return nodeList;
    }
  }
  
  export default {
    components: { TreeItem },
    props: {
      value: {
        type: [String, Array],
        required: true,
      },
      data: {
        type: Array,
        default: () => [],
      },
      props: {
        type: [Object, null],
        default: null,
      },
      // eslint-disable-next-line vue/no-unused-properties
      rowKey: {
        type: String,
        default: "",
      },
      // 是否是懒加载,需要配合load使用,为true是options将失效
      lazy: {
        type: Boolean,
        default: false,
      },
      // 懒加载函数,需要配合lazy使用,
      load: {
        type: Function,
        default: (node, resolve) => resolve([]),
      },
      // eslint-disable-next-line vue/require-default-prop
      filterNodeMethod: Function,
    },
    data() {
      return {
        childNodes: [],
        nodesMap: {},
      };
    },
    computed: {
      config() {
        return {
          multiple: false, // 是否多选
          checkStrictly: false, // 是否父子不相互关联
          ...this.props,
        };
      },
      isEmpty() {
        const { childNodes } = this;
        return (
          !childNodes ||
          childNodes.length === 0 ||
          childNodes.every(({ visible }) => !visible)
        );
      },
    },
    watch: {
      value(val) {
        this.updateValue(val);
      },
      data(options) {
        if (!this.lazy) {
          this.initNode(options);
          this.$emit("loaded");
        }
      },
    },
    created() {
      if (this.lazy) {
        this.load(null, (options) => {
          this.initNode(options);
          this.$emit("loaded");
        });
      } else {
        this.initNode(this.data);
      }
    },
    methods: {
      // 初始化节点
      initNode(children = []) {
        this.childNodes = children.map((item) => {
          return new Node({
            value: this.value,
            data: item,
            tree: this,
          });
        });
      },
  
      // 更新value值,更准确的说是通过value更新树的状态
      updateValue(val = this.value) {
        const deep = (arr = []) => {
          arr.forEach((node) => {
            deep(node.childNodes);
            node.value = val;
            node.updateValue();
          });
        };
        deep(this.childNodes);
      },
  
      // 获取当前value值的显示label
      // eslint-disable-next-line vue/no-unused-properties
      getCurrentLables() {
        let { value } = this;
        const { multiple } = this.config;
        if (multiple) {
          if (typeof value === "string") value = [value];
          return value.map((key) => {
            const node = this.nodesMap[key];
            return {
              value: key,
              label: node ? node.getLabels() : key,
            };
          });
        } else {
          const node = this.nodesMap[value];
          return node ? node.getLabels() : value;
        }
      },
  
      // eslint-disable-next-line vue/no-unused-properties
      filter(value, reserve = true) {
        const { filterNodeMethod } = this;
        if (filterNodeMethod) {
          const traverse = function(node) {
            const { childNodes = [], isLeaf } = node;
            node.visible = filterNodeMethod.call(node, value, node.data, node);
            if (node.visible && reserve) {
              // 如果上级已经匹配并且reserve为true,则保留所有下级
              node.findChildrenNodeAll().forEach((child) => {
                child.visible = true;
              });
              node.expanded = false;
              return;
            }
            childNodes.forEach((child) => traverse(child));
            if (!node.visible && childNodes.length) {
              node.visible = childNodes.some((node) => node.visible);
            }
            if (!value) return;
            if (node.visible && node.loaded && !isLeaf) node.expanded = true;
          };
          this.childNodes.forEach((node) => traverse(node));
        } else {
          throw new Error("filterNodeMethod 不存在!");
        }
      },
    },
  };
  </script>
  
<!-- eslint-disable vue/no-mutating-props -->
<template>
  <div
    v-show="node.visible"
    class="el-tree-node"
    :class="{
      'is-expanded': expanded,
      'is-current': node.checked === 1 || node.checked === 2,
      'is-hidden': !node.visible,
    }"
    @click.stop="handleClick"
  >
    <div class="el-tree-node__content">
      <span
        :class="[
          { 'is-leaf': node.isLeaf, expanded: !node.isLeaf && expanded },
          'el-tree-node__expand-icon',
          'el-icon-caret-right',
        ]"
        @click.stop="handleExpandIconClick"
      >
      </span>
      <el-checkbox
        v-if="showCheckbox"
        v-model="node.checked"
        :indeterminate="node.checked === 2"
        :disabled="!!node.disabled"
        :true-label="1"
        :false-label="0"
        @click.native.stop
        @change="handleCheckChange"
      >
      </el-checkbox>
      <span
        v-if="node.loading"
        class="el-tree-node__loading-icon el-icon-loading"
      >
      </span>
      <span class="el-tree-node__label">
        {{ node.data.label }}
      </span>
    </div>

    <el-collapse-transition>
      <div
        v-show="expanded"
        v-if="node.childNodes && node.childNodes.length"
        class="el-tree-node__children"
        role="group"
      >
        <tree-item
          v-for="child in node.childNodes"
          :key="child.id"
          :node="child"
          :show-checkbox="showCheckbox"
        >
        </tree-item>
      </div>
    </el-collapse-transition>
  </div>
</template>

<script>
export default {
  name: "TreeItem",
  props: {
    node: {
      type: Object,
      required: true,
    },
    showCheckbox: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      expanded: false,
    };
  },
  watch: {
    "node.expanded"(val) {
      this.expanded = val;
    },
  },
  methods: {
    handleCheckChange(val) {
      const { tree } = this.node;
      let oldValue = this.node.value;
      const { checkStrictly } = tree.config;
      if (typeof oldValue === "string") oldValue = [oldValue];

      let result = oldValue;

      if (checkStrictly) {
        if (val === 1) {
          result = [...new Set([...oldValue, this.node.id])];
        } else if (val === 0) {
          result = oldValue.filter((key) => key !== this.node.id);
        }
        tree.$emit("input", result);
      } else {
        // 以下这部分的代码我有点不认可,我是先去改变了组件的check状态,然后再从组件中获取keys
        // 我希望的方式是先计算出keys,然后再通过keys渲染组件
        // eslint-disable-next-line vue/no-mutating-props
        this.node.checked = val;
        const childrenNodeAll = this.node.findChildrenNodeAll();
        childrenNodeAll.forEach((node) => (node.checked = val));

        const parentNodeAll = this.node.findParentNodeAll(); // parentNodeAll的顺序是从下往上的
        (parentNodeAll || []).forEach((node) => {
          if ((node.childNodes || []).every((item) => item.checked === 1)) {
            node.checked = 1;
          } else if (
            (node.childNodes || []).some((item) => item.checked === 1)
          ) {
            node.checked = 2;
          } else {
            node.checked = 0;
          }
        });

        const keys = [];
        // 父子节点关联时,只取选中的最上级
        const deep = (array) => {
          array.forEach((node) => {
            if (node.checked === 1) {
              keys.push(node.id);
            } else {
              deep(node.childNodes || []);
            }
          });
        };
        deep(tree.childNodes || []);
        tree.$emit("input", keys);
      }
    },

    // 处理点击事件
    handleClick() {
      const { tree } = this.node;
      const { multiple, checkStrictly } = tree.config;
      if (multiple) {
        switch (this.node.checked) {
          case 0:
            this.handleCheckChange(1);
            break;
          case 1:
            this.handleCheckChange(0);
            break;
          default:
            this.handleCheckChange(1);
            break;
        }
      } else {
        if (checkStrictly) {
          tree.$emit("input", this.node.id);
        } else {
          tree.$emit("input", this.findExclusiveRoot(this.node).id);
        }
        tree.$emit("selected", this.node.key, this.node);
      }
    },

    // 找到节点独有的根节点,
    findExclusiveRoot(node) {
      let currentNode = node;
      while (currentNode) {
        if (currentNode.parent && currentNode.parent.childNodes.length === 1) {
          currentNode = currentNode.parent;
        } else {
          return currentNode;
        }
      }
    },

    // 处理展开收起切换
    handleExpandIconClick() {
      if (this.expanded) {
        this.expanded = false;
      } else {
        this.expanded = true;
        if (!this.node.loaded) {
          this.loadNode();
        }
      }
    },

    // 懒加载
    loadNode() {
      const { node } = this;
      node.loading = true;
      node.tree.load(node, (children = []) => {
        node.data.children = children;
        node.initChildNodes(children);
        node.loading = false;
        node.loaded = true;
        node.isLeaf = children.length === 0;
        this.$forceUpdate();
        node.tree.updateValue(node.tree.value);
        node.tree.$emit("loaded");
      });
    },
  },
};
</script>

<style scoped>
.el-tree-node.is-current > .el-tree-node__content {
  font-weight: bold;
  color: #2b7bfb;
}

.el-tree-node,
.el-tree-node__children {
  position: relative;
}
.el-tree-node__children {
  padding-left: 16px;
}
.el-tree-node__children > .el-tree-node::after {
  content: "";
  width: 1px;
  height: 100%;
  position: absolute;
  top: -13px;
  left: -4px;
  border-left: 1px dashed #c0c4cc;
  pointer-events: none;
}

.el-tree-node__children > .el-tree-node:last-child::after {
  height: 26px;
}

.el-tree-node__children > .el-tree-node::before {
  content: "";
  width: 12px;
  height: 1px;
  position: absolute;
  top: 13px;
  left: -4px;
  border-top: 1px dashed #c0c4cc;
  pointer-events: none;
}
</style>

# tree-table-line

TIP

某些情况下,可能需要给el-table中的树添加层级指示线

foo
点击查看代码
<!-- 
  用于在el-table中的树型结构显示指示线;
  注意:最好保证表格中每一行行高相等,否则可能会出现线条错位的情况
-->
<script>
const offset = 4;
export default {
  props: {
    indent: {
      type: Number,
      default: 16,
    },
    scope: {
      type: Object,
      required: true,
    },
    border: {
      type: String,
      default: "1px dashed #c0c4cc",
    },
  },
  render(h) {
    const { level = 0 } = this.scope.treeNode || {};
    const { treeData = {}, rowKey } = this.scope.store.states;

    const domList = [];

    if (level >= 1) {
      domList.push(
        h("div", {
          class: "horizontal-line",
          style: {
            left: level * this.indent + offset + "px",
            borderBottom: this.border,
          },
        })
      );
    }

    let currentId = this.scope.row[rowKey];
    while (currentId) {
      const parentId = Object.keys(treeData).find((key) => {
        return treeData[key].children.includes(currentId);
      });

      if (
        parentId &&
        (treeData[parentId].children[treeData[parentId].children.length - 1] !==
          currentId ||
          currentId === this.scope.row[rowKey])
      ) {
        domList.push(
          h("div", {
            class: "vertical-line",
            style: {
              left:
                (treeData[parentId].level + 1) * this.indent + offset + "px",
              borderLeft: this.border,
            },
          })
        );
      }
      currentId = parentId;
    }

    return h("div", { class: "tree-table-line" }, domList);
  },
};
</script>

<style scoped>
.tree-table-line {
  display: inline-block;
}

.horizontal-line {
  display: inline-block;
  width: 16px;
  position: absolute;
  top: 50%;
  pointer-events: none;
}
.vertical-line {
  display: inline-block;
  width: 1px;
  height: 100%;
  position: absolute;
  top: -50%;
  pointer-events: none;
}
</style>
上次更新:: 7/17/2023, 3:05:03 PM