树状结构对象(组织架构等)通用CRUD工具类

版权说明: 本文由博主keep丶原创,转载请注明出处。
原文地址: https://blog.csdn.net/qq_38688267/article/details/112675319
代码已同步更新到:https://gitee.com/zengzefeng/easy_frame

场景介绍

  在系统开发过程中,树状结构对象时非常常见的,比如组织架构等,一般前端展示时都是以树状图的形式展示:
在这里插入图片描述

  而这种数据通常伴随着移动节点删除节点等功能,这些功能虽然逻辑不复杂,但是实现起来细节还是蛮多的,而且一般一个系统都会有很多树状结构的对象,每个都有这些功能,写起来还是挺麻烦的。

  所以封装了一个通用的树状结构对象基础功能实现,极大的简化了后续对树状结构对象基础功能的开发工作,一起来看看吧:

封装类

BaseTreePO树状结构对象基类

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.io.Serializable;
import java.util.Date;

/**
 * 树状结构对象基类
 *
 * @author zzf
 * @date 2020/7/30 11:09
 */
@Getter
@Setter
@ToString
public abstract class BaseTreePO implements Serializable {

    @ApiModelProperty("名字")
    private String name;

    @ApiModelProperty("短编码")
    private String path;

    @ApiModelProperty("父编码")
    private String parentPath;

    @ApiModelProperty("层级")
    private Integer level;

    @ApiModelProperty("排序")
    private Integer orderNum;

    @ApiModelProperty("是否叶节点 [0不是 1是]")
    private Boolean leafNode;

    @ApiModelProperty("是否启用 [0禁用 1启用]")
    private Integer enabled;

    @ApiModelProperty("备注")
    private String remark;

    @ApiModelProperty("是否删除 [0:未删除 1:已删除]")
    private Integer deleted;

    @TableField(fill = FieldFill.INSERT)
    @ApiModelProperty("创建人")
    private Integer createBy;

    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", locale = "zh", timezone = "GMT+8")
    @ApiModelProperty("创建时间")
    private Date createTime;

    @TableField(fill = FieldFill.UPDATE)
    @ApiModelProperty("修改人")
    private Integer updateBy;

    @TableField(fill = FieldFill.UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", locale = "zh", timezone = "GMT+8")
    @ApiModelProperty("修改时间")
    private Date updateTime;
}

TreeService树状对象业务接口(全文核心)

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.service.IService;
import com.copm.ifm.base.basic.constant.DbConstants;
import com.copm.ifm.base.basic.constant.NumberConstants;
import com.copm.ifm.base.basic.exception.ServiceException;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
import com.copm.ifm.base.basic.util.ObjectUtils;
import com.copm.ifm.base.service.util.TreeUtil;
import org.apache.commons.lang.StringUtils;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

import static com.copm.ifm.base.basic.constant.DbConstants.DEFAULT_YES;

/**
 * 树状图相关接口
 *
 * @author zzf
 * @date 2021/1/11 13:56
 */
public interface TreeService<T extends BaseTreePO> extends IService<T> {
    /**
     * 添加树状对象
     *
     * @param entity 树状对象
     */
    @Transactional(rollbackFor = Exception.class)
    default void addTree(T entity) {

        if (StringUtils.isBlank(entity.getParentPath())) {
            entity.setParentPath(null);
        } else {
            if (entity.getParentPath().length() % TreeUtil.DEFAULT_PATH_LENGTH > 0) {
                throw new ServiceException("父路径取值错误!");
            }
        }
        //前置任务
        beforeTreeAddProcessor(entity);

        //添加
        entity.setPath(this.getNewChildPath(entity, entity.getParentPath()));
        entity.setLevel(TreeUtil.getLevel(entity.getPath()));
        entity.setEnabled(DEFAULT_YES);

        this.save(entity);

        //后置任务
        afterTreeAddProcessor(entity);

        //判断并修改父级 是否叶子节点
        if (StrUtil.isNotEmpty(entity.getParentPath())) {
            modifyParentLeafNodeToFalse(entity);
        }
    }

    /**
     * 移动节点
     *
     * <p>
     * 1.更新原父节点(注意无原父节点情况)
     * 2.更新新父节点(注意无新父节点情况)
     * 3.遍历所有子节点,批量更新
     * 4.更新自己(前面的更新需要用到 entity 的 path 和 parentPath)
     *
     * @param movedNodeId     需要移动的节点id
     * @param newParentNodeId 新父级节点id
     */
    @Transactional(rollbackFor = Exception.class)
    default void moveNode(Integer movedNodeId, Integer newParentNodeId) {

        if (ObjectUtil.isEmpty(movedNodeId)) {
            throw new ServiceException("movedNodeId不能为空。");
        }

        // 记录需要修改的对象
        List<T> batchUpdateList = new ArrayList<>();

        T entity = getBaseMapper().selectById(movedNodeId);

        // 1.更新原父节点的叶子节点(注意无原父节点情况)
        this.checkAndModifyParentLeafNodeToYes(entity);

        String newParentPath;
        if (ObjectUtils.isNull(newParentNodeId)) {
            newParentPath = "";
        } else {
            T parentNode = getBaseMapper().selectById(newParentNodeId);
            newParentPath = parentNode.getPath();

            // 2.更新新父节点
            if (parentNode.getLeafNode()) {
                parentNode.setLeafNode(false);
                getBaseMapper().updateById(parentNode);
            }
        }

        String oldChildPath = entity.getPath();
        String newChildPath = this.getNewChildPath(entity, newParentPath);

        // 3.遍历所有子节点,批量更新
        LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
                // 这里要用likeRight,将其所有子节点全部修改
                .likeRight(T::getParentPath, entity.getPath());

        List<T> childrenList = this.getBaseMapper().selectList(this.doAppend(wrapper, entity));
        if (CollectionUtil.isNotEmpty(childrenList)) {
            for (T s : childrenList) {
                s.setParentPath(s.getParentPath().replaceFirst(oldChildPath, newChildPath));
                s.setPath(s.getPath().replaceFirst(oldChildPath, newChildPath));
                s.setLevel(TreeUtil.getLevel(s.getPath()));
            }
            batchUpdateList.addAll(childrenList);
        }

        // 4.更新自己
        entity.setParentPath(newParentPath);
        entity.setPath(newChildPath);
        entity.setLevel(TreeUtil.getLevel(newChildPath));
        batchUpdateList.add(entity);

        this.updateBatchById(batchUpdateList);
    }

    /**
     * 删除节点
     * 1.判断存在未删除的孩子节点,不允许删除
     * 2.更新自己的 deleted 字段
     * 3.判断是否更新父节点
     *
     * @param id 需要删除的节点id
     */
    default void deleteNode(Integer id) {

        T entity = getBaseMapper().selectById(id);
        if (!entity.getLeafNode()) {
            throw new ServiceException("该节点下存在未删除的孩子节点,不允许删除。");
        }

        // 2.更新自己的 deleted 字段
        entity.setDeleted(DEFAULT_YES);
        this.getBaseMapper().updateById(entity);

        // 3.判断是否更新父节点
        this.checkAndModifyParentLeafNodeToYes(entity);

    }

    /**
     * 获取 parentPath下的 最大的子节点路径
     *
     * @param entity     树状对象,提供其他查询条件,
     * @param parentPath 指定父路径,可能和 entity.getParentPath 不一致
     * @return 当前最大的子节点路径
     */
    default String getCurrentMaximumChildPath(T entity, String parentPath) {

        LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
                .orderByDesc(T::getPath)
                .last("limit 1");

        if (StrUtil.isEmpty(parentPath)) {
            wrapper.isNull(T::getParentPath);
        } else {
            wrapper.eq(T::getParentPath, parentPath);
        }

        T maxPathChild = this.getBaseMapper().selectOne(this.doAppend(wrapper, entity));
        return ObjectUtil.isEmpty(maxPathChild) ? null : maxPathChild.getPath();
    }

    /**
     * 获取 parentPath 下的新子路径
     *
     * @param entity     提供其他查询条件
     * @param parentPath 父节点路径
     * @return parentPath 下的最新子路径
     */
    default String getNewChildPath(T entity, String parentPath) {

        String newChildPath;
        String currentMaximumChildPath = this.getCurrentMaximumChildPath(entity, parentPath);
        if (StrUtil.isEmpty(currentMaximumChildPath)) {
            newChildPath = TreeUtil.getFirstChildPath(parentPath);
        } else {
            newChildPath = TreeUtil.getNextChildPath(currentMaximumChildPath);
        }
        return newChildPath;
    }

    /**
     * 查询父节点
     *
     * @param entity 节点对象
     * @return 父节点
     */
    default T doSelectParent(T entity) {
        // default
        LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
        if (StringUtils.isBlank(entity.getParentPath())) {
            throw new ServiceException("该节点为一级节点,不存在父节点!");
        } else {
            wrapper.eq(T::getPath, entity.getParentPath());
        }

        List<T> list = this.getBaseMapper().selectList(this.doAppend(wrapper, entity));

        if (CollectionUtil.isEmpty(list)) {
            throw new ServiceException("未找到父节点或数据传输错误,请联系管理员。");
        }

        if (list.size() > 1) {
            throw new ServiceException("查询到的父节点个数大于1,数据异常,请联系管理员。");
        }
        return list.get(0);
    }

    /**
     * 拼接 wrapper 条件后查询返回查询集合
     *
     * @param wrapper 默认 wrapper
     * @param entity  提供其他查询条件
     * @return 返回查询集合
     */
    default LambdaQueryWrapper<T> doAppend(LambdaQueryWrapper<T> wrapper, T entity) {
        wrapper.eq(T::getDeleted, DbConstants.DEFAULT_NO);
        List<SFunction<T, ?>> selectConditionFunctions = this.appendSelectConditionFunctions();
        if (ObjectUtils.isNotNull(selectConditionFunctions)) {
            selectConditionFunctions.forEach(
                    tsFunction -> wrapper.eq(tsFunction, tsFunction.apply(entity)));
        }
        return wrapper;
    }

    /**
     * 添加查询参数
     * <p>
     * 这个是为了适应不同实体类的树状结构条件:
     * 如果一个实体类只会有一个树状结构,则不需要添加条件,反之则需要传相关条件来确定是那颗树
     * <p>
     * 举个例子:
     * 假如我们的菜单是针对用户的,即每个用户都有一个菜单树,都保存在SysMenu表中
     * 那么我们在编辑一棵树时就需要传相关用户id来唯一确定一棵树。
     * <p>
     * 换句话说,在只有一棵树的情况下,path和parentPath都是唯一的,
     * 当存在一个或多个维度(上例中的用户)来区分树时,path和parentPath就不是唯一的了。
     * <p>
     * 所以这里需要传入对应的条件使得根据该条件能够查询到唯一的数据。
     * 按上述例子来说,就应该传入Arrays.asList(SysMenu::getUserId);
     *
     * @return 用于Wrapper中的参数方法集合
     */
    List<SFunction<T, ?>> appendSelectConditionFunctions();

    /**
     * 修改父节点的是否叶子节点为 false
     *
     * @param entity 树状对象
     */
    default void modifyParentLeafNodeToFalse(T entity) {
        T parent = this.doSelectParent(entity);
        parent.setLeafNode(false);
        this.getBaseMapper().updateById(parent);
    }

    /**
     * 判断是否修改父节点的是否叶子节点为 true
     *
     * @param entity 树状对象
     */
    default void checkAndModifyParentLeafNodeToYes(T entity) {

        if (StrUtil.isNotEmpty(entity.getParentPath())) {
            T parent = this.doSelectParent(entity);
            if (ObjectUtil.isNotEmpty(parent)) {
                LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
                        .eq(T::getParentPath, entity.getParentPath());
                Integer childCount = this.getBaseMapper().selectCount(this.doAppend(wrapper, entity));
                parent.setLeafNode(childCount.equals(1));
                this.getBaseMapper().updateById(parent);
            }
        }
    }

    /**
     * 前置任务,比如名字判重等工作
     *
     * @param entity 树状对象
     */
    default void beforeTreeAddProcessor(T entity) {

    }

    /**
     * 后置任务
     *
     * @param entity 树状对象
     */
    default void afterTreeAddProcessor(T entity) {

    }

TreeUtil工具类

import cn.hutool.core.util.ObjectUtil;
import com.copm.ifm.base.basic.exception.ServiceException;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
import com.copm.ifm.base.basic.pojo.vo.TreeVO;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;

import java.text.DecimalFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;

/**
 * 树状结构工具类
 *
 * @author zzf
 * @date 2020/7/30 11:14
 */
public class TreeUtil {

    public static final int DEFAULT_PATH_LENGTH = 4;

    private static final DecimalFormat format = new DecimalFormat("0000");

    public static final String FIRST_NODE = "0001";

    /**
     * 递归生成树状结构
     */
    public static TreeVO getTree(Collection<? extends BaseTreePO> list) {
        TreeVO treeVO = TreeVO.head();
        TreeUtil.getTree(list, treeVO);
        return treeVO;
    }

    /**
     * 递归生成树状结构
     */
    private static void getTree(Collection<? extends BaseTreePO> list, TreeVO parentNode) {
        List<TreeVO> childList = new ArrayList<>();
        list.forEach(s -> {
            if (isChild(s, parentNode)) {
                TreeVO treeVO = doToDTO(s);
                if (!s.getLeafNode()) {
                    getTree(list, treeVO);
                }
                childList.add(treeVO);
            }
        });
        //排序
        if (ObjectUtil.isNotEmpty(childList)) {
            childList.sort(Comparator.comparingInt(TreeVO::getOrderNum));
        }
        parentNode.setChildList(childList);
    }

    /**
     * 对象转换
     */
    private static TreeVO doToDTO(BaseTreePO treeDO) {
        TreeVO treeVO = new TreeVO();
        BeanUtils.copyProperties(treeDO, treeVO);
        return treeVO;
    }

    /**
     * 判断是否为其子节点
     *
     * @param treeDO     树状实体对象
     * @param parentNode 父级节点对象
     */
    private static boolean isChild(BaseTreePO treeDO, TreeVO parentNode) {
        if (StringUtils.isEmpty(parentNode.getPath())) {
            return StringUtils.isEmpty(treeDO.getParentPath());
        } else {
            return parentNode.getPath().equals(treeDO.getParentPath());
        }
    }

    /**
     * 获得第一个子集的编号
     */
    public static String getFirstChildPath(String parentPath) {
        return (parentPath == null ? "" : parentPath) + format.format(1);
    }

    /**
     * 获得下一个子集的编号
     *
     * @param prevChildPath 上一个子集的编号
     */
    public static String getNextChildPath(String prevChildPath) {
        Number num;
        try {
            num = format.parse(prevChildPath.substring(prevChildPath.length() - DEFAULT_PATH_LENGTH));
        } catch (ParseException e) {
            throw new ServiceException("编码生成错误,请联系管理员~");
        }
        return getParentPath(prevChildPath) + format.format(num.intValue() + 1);
    }

    /**
     * 获得父路径
     */
    public static String getParentPath(String childPath) {
        int level = childPath.length() / DEFAULT_PATH_LENGTH;
        return childPath.substring(0, (level - 1) * DEFAULT_PATH_LENGTH);
    }

    /**
     * 根据路径获得层级
     */
    public static Integer getLevel(String path) {
        if (path == null) {
            return 0;
        }
        return path.length() / DEFAULT_PATH_LENGTH;
    }

    /**
     * 获取路径当层的序号
     */
    public static String getPathEnd(String path) {
        return path.substring(path.length() - DEFAULT_PATH_LENGTH);
    }

}

使用

SysMenu实体类

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
import com.copm.ifm.base.basic.pojo.MenuContentList;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;


/**
 * 系统菜单表
 *
 * @author zzf
 * @date 2020-12-14
 */
@Getter
@Setter
@ToString
@TableName(value = "sys_menu", autoResultMap = true)
@ApiModel("系统菜单表")
public class SysMenu extends BaseTreePO {

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    @ApiModelProperty("名称 - 繁体")
    private String ftName;

    @ApiModelProperty("名称 - 英文")
    private String enName;

    @ApiModelProperty("路径")
    private String url;

    @ApiModelProperty("操作权限")
    @TableField(typeHandler = JacksonTypeHandler.class)
    private MenuContentList operations;

}

SysMenuService服务类

import com.copm.ifm.base.service.TreeService;

/**
 * 系统菜单表 服务类
 *
 * @author zzf
 * @since 2020-12-14
 */
public interface SysMenuService extends TreeService<SysMenu> {
}

SysMenuServiceImpl服务实现类

/**
 * 系统菜单表 服务实现类
 *
 * @author zzf
 * @since 2020-12-14
 */
@Service
public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> implements SysMenuService {


    @Override
    public List<SFunction<SysMenu, ?>> appendSelectConditionFunctions() {
        return null;
    }
}

SysMenuController 接口类

import com.copm.ifm.base.basic.ServiceResponse;
import com.copm.ifm.base.service.base.BaseController;
import com.copm.ifm.servers.sys.client.SysMenuClient;
import com.copm.ifm.servers.sys.entity.SysMenu;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;


/**
 * 菜单表相关接口类
 * 
 * @author zzf
 * @date 2020/12/21 15:18
 */
@Slf4j
@Api(tags = "菜单/sys-menu")
@RestController
@RequestMapping("/sys-menu")
public class SysMenuController extends BaseController implements SysMenuClient {

    @Autowired
    SysMenuService service;

    @ApiOperation("移动节点")
    @PutMapping("/move-node")
    public ServiceResponse<?> testMove(@ApiParam("需要移动的节点id")
                                       @RequestParam Integer movedNodeId,
                                       @ApiParam("新的父节点的id")
                                       @RequestParam(required = false) Integer newParentNodeId) {
        service.moveNode(movedNodeId, newParentNodeId);
        return success();
    }

    @ApiOperation("删除节点")
    @DeleteMapping("/by-id")
    public ServiceResponse<?> testDelete(@RequestParam Integer id) {
        service.deleteNode(id);
        return success();
    }
}

注意

  作者在实现过程中踩了个坑,就是在TreeService中执行T::getPath时,会报错:MybatisPlusException: can not find lambda cache for this entity [com.copm.ifm.base.basic.pojo.BaseTreePO]

  解决方案是为BaseTreePO创建个Mapper类,原因也写在了代码中,更多请看作者的另外一篇博客:MybatisPlusException: can not find lambda cache for this entity[]异常解决

package com.copm.ifm.base.service.base.mapper;

import com.baomidou.mybatisplus.core.MybatisMapperAnnotationBuilder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.copm.ifm.base.basic.pojo.BaseTreePO;

/**
 * 因为MP3.2+之后不会缓存实体类的父类字段信息,所以在使用泛型的Lambda表达式时会报错:
 * {@code MybatisPlusException: can not find lambda cache for this entity [com.copm.ifm.base.basic.pojo.BaseTreePO]}
 * 原因是在执行{@link com.baomidou.mybatisplus.core.toolkit.LambdaUtils#getColumnMap(Class)}时
 * {@code COLUMN_CACHE_MAP}中没有{@link BaseTreePO}的信息
 *
 * <p>
 * 根据源码
 * {@link com.baomidou.mybatisplus.core.MybatisMapperRegistry#addMapper(Class)}
 * {@link MybatisMapperAnnotationBuilder#parse()}方法的加载逻辑
 * <p>
 * 他会将所有扫描到的mapper中的泛型({@link BaseMapper<Class>}中的Class,即实体类)的字段信息缓存到
 * {@link com.baomidou.mybatisplus.core.toolkit.LambdaUtils}中的{@code COLUMN_CACHE_MAP}中。
 * 但是MP3.2+之后不会加载父类的信息,所以{@code COLUMN_CACHE_MAP}中没有相关缓存,就报错了。
 * <p>
 * 因此我们单独为{@link BaseTreePO}添加一个的Mapper类,这样他就会缓存该类的信息了。
 */
public interface BaseTreeMapper extends BaseMapper<BaseTreePO> {
}

总结

  其实作者最开始只封装了BaseTreePOTreeUtil两个,后面发现重复的工作还是特别多,就花了些封装了最核心的TreeServiceTreeService里面的代码还有很多可以优化的地方,比如查询时只查询有用的几列,修改时创个新对象,只修改变动的列,这样性能方面就更好了。

  不过刚通过测试就怀着激动的心情波不急待的想分享出来,还没来得及优化这些,后续有优化会同步更新到代码仓库里。

  最后还想说一句,大家在编码过程中一定要想尽办法“偷懒”,想办法封装,一步步的抽离共性,这样会让我们的编码效率/质量得到极大的提升!

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
MyBatis Plus 是在 MyBatis 的基础上进行的二次封装,提供了一套通用CRUD(增删改查)代码操作。通过使用 MyBatis Plus,我们可以大大简化数据库操作的代码编写,并且极大提升了开发效率。 在 MyBatis Plus 中,我们只需要定义一个实体类,通过注解的方式指定数据表的映射关系,就可以自动生成包括增加、删除、更新、查询的通用操作方法。同时,MyBatis Plus 也支持使用简单的条件构造器进行查询操作,并且可以通过代码生成器生成实体类、Mapper 接口、XML 映射文件等。 对于增加操作,我们只需要调用对应的插入方法,并传入需要插入的实体对象即可。对于删除操作,我们可以通过主键或者条件构造器来删除满足条件的记录。更新操作使用了乐观锁的机制,在更新时会检查数据的版本号,防止并发导致的数据错误。查询操作可以通过主键、条件构造器或者分页进行查询,为了提高性能,还可以使用缓存进行查询结果的缓存。 MyBatis Plus 不仅提供了基本的 CRUD 操作,还可以进行条件构造器的自定义,支持 Lambda 表达式进行查询操作,提供了分页、乐观锁等功能,灵活性很高。同时,MyBatis Plus 也支持自定义 SQL 语句的编写,可以满足复杂业务场景下的需求。 总的来说,MyBatis Plus 提供了一套通用CRUD 代码操作,大大简化了数据访问层的开发工作,提升了开发效率和代码质量。它是一个成熟而强大的持久层框架,被广泛应用于各种 Java 后端项目中。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值