写一个行政区划下拉选组件(异步+搜索)

直接上代码

ini 复制代码
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { http, useDebounce } from '../../utils';
import { maxRequestNum } from '../../global';
import { TreeSelect, message } from 'antd';
import type { TreeSelectProps } from 'antd';

// 节点类型
export interface RegionTreeNode {
  id: string;
  value: string;
  label: string;
  children?: RegionTreeNode[];
  isLeaf?: boolean;
  regionLevel?: number;
}

// 组件 Props
export interface AsyncRegionSelectProps extends TreeSelectProps {
  /**
   * 自定义加载顶层节点的参数
   */
  topLevelParams?: Record<string, any>;
  disabled?: boolean;
}

/**
 * 异步懒加载 区域选择组件
 * 省/市/区/街道 四级联动异步加载 + 搜索功能
 */
const AsyncRegionSelect: React.FC<AsyncRegionSelectProps> = (props) => {
  const { topLevelParams, disabled, ...restProps } = props;

  // 区域树数据
  const [regionTreeData, setRegionTreeData] = useState<RegionTreeNode[]>([]);
  // 搜索后临时展示的树数据
  const [searchTreeData, setSearchTreeData] = useState<RegionTreeNode[]>([]);
  // 加载状态
  const [loading, setLoading] = useState<boolean>(false);
  // 展开的节点 keys
  const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
  // 记录是否处于搜索状态
  const isSearchRef = useRef(false);

  // 格式化树数据
  const formatTreeData = (data: any[] = []): RegionTreeNode[] => {
    return data.map((item) => ({
      id: item?.id || '',
      value: item?.id || '',
      label: item?.regionName || '',
      children: item?.children ? formatTreeData(item.children) : [],
      isLeaf: false, // 强制显示展开箭头
      regionLevel: item?.regionLevel || 0,
    }));
  };

  // 加载顶层节点(省)
  const loadTopLevelNodes = () => {
    const param = {
      pageNum: 1,
      pageSize: maxRequestNum,
      param: {
        parentRegionId: '',
        regionLevel: 0,
        ...topLevelParams,
      },
    };

    setLoading(true);
    http
      .post('/service/webapp/listRegionManagement', param)
      .then((res: any) => {
        if (res?.code === 200) {
          const treeData = formatTreeData(res?.rows || []);
          setRegionTreeData(treeData);
        } else {
          message.error(res?.msg || '加载区域数据失败');
        }
      })
      .catch(() => message.error('加载区域数据失败'))
      .finally(() => setLoading(false));
  };

  // 加载子节点
  const loadChildNodes = async (
    parentId: string,
    parentLevel: number,
  ): Promise<any[]> => {
    try {
      const param = {
        pageNum: 1,
        pageSize: maxRequestNum,
        param: {
          parentRegionId: parentId,
          regionLevel: parentLevel + 1,
        },
      };

      const res = await http.post(
        '/service/webapp/listRegionManagement',
        param,
      );
      if (res?.code !== 200) return [];
      return res?.rows || [];
    } catch (err) {
      return [];
    }
  };

  // 更新树子节点
  const updateTreeChildren = (
    tree: RegionTreeNode[],
    id: string,
    newChildren: RegionTreeNode[],
  ): RegionTreeNode[] => {
    return tree?.map((node) => {
      if (node?.id === id) {
        return {
          ...node,
          children: newChildren,
          isLeaf: newChildren?.length === 0,
        };
      }
      if (node?.children) {
        return {
          ...node,
          children: updateTreeChildren(node?.children, id, newChildren),
        };
      }
      return node;
    });
  };

  // 异步加载子节点
  const handleLoadData = async (node: any) => {
    // 搜索状态下不执行懒加载
    if (isSearchRef.current) return;

    const { id, children } = node;
    if (children && children.length > 0) return;

    const childList = await loadChildNodes(id, node?.regionLevel || 0);
    const newChildTreeData = formatTreeData(childList);

    setRegionTreeData((prev) => updateTreeChildren(prev, id, newChildTreeData));
  };

  // 递归收集节点所有父级 ID(用于展开)
  // const getExpandedKeys = (
  //   node: RegionTreeNode,
  //   keys: string[] = [],
  // ): string[] => {
  //   if (node.id) keys.push(node.id);
  //   if (node.children && node.children.length > 0) {
  //     getExpandedKeys(node.children[0], keys);
  //   }
  //   return keys;
  // };
  // 递归收集节点路径(用于搜索后展开整条链)
  const collectPathKeys = (node: RegionTreeNode): string[] => {
    const keys: string[] = [];
    const traverse = (n: RegionTreeNode) => {
      keys.push(n?.id);
      if (n?.children && n?.children?.length > 0) {
        traverse(n?.children?.[0]);
      }
    };
    traverse(node);
    return keys;
  };

  //根据名称搜索
  const handleSearch = useDebounce((value: string) => {
    if (!value) {
      // 清空搜索 → 恢复原始树
      isSearchRef.current = false;
      setSearchTreeData([]);
      setExpandedKeys([]);
      loadTopLevelNodes();
      return;
    }
    isSearchRef.current = true;
    setLoading(true);
    http
      .get(`/service/webapp/getRegionChainByName?regionName=${value}`)
      .then((res: any) => {
        if (res?.code === 200) {
          const newTreeData = res?.rows || [];
          if (newTreeData?.length === 0) {
            message.info('未搜索到匹配区域');
            setSearchTreeData([]);
            return;
          }

          // 格式化搜索返回的完整树
          const treeData = formatTreeData(newTreeData);
          setSearchTreeData(treeData);

          // 自动展开到最末级
          const expandKeys = collectPathKeys(treeData[0]);
          setExpandedKeys(expandKeys);
        } else {
          message.error(res?.msg || '加载区域数据失败');
        }
      })
      .catch(() => {
        message.error('加载区域数据失败');
      })
      .finally(() => setLoading(false));
  }, 500);

  // 清除后重新加载顶层
  const handleChange = useCallback(
    (value: any) => {
      if (value === undefined || value === null) {
        isSearchRef.current = false;
        setSearchTreeData([]);
        setExpandedKeys([]);
        loadTopLevelNodes();
      }
      props.onChange?.(value);
    },
    [props.onChange],
  );

  // 初始化加载顶层
  useEffect(() => {
    loadTopLevelNodes();
  }, []);

  // 最终渲染的 treeData:搜索优先,否则用原始树
  const renderTreeData =
    searchTreeData.length > 0 ? searchTreeData : regionTreeData;

  return (
    <TreeSelect
      disabled={disabled}
      style={{ width: '100%' }}
      placeholder="请选择区域"
      treeData={renderTreeData}
      loadData={handleLoadData}
      loading={loading}
      treeNodeFilterProp="label"
      allowClear
      showSearch
      onSearch={handleSearch}
      treeExpandedKeys={expandedKeys}
      onTreeExpand={(keys) => setExpandedKeys(keys as string[])}
      {...restProps}
      onChange={handleChange}
    />
  );
};

export default AsyncRegionSelect;

效果

相关推荐
星栈1 小时前
用 Rust + Makepad 做一个 JSON 查看器:从零到能用的全过程
前端·rust
yijianace1 小时前
Python爬虫实战:分页爬取 + 详情页采集 + CSV存储
前端·爬虫·python
想吃火锅10051 小时前
【前端手撕】防抖节流
前端
MemoriKu1 小时前
Flutter 相册 APP 视频模态稳定化实战:从视频抽帧、Embedding 元数据到 Android 真机启动修复
android·开发语言·前端·flutter·架构·音视频·embedding
lichenyang4531 小时前
ArkUI 票根卡片:PathShape 真挖洞,shadow 沿凹陷外发光
前端
Cache技术分享1 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
假如让我当三天老蒯2 小时前
暂时性死区是否和闭包是相背的呢(自学用)
前端·javascript
渣波2 小时前
前端开发主页面小技巧
前端·javascript
柯克七七2 小时前
我用3个周末重构了公司的前端项目,老板没发现,但同事都来找我要代码了
前端