自定义 TreeSelect 可搜索,可自定义新增节点

65 阅读4分钟

自定义 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>