el-tree 单接口滚动分页

完整组件

保存为项目内的 CustomerTreeScrollDemo.vue,使用已有的 getCustomerList 接口方法。父组件传入组织 ID;切换组织时通过 key 重建组件,重新从第一页加载:

<customer-tree-scroll-demo :key="orgId" :org-id="orgId" />
<template>
  <div class="customer-tree-demo">
    <!-- 监听实际滚动容器,必须有高度限制和 overflow:auto。 -->
    <div ref="scrollBox" class="tree-scroll" @scroll="checkBottom">
      <el-tree
        ref="tree"
        node-key="treeKey"
        :props="treeProps"
        highlight-current
        @node-expand="onExpand"
        @node-collapse="onCollapse"
        @node-click="onClick"
      />
    </div>
    <div class="tree-status">
      <span v-if="loading">加载中…</span>
      <el-button v-else-if="failed" size="mini" @click="retry">加载失败,重试</el-button>
      <span v-else-if="!hasMore">已加载全部数据</span>
      <span v-else>滚动到底部加载下一页</span>
    </div>
  </div>
</template>

<script>
import { getCustomerList } from "@/api/business/qualityControl";

export default {
  name: "CustomerTreeScrollDemo",
  props: { orgId: { type: [String, Number], required: true } },
  data() {
    return {
      pageIndex: 0,
      pageSize: 200,
      hasMore: true,
      loading: false,
      failed: false,
      treeProps: { label: "name", children: "children" },
    };
  },
  mounted() {
    this.loadNextPage();
  },
  methods: {
    parsePage(res, pageIndex) {
      const payload = res && res.data;
      const rawItems = Array.isArray(payload)
        ? payload
        : (payload && (payload.dataList || payload.list || payload.items)) || [];
      const items = Array.isArray(rawItems) ? rawItems : [];
      // 分页信息可以放在响应顶层,也可以放在 data 对象内。
      const meta = { ...res, ...(Array.isArray(payload) ? {} : payload) };
      const total = meta.totalItemCount != null ? meta.totalItemCount
        : meta.totalCount != null ? meta.totalCount : meta.total;
      let hasMore = items.length >= this.pageSize;
      if (meta.pageCount != null) hasMore = pageIndex < Number(meta.pageCount);
      else if (total != null) hasMore = pageIndex * this.pageSize < Number(total);
      return { items, hasMore };
    },
    makeItems(items, parentKey = "") {
      return items.map(item => {
        const id = item.iD != null ? item.iD : item.id != null ? item.id : item.code;
        if (id == null || id === "") throw new Error("树节点缺少稳定的唯一 ID");
        const treeKey = parentKey ? parentKey + "/" + id : String(id);
        return {
          ...item,
          treeKey,
          children: Array.isArray(item.children) ? this.makeItems(item.children, treeKey) : [],
        };
      });
    },
    async loadNextPage() {
      // 请求进行中、已经没有下一页,或失败等待重试时不自动请求。
      if (this.loading || !this.hasMore || this.failed || this._isDestroyed) return;
      const nextPage = this.pageIndex + 1;
      this.loading = true;
      try {
        // 所有页固定调用这一个接口,不区分分类和节点类型。
        const res = await getCustomerList({
          orgId: this.orgId,
          pageIndex: nextPage,
          pageSize: this.pageSize,
        });
        if (this._isDestroyed) return;
        const result = this.parsePage(res, nextPage);
        const tree = this.$refs.tree;
        const seen = new Set();
        const added = this.makeItems(result.items).filter(item => {
          if (seen.has(item.treeKey) || tree.getNode(item.treeKey)) return false;
          seen.add(item.treeKey);
          return true;
        });
        // 通过树组件的 append 追加,不重新赋值整棵树,保留展开和选中状态。
        added.forEach(item => tree.append(item));
        this.pageIndex = nextPage;
        // 即使后端分页信息异常,空页或整页重复节点也不再继续请求。
        this.hasMore = result.hasMore && result.items.length > 0 && added.length > 0;
      } catch (error) {
        if (!this._isDestroyed) this.failed = true;
        // 不推进页码,等待用户重试,避免无限请求错误接口。
      } finally {
        this.loading = false;
        if (!this._isDestroyed) this.$nextTick(this.checkBottom);
      }
    },
    checkBottom() {
      const box = this.$refs.scrollBox;
      if (!box || this._isDestroyed || this.loading || this.failed || !this.hasMore) return;
      const distance = box.scrollHeight - box.scrollTop - box.clientHeight;
      if (distance <= 24) this.loadNextPage();
    },
    onExpand() {
      this.$nextTick(this.checkBottom);
    },
    onCollapse() {
      // 收起子树后内容可能不足一屏,也需要检查是否继续加载。
      this.$nextTick(this.checkBottom);
    },
    onClick(data) {
      this.$emit("select", data);
    },
    retry() {
      this.failed = false;
      this.loadNextPage();
    },
  },
};
</script>

<style scoped>
.customer-tree-demo {
  width: 300px;
  height: 500px;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.tree-scroll {
  flex: 1;
  min-height: 0;
  overflow: auto;
}
.tree-status {
  flex-shrink: 0;
  padding: 8px;
  font-size: 12px;
  color: #909399;
}
</style>

触底加载规则

  1. 组件挂载后调用 CustomerList,加载第一页。
  2. 滚动容器距离底部不超过 24px 时,调用同一个接口加载下一页。
  3. loading 锁住重复请求,pageIndex 仅在成功后增加。
  4. tree.append 追加节点,保留已加载节点、展开状态和选中状态。
  5. 首屏数据不足以出现滚动条时,渲染后继续检查底部,自动加载到填满容器。
  6. 没有下一页、返回空数组或整页重复数据时停止;失败则暂停,点击重试继续原页码。

排查触底不请求

  • scroll 必须监听真正滚动的容器。原页面使用 @scroll.native 监听 el-tree;本示例将滚动和监听统一放在外层 .tree-scroll。
  • 容器必须有有效高度和 overflow: auto。示例用 500px 方便验证,接入页面可换成父布局提供的高度,同时保留 min-height: 0。
  • 不要每次追加后把 pageIndex 重置为 1,也不要替换整个树组件。
  • 每条数据必须有稳定的唯一 ID,接口分页应稳定排序。示例通过 treeKey 去重,子树节点增加父节点前缀。
  • 如果接口只返回完整数组、没有分页字段,按每页返回数量判断是否继续;后端重复返回同一页时,示例会在整页重复数据后停止。
posted @ 2026-10-12 10:35  不完美的完美  阅读(2)  评论(0)    收藏  举报