# 相关链接
- vue (opens new window) vue官网
- vue-cli (opens new window) Vue.js 开发的标准脚手架
- Vue Router (opens new window) vue路由
- Vuex (opens new window) 全局状态管理
# 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 滚动吸顶
点击查看代码
<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中的树添加层级指示线
点击查看代码
<!--
用于在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>