Vue3 + TS + Element-Plus 封装Tree组件 《亲测可用》

9a69fede8b2044a79dd834e3e48f20b4.png前期回顾f8e3cc1a0f694ac2b665ca2ad14c49d7.png

Vite + Vue3 + Ts 《企业级项目》二次封装 el-table、el-pagination、el-tooltip、el-dialog_vue后台管理系统需要二次封装的组件有哪些_彩色之外的博客-CSDN博客封装的功能有哪些?分页、表格排序、文字居中、溢出隐藏、操作列、开关、宽、最小宽、type类型(selection/index/expand)、格式化 、不同页面不同操作列、vuex、vue持久化插件、(此处没有接口所以用到,还涉及了query与params传值区别)子组件说思路:data数据请求接口拿到,表头数据一般也是后台接口,如没,前台可自定义自己写......_vue后台管理系统需要二次封装的组件有哪些icon-default.png?t=N7T8https://blog.csdn.net/m0_57904695/article/details/125613767?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168906559516800227493706%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=168906559516800227493706&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-5-125613767-null-null.268%5Ev1%5Ekoosearch&utm_term=%E5%B0%81%E8%A3%85&spm=1018.2226.3001.4450

目录

😷  子组件:

😎  父组件:

🥰😉  谢谢观看


😷  子组件:

<template>
	<div style="height: 100%">
		<template v-if="isOperator.length > 0">
			<div class="mb5">
				<el-button
					v-if="hasPermission.add"
					size="small"
					@click="addNode"
					type="primary"
					style="margin-left: 2px"
				>
					<SvgIcon name="ant-PlusOutlined"></SvgIcon>
					添加节点
				</el-button>

				<el-button
					v-if="hasPermission.delete"
					size="small"
					@click="removeNode"
					type="danger"
					style="margin-left: 76px"
				>
					<SvgIcon name="ant-ClearOutlined"></SvgIcon>
					删除节点
				</el-button>
			</div>

			<el-input
				v-if="hasPermission.search"
				ref="searchInputRef"
				v-model="searchQuery"
				size="small"
				placeholder="输入关键字搜索"
				class="mb5"
				style="width: 280px; display: block"
				:maxlength="20"
				show-word-limit
				clearable
			></el-input>
		</template>

		<el-tree
			class="tree"
			ref="treeRef"
			:indent="0"
			:node-key="nodeKey"
			:data="treeData"
			:props="defaultProps"
			:check-strictly="checkStrictly"
			:show-checkbox="isShowCheckbox"
			:check-on-click-node="checkOnClickNode"
			:default-expand-all="defaultExpandAll"
			:allow-drag="allowDrag"
			:draggable="isDraggable"
			:allow-drop="allowDrop"
			@node-drag-end="handleDragEnd"
			@node-click="handleNodeClick"
			@node-contextmenu="editNode"
			@check-change="getCheckedAllNodes"
			:filter-node-method="filterNode"
			v-bind="$attrs"
		>
			<template #default="{ node }">
				<div :style="nodeContentStyle" class="node-content">
					<SvgIcon :name="checkIconByNodeLevel(node)" class="mr5"></SvgIcon>
					<input
						v-if="showIpt && node.label === curNodLabel && isEditNode"
						ref="inputRef"
						type="text"
						:value="node.label"
						@blur="showIpt = false"
						@keyup.enter="updateNodeLabel($event, node)"
					/>
					<el-tooltip
						:disabled="showTitle"
						effect="dark"
						:content="node.label"
						placement="right"
					>
						<span
							:class="compNodeCustomized(node)"
							class="ellipsis"
							@mouseover="onShowNameTipsMouseenter"
						>
							{{ node.label }}
						</span>
					</el-tooltip>
				</div>
			</template>
		</el-tree>
	</div>
</template>

<script setup lang="ts">
import { nextTick, ref, computed, watch, onMounted } from 'vue';
import type Node from 'element-plus/es/components/tree/src/model/node';
const showIpt = ref<boolean>(false); // 是否显示输入框
const curNodLabel = ref<string>(); // 记录右键点击的节点
const inputRef = ref(); // 输入框实例
const treeRef = ref(); // 树实例
const searchInputRef = ref(); // 树搜索实例
//判断是否显示tooltip
const showTitle = ref<boolean>(true);
function onShowNameTipsMouseenter(e: MouseEvent) {
	/**
	 * onShowNameTipsMouseenter 函数被用作鼠标悬停,因此它应该接收一个 MouseEvent 类型的参数
	 * 访问一个 EventTarget 对象的属性如 clientWidth 和 scrollWidth 时,你会遇到类型错误。
	 * 因为 EventTarget 类型并不具有这些属性。这些属性是 HTMLElement 类型的一部分。
	 * 因此,你需要将 e.target 断言为 HTMLElement。
	 */
	const target = e.target as HTMLElement; //减少属性访问次数、提高性能
	if (!target) return;
	let textLength = target?.clientWidth; // 获取文本宽度
	let containerLength = target.scrollWidth; // 获取容器宽度
	// 如果文本宽度小于容器宽度,没溢出、不显示提示
	if (textLength < containerLength) {
		showTitle.value = false;
	} else showTitle.value = true;
}
// 判断节点能否被放置 如果返回 false ,节点不能被放置
const allowDrop = () => true;
// 判断节点能否被拖拽 如果返回 false ,节点不能被拖动
const allowDrag = () => true;
// 搜索
const searchQuery = ref('');

/* props
---------------------------- Satrt */
interface DefaultProps {
	id: string;
	children: string;
	label: string;
	disabled: string;
}
const emits = defineEmits(['eCurNode', 'eCheckedNodes', 'eSaveNodes']);
interface Props {
	treeData?: any[]; // 树数据
	nodeKey?: number | string; // 节点唯一标识 -用于默认选中和展开
	checkStrictly?: boolean; // 是否严格模式 (true子父不关联 false子父关联)
	isShowCheckbox?: boolean; // 是否显示复选框
	checkOnClickNode?: boolean; // 点击节点时是否选中复选框
	defaultExpandAll?: boolean; // 是否默认展开所有
	isDraggable?: boolean; // 是否可拖拽
	isOperator?: string[]; // 是否有操作
	isEditNode?: boolean; // 是否可编辑节点
	ellipsisLen?: number; // 文本数量显示tooltip提示 - (没有用到暂时不删父组件可能有传递)
	defaultProps?: DefaultProps; // 默认属性
	treeIcon?: string[]; // 树图标 [0]:最后一级图标;  [1]:展开图标;  [2]:折叠图标;
	/*
	 * 只有一级节点让其靠左对齐
	 * 例如
	 *	:node-content-style="{
	 *			'margin-left': '-27px',
	 *		}"
	 * 如果有复选框可以在父组件加上以下样式控制复选框位置
	 * :deep(.el-checkbox__inner) {
	 *		margin-left: -30px;
	 * }
	 */
	nodeContentStyle?: any;
}

const props = withDefaults(defineProps<Props>(), {
	treeData: () => [],
	nodeKey: 'id',
	checkStrictly: false,
	isShowCheckbox: true,
	checkOnClickNode: false, // true:不关联,false:关联 (line:105 handleNodeClick做了处理)
	defaultExpandAll: true,
	isDraggable: false,
	isOperator: () => [],
	isEditNode: false,
	ellipsisLen: 15,
	defaultProps: (): DefaultProps => ({
		id: 'strategyId',
		children: 'children',
		label: 'label',
		disabled: 'disabled',
	}),
	treeIcon: () => ['FileOutlined', 'FolderOpenOutlined', 'MinusSquareOutlined'],
});

/* props
---------------------------- End */

// 计算属性,用于判断操作权限
const hasPermission = computed(() => ({
	add: props.isOperator.includes('add'),
	delete: props.isOperator.includes('delete'),
	search: props.isOperator.includes('search'),
}));
// 点击节点时触发
const handleNodeClick = (data: Tree, node: { data: { disabled: boolean }; checked: boolean }) => {
	emits('eCurNode', data);
	// 点击该行 如果不是复选框或是禁用的,不勾选
	if (!props.isShowCheckbox || node.data.disabled) return;
	node.checked = !node.checked;
};

// 删除节点
const removeNode = () => {
	const checkedNodes = treeRef.value.getCheckedNodes();
	if (checkedNodes.length === 0) return alert('请至少勾选一项才能删除节点');
	for (const node of checkedNodes) {
		nextTick(() => {
			// id如果重复则会删除失败错乱
			treeRef.value.remove(node);
		});
	}
};

// 右击节点时触发
const editNode = (event: MouseEvent, node: Node & DefaultProps) => {
	event.preventDefault();
	curNodLabel.value = node[props.defaultProps.label];
	showIpt.value = true;
	nextTick(() => {
		inputRef.value?.focus();
	});
};

// 更新节点的label
const updateNodeLabel = (e: Event, node: Tree) => {
	const target = e.target as HTMLInputElement;
	if (isValueInTree(props.treeData, target.value)) return alert('该节点已存在');
	node = Object.assign({}, node);
	node.data.label = target.value;
	showIpt.value = false;
};

// 使用非递归方式查找树中是否存在某个值
function isValueInTree(data: string | any[], value: string) {
	const stack = [...data];
	while (stack.length) {
		const node = stack.pop();
		if (node.label === value) return true;
		if (Array.isArray(node.children)) {
			stack.push(...node.children);
		}
	}
	return false;
}

// 新增节点
const addNode = () => {
	const checkedNodes = treeRef.value.getCheckedNodes();
	if (checkedNodes.length === 0) return alert('请至少勾选一项才能添加节点');
	const nodeName = prompt('请输入节点名称');
	if (!nodeName) return;

	if (isValueInTree(props.treeData, nodeName)) return alert('该节点已存在');
	for (const parentNode of checkedNodes) {
		const newNode = {
			id: props.treeData.length + 1,
			label: nodeName,
		};
		if (!parentNode.children) {
			parentNode.children = [];
		}
		parentNode.children.push(newNode);
	}
};

// 结束拖拽
const handleDragEnd = (dropNode: Node) => {
	if (!dropNode) return;
	if (props.isDraggable === false) return;
	saveNode();
};

// 保存节点
function saveNode() {
	emits('eSaveNodes', props.treeData);
}

// 复选框改变
const getCheckedAllNodes = (data: Tree, isSelected: boolean) => {
	if (!props.isShowCheckbox) return;
	const checkedNodes = treeRef.value.getCheckedNodes();
	const halfCheckedNodes = treeRef.value.getHalfCheckedNodes();
	// data: 当前节点的数据
	// isSelected: 当前节点是否被选中
	// checkedNodes: 所有选中的节点
	// halfCheckedNodes: 所有半选中的节点
	emits('eCheckedNodes', data, isSelected, checkedNodes, halfCheckedNodes);
};

// 根据节点层级显示不同的图标
const checkIconByNodeLevel = computed(() => {
	return (node: { childNodes: []; expanded: boolean }) => {
		if (node.childNodes.length === 0) return `ant-${props.treeIcon[0]}`;
		return node.expanded ? `ant-${props.treeIcon[1]}` : `ant-${props.treeIcon[2]}`;
	};
});

// 根据id || disabled 控制节点颜色变化
const compNodeCustomized = computed(() => {
	// const customizedIds = [1]; // 假设dome指定需要自定义的节点id数组

	return (node: { data: { id: number; disabled: boolean } }) => {
		// 如果有禁用的节点,则加上样式
		if (node.data.disabled) return 'node-customized';
		// 如果节点id在指定的id数组中,则加上样式
		// return customizedIds.includes(node.data.id) ? 'node-customized' : '';
	};
});

// 数据搜索
const filterNode = (value: string, data: any) => {
	if (!value) return true;
	return data.label.includes(value);
};
watch(searchQuery, (newVal) => {
	treeRef.value?.filter(newVal);
});

onMounted(() => setTimeout(() => searchInputRef.value?.focus(), 1000));
defineExpose({
	treeRef,
	removeNode,
	addNode,
});
</script>

<style lang="scss" scoped>
.tree {
	border-radius: 5px;
	// background-color: #d30808;
	padding: 15px;
	width: 280px;
	height: 100%;

	padding-bottom: 10px;
	overflow-x: hidden;
	overflow-y: auto !important;
	border-radius: var(--el-card-border-radius);
	border: 1px solid var(--el-border-color-light);
	background-color: #fff;
	overflow: hidden;
	color: var(--el-text-color-primary);
	transition: var(--el-transition-duration);
	border-radius: 4px;
	&:hover {
		&:hover {
			box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
		}
	}
	transition: box-shadow 0.3s ease-in-out;

	:deep(.el-tree-node) {
		position: relative;
	}
	:deep(.el-tree-node__children) {
		padding-left: 16px;
	}
	:deep(.el-tree-node::before) {
		content: '';
		width: 22px;
		height: 20px;
		position: absolute;
		left: -12px;
		top: 1px;
		border-width: 1px;
		border-left: 1px dashed #ccc;
	}
	:deep(.el-tree-node:last-child::before) {
		height: 38px;
	}
	:deep(.el-tree-node::after) {
		content: '';
		width: 22px;
		height: 20px;
		position: absolute;
		left: -3px;
		top: 11px;
		border-width: 1px;
		border-top: 1px dashed #ccc;
	}
	& > :deep(.el-tree-node::after) {
		border-top: none;
	}
	& > :deep(.el-tree-node::before) {
		border-left: none;
	}
	:deep(.el-tree-node__expand-icon) {
		font-size: 16px;
		:deep(&.is-leaf) {
			color: transparent;
			display: none;
		}
	}
	.node-customized {
		color: #c4b6b3;
	}

	/* 	一级不显示复选框其余内层都显示
	-----------------------------------------------------------*/
	// :deep(.el-checkbox .el-checkbox__inner) {
	// 	display: none;
	// }

	// :deep(div[role='group']) {
	// 	.el-checkbox .el-checkbox__inner {
	// 		display: inline-block;
	// 	}
	// }

	// :deep(.el-tree-node__content) {
	// 	:deep(.el-checkbox .el-checkbox__inner) {
	// 		display: none;
	// 	}
	// }

	/* 只有最后一级显示复选框
	-----------------------------------------------------------*/
	// :deep(.el-tree-node) {
	// 	.is-leaf + .el-checkbox .el-checkbox__inner {
	// 		display: inline-block;
	// 	}
	// 	.el-checkbox .el-checkbox__inner {
	// 		display: none;
	// 	}
	// }

	/* 隐藏所有复选框
	-----------------------------------------------------------*/
	// :deep(.el-checkbox .el-checkbox__inner) {
	// 	display: none;
	// }

	/* 只在三级菜单中显示复选框
	-----------------------------------------------------------*/
	// :deep(div[role='group'] div[role='group'] .el-checkbox .el-checkbox__inner) {
	// 	display: inline-block;
	// }
}
:deep(.el-input__wrapper, .el-input__wrapper.is-focus) {
	width: 100%;
}
.node-content {
	display: flex;
	align-items: center;
	width: 100%; /* 确保容器宽度填满 */
	min-width: 0; /* 防止flex子项溢出不换行 */
}

.ellipsis {
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	flex-basis: 1; /* 允许文本元素填充剩余空间 */
	max-width: calc(100% - 30px); /* 减去图标等其他元素占用的宽度 */
}
</style>

😎  父组件:

<template>
	<div class="con">
		<zw-tree @eSaveNodes="onSaveNodes" v-bind="state" />
	</div>
</template>

<script setup lang="ts">
import { reactive } from 'vue';
const state = reactive({
	treeData: [
		{
			id: 1,
			label: '一级 1一级一级一级一级一级一级一级一级一级一级一级一级',
			disabled: true,
			children: [
				{
					id: 4,
					label: '二级 1-1',
					disabled: null,

					children: [
						{
							id: 9,
							label: '三级 1-1-1',
						},
						{
							id: 10,
							label: '三级 1-1-2',
						},
					],
				},
			],
		},
		{
			id: 2,
			label: '一级 2',
			children: [
				{
					id: 5,
					label: '二级 2-1',
				},
				{
					id: 6,
					label: '二级 2-2',
				},
			],
		},
		{
			id: 3,
			label: '一级 3',
			children: [
				{
					id: 7,
					label: '二级 3-1',
				},
				{
					id: 8,
					label: '二级 3-2',
				},
			],
		},
		{
			id: 1,
			label: '一级 1一级一级一级一级一级一级一级一级一级一级一级一级',
			disabled: true,
			children: [
				{
					id: 4,
					label: '二级 1-1',
					disabled: null,

					children: [
						{
							id: 9,
							label: '三级 1-1-1',
						},
						{
							id: 10,
							label: '三级 1-1-2',
						},
					],
				},
			],
		},
		{
			id: 2,
			label: '一级 2',
			children: [
				{
					id: 5,
					label: '二级 2-1',
				},
				{
					id: 6,
					label: '二级 2-2',
				},
			],
		},
		{
			id: 3,
			label: '一级 3',
			children: [
				{
					id: 7,
					label: '二级 3-1',
				},
				{
					id: 8,
					label: '000000000000000000000',
				},
			],
		},
		{
			id: 4,
			label: '一级 3',
			children: [
				{
					id: 7,
					label: '二级 3-1',
				},
				{
					id: 8,
					label: '222222',
				},
			],
		},
		{
			id: 5,
			label: '一级 3',
			children: [
				{
					id: 7,
					label: '二级 3-1',
				},
				{
					id: 8,
					label: '注意不要遮挡数据,底部要展示出来-----------------------------------------',
				},
			],
		},
	],
	isOperator: true,
});
function onSaveNodes(data: Tree) {
	console.log(data);
}
</script>

<style>
.con {
	padding: 10px;
	/* 【重要】tree的高度根据外层容器,tree默认100% */
	width: 280px;
	/* height: 500px; */
	height: 100%;
}
</style>

更多的el-tree看这里 🤺👉  自定义《element-UI》el-tree 的样式 、亲测管用_自定义《element-UI》el-tree 的样式 、亲测管用_icon-default.png?t=N7T8https://blog.csdn.net/m0_57904695/article/details/123514519?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168906618216800211567162%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=168906618216800211567162&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-123514519-null-null.268%5Ev1%5Ekoosearch&utm_term=el-tree&spm=1018.2226.3001.4450彩色之外的博客-csdn博客<>

更多的el-table看这里  😂 

点击《el-table》让选中的行变色,亲测实用_彩色之外的博客-CSDN博客公司各种需求又来了,直接看下面文吧,一看就懂就不在说需求了,此时我觉得我的表情包是【我就像是一个小朋友站在路标下满头的问号】亲测管用,希望可以帮助到大家icon-default.png?t=N7T8https://blog.csdn.net/m0_57904695/article/details/123722382?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168906621616782425128470%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=168906621616782425128470&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-9-123722382-null-null.268%5Ev1%5Ekoosearch&utm_term=%E8%A1%A8%E6%A0%BC&spm=1018.2226.3001.4450

🥰😉  谢谢观看

7730e2bd39d64179909767e1967da702.jpeg

 _______________________________  期待再见  _______________________________ 

  • 6
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

彩色之外

你的打赏是我创作的氮气加速动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值