自定义 TreeSelect 可搜索,可自定义新增节点
使用
const treeDataPublishSubject2 = [
{
id: 1,
parentId: 0,
level: 1,
name: "主体",
sortOrder: 1,
categoryType: "publish_subject",
description: null,
createTime: "2025-08-18 10:00:42",
children: [
{
id: "1958065566492909570",
parentId: 1,
level: 2,
name: "行业级",
sortOrder: 0,
categoryType: "publish_subject",
description: "行业级",
createTime: "2025-08-20 15:16:24",
children: [
{
id: "1962409340945129473",
parentId: "1958065566492909570",
level: 3,
name: "行业3",
sortOrder: 0,
categoryType: "publish_subject",
description: "",
createTime: "2025-09-01 14:57:01",
children: [],
},
],
},
{
id: "1958793782978723841",
parentId: 1,
level: 2,
name: "区域级",
sortOrder: 0,
categoryType: "publish_subject",
description: "",
createTime: "2025-08-22 15:30:05",
children: [
{
id: "1958793857922547714",
parentId: "1958793782978723841",
level: 3,
name: "xx区域",
sortOrder: 0,
categoryType: "publish_subject",
description: null,
createTime: "2025-08-22 15:30:23",
children: [],
},
],
},
{
id: "1958794011622817794",
parentId: 1,
level: 2,
name: "蔬菜级",
sortOrder: 0,
categoryType: "publish_subject",
description: null,
createTime: "2025-08-22 15:30:59",
children: [
{
id: "1958794221514178562",
parentId: "1958794011622817794",
level: 3,
name: "西红柿",
sortOrder: 0,
categoryType: "publish_subject",
description: null,
createTime: "2025-08-22 15:31:49",
children: [],
},
],
},
],
},
];
<CusTreeSelect
v-model="form.publishSubject"
:data="treeDataPublishSubject2"
a-type="aPublishSubject"
@select="onSelect1"
@add-child="onAddChild"
/>
<script setup lang="ts">
// value为传递给后端的具体值
const publishSubjectStr = ref("");
function onSelect1(node) {
// node为选中节点
// value为传递给后端的具体值
const { value } = node;
publishSubjectStr.value = value;
}
async function onAddChild({ parent, label, extra }) {
try {
let params = {};
if (extra === "aPublishSubject") {
const { value, level, categoryType } = parent;
params = {
id: null, // 新增id为空
name: label,
parentId: value, // 父value
description: "",
level: level + 1, // 父level
categoryType,
};
const { code, msg } = await postTreeList(params);
if (code === 200) {
const { treeData = [] } = await fetchTreeData("publish_subject");
treeDataPublishSubject2.value = treeData;
}
}
} catch (error) {
} finally {
}
}
</script>
TreeSelect 组件
<template>
<div ref="wrapper" class="tree-select">
<el-input
v-model="inputValue"
placeholder="请选择节点"
class="tree-input"
clearable
@input="handlerChange"
@focus="showTree = true"
@clear="handleClear"
/>
<div v-if="showTree" class="dropdown-tree">
<ul>
<TreeNode
v-for="node in aData"
:key="node.id"
:node="node"
:selected-label="inputValue"
@select="onSelect"
@add-child="onAddChild"
/>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, toRaw, watch } from "vue";
import TreeNode, { TreeNodeItem } from "./TreeNode.vue";
interface Props {
modelValue: string;
data: TreeNodeItem[];
aType: string;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "select", "add-child"]);
const inputValue = ref(props.modelValue || "");
const showTree = ref(false);
const wrapper = ref<HTMLElement | null>(null);
// 原始数据
const treeData = ref([]);
const aData = ref([]);
watch(
() => props.data,
(newValue, oldValue) => {
treeData.value = newValue;
aData.value = newValue;
},
{
immediate: true,
deep: true,
}
);
// ===
// 递归过滤方法
const filterTree = (data: TreeNodeItem[], keyword: string): TreeNodeItem[] => {
return data
.map((node) => {
// 递归过滤子节点
const children = node.children ? filterTree(node.children, keyword) : [];
// 如果当前节点匹配,或子节点有匹配,就保留
if (node.label.includes(keyword) || children.length > 0) {
return { ...node, children };
}
return null;
})
.filter((node) => node !== null) as TreeNodeItem[];
};
// 输入框搜索过滤
const handlerChange = (value: string) => {
showTree.value = true; // 确保输入和清空都显示下拉
const rawData = toRaw(treeData.value);
if (!value) {
// 没有搜索内容,恢复原始数据
aData.value = rawData;
} else {
// 递归过滤
aData.value = filterTree(rawData, value);
}
emit("update:modelValue", value);
};
// ===
const handleClear = () => {
showTree.value = true; // 显示下拉
handlerChange(""); // 恢复完整树
};
// 点击已有节点选中,关闭下拉
function onSelect(node: TreeNodeItem) {
inputValue.value = node.label;
showTree.value = false;
emit("update:modelValue", node.label);
emit("select", node);
}
// 新增自定义节点,保持下拉打开
function onAddChild([parent, label]: [TreeNodeItem, string]) {
if (!parent.children) {
parent.children = reactive<TreeNodeItem[]>([]);
}
const newId = Date.now();
parent.children.push({ id: newId, label });
// inputValue.value = label;
emit("add-child", { parent, label, extra: props.aType });
}
// 点击外部关闭下拉
onMounted(() => {
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (
!wrapper.value?.contains(target) &&
!target.closest(".el-input__icon")
) {
showTree.value = false;
}
});
});
// 监听外部 v-model 改变
watch(
() => props.modelValue,
(val) => {
inputValue.value = val;
}
);
</script>
<style scoped>
.tree-select {
position: relative;
width: 280px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.tree-input {
width: 100%;
box-sizing: border-box;
/* border: 1px solid #dcdfe6; */
border-radius: 4px;
cursor: pointer;
}
.tree-input:focus {
outline: none;
border-color: #409eff;
}
.dropdown-tree {
position: absolute;
top: 100%;
left: 0;
width: 100%;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fff;
max-height: 300px;
overflow: auto;
z-index: 1000;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.dropdown-tree ul {
list-style: none;
margin: 0;
padding: 0;
}
</style>
TreeNode 组件
<template>
<li>
<div
:class="['node-label', selectedLabel === node.label ? 'selected' : '']"
:style="{ paddingLeft: `${level * 16}px` }"
>
<span
v-if="node.children?.length"
class="toggle-btn"
@click.stop="toggle"
>
<el-icon v-if="expanded"><ArrowDown /></el-icon>
<el-icon v-else><ArrowRight /></el-icon>
</span>
<span v-else class="temp_box"></span>
<span @click.stop="selectNode">{{ node.label }}</span>
<el-icon :size="20" class="add-btn" @click.stop="showInput = true"
><Plus
/></el-icon>
</div>
<div
v-if="showInput"
class="add-child-box"
:style="{ paddingLeft: `${(level + 1) * 16}px` }"
>
<el-input v-model="newLabel" placeholder="请输入新节点" @click.stop />
<div class="btns">
<el-button class="confirm-btn" type="primary" @click.stop="confirmAdd"
>确定</el-button
>
<el-button class="cancel-btn" @click.stop="cancelAdd">取消</el-button>
</div>
</div>
<ul v-if="node.children?.length && expanded">
<TreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:selected-label="selectedLabel"
:level="level + 1"
@select="$emit('select', $event)"
@add-child="$emit('add-child', $event)"
/>
</ul>
</li>
</template>
<script lang="ts" setup>
import { ref, reactive, defineProps, defineEmits } from "vue";
export interface TreeNodeItem {
id: number | string;
label: string;
children?: TreeNodeItem[];
}
const props = defineProps<{
node: TreeNodeItem;
selectedLabel?: string;
level?: number;
}>();
const emit = defineEmits<{
(e: "select", node: TreeNodeItem): void;
(e: "add-child", payload: [TreeNodeItem, string]): void;
}>();
const level = props.level ?? 0;
const showInput = ref(false);
const newLabel = ref("");
const expanded = ref(true);
function selectNode() {
emit("select", props.node); // 点击已有节点才触发关闭下拉
}
function toggle() {
expanded.value = !expanded.value;
}
function confirmAdd() {
if (!newLabel.value) return;
if (!props.node.children) {
props.node.children = reactive<TreeNodeItem[]>([]);
}
emit("add-child", [props.node, newLabel.value]);
newLabel.value = "";
showInput.value = false; // 只关闭输入框,不关闭下拉
}
function cancelAdd() {
newLabel.value = "";
showInput.value = false;
}
</script>
<style scoped>
.node-label {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.node-label:hover {
background-color: #f5f7fa;
}
.selected {
background-color: #e6f7ff;
color: #409eff;
}
.toggle-btn {
display: inline-block;
width: 16px;
text-align: center;
cursor: pointer;
}
.temp_box {
display: inline-block;
width: 16px;
}
.add-btn {
margin-left: auto;
cursor: pointer;
/* background: #fff; */
/* border: 1px solid #dcdfe6; */
border-radius: 2px;
padding: 0 4px;
}
.add-btn:hover {
border-color: #409eff;
color: #409eff;
}
.add-child-box {
margin-top: 4px;
}
.add-child-box .btns {
margin: 10px 0;
display: flex;
justify-content: flex-end;
gap: 4px;
}
.add-child-box input {
flex: 1;
padding: 2px 4px;
border: 1px solid #dcdfe6;
border-radius: 2px;
}
.confirm-btn,
.cancel-btn {
}
.confirm-btn:hover {
}
.cancel-btn:hover {
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
</style>