ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot+Vue家谱管理系统:树形家族树递归查询与渲染实践

Spring Boot+Vue家谱管理系统:树形家族树递归查询与渲染实践 简介一份面向高校毕业设计及课程设计场景的家谱管理系统新版源码专注解决家族信息数字化管理与传承需求。系统包含家族成员档案录入、多条件检索、亲属关系可视化展示、家族文化资料保存等功能模块覆盖从数据维护到展示的完整业务链路适合Java开发学习者作为实战参考。压缩包共117个文件以71个Java源码、32个PNG图片、XML与Eclipse工程配置等为主整体体积仅2.46MB文件分类明确便于按功能模块研读。源码基于典型Java Web结构组织实体类、控制层、页面资源对应清晰工程化程度高便于二次开发。目前已有55人学习下载可同时作为毕设案例和课程设计的直接参考。读者能从中掌握实体建模、控制器路由、页面展示等分层实现思路借助注释详尽的源码快速理解家族图谱管理系统的核心设计尤其是关系可视化与多条件检索部分可直接迁移到类似信息管理项目作为从入门到进阶的练习蓝本或进一步扩展。1. 家谱管理系统为什么每年毕设都有人做每年都有人翻车家谱管理系统新版源码是毕设选题里最经典的“看起来容易、做起来全是坑”的项目之一。很多同学拿到题目时以为就是普通的增删改查录几个人名挂个父子关系再套个管理系统模板。等真正动手才发现家谱的核心不是“管理”而是“把一棵树在网页上画出来并且这个树的层级可以是无限深的”。这是一个典型的“数据关系 前端展示”双重难点比学生管理系统、图书馆管理系统高出一个档次同时也正好落在课设和毕设的验收标准里。这篇笔记我按自己做过的一版方案来讲技术栈选的是 Java Spring Boot MyBatis Plus MySQL Vue 2 Element UI这也是目前毕设源码里最常见、最好抄作业的组合。读完你能得到一套可以直接改的建表 SQL、后端递归/迭代查询逻辑、前端渲染方案以及答辩时最容易被问住的几个边界问题的标准答法。2. 为什么选 Spring Boot Vue而不是 JSP 或纯前端2.1 三个技术栈的真实对比别只看“哪个简单”家谱系统在毕设场景里出现过三种常见形态最老的是 JSP Servlet MySQL第二类是 Spring Boot 单体应用 Thymeleaf 模板第三类就是前后端分离的 Spring Boot Vue。我的建议是如果学校没强制要求 JSP直接选第三类。前端分离最大的好处不是“时髦”而是家族树渲染这件事必须要有独立的前端空间。JSP 的服务端渲染做递归树很痛苦每展开一层就要刷新一次页面体验和代码复杂度双输。Vue 配合递归组件可以一次把整棵树渲染完父子节点直接用组件递归调用自己代码量少一半。而且答辩时老师问“前端和后端怎么通信的”你答“通过 RESTful API 接口JSON 格式传参”比 JSP 里 request 转发清晰得多。后端选 Spring Boot MyBatis Plus 的理由更直接毕设时间紧MyBatis Plus 的代码生成器和 BaseMapper 帮你省掉大量单表 CRUD 代码你省下来的时间应该花在家谱的树形查询和世代计算上那才是这个题目的核心得分点。2.2 新版源码里“新版”到底新在哪标题里的“新版”一般指三件事。第一是前端从 Element UI 换到 Ant Design Vue 或者 Naive UI界面更现代第二是后端接入了 Redis 缓存把家族树的 JSON 缓存起来减少重复查询数据库第三是支持了 Excel 导入导出可以把线下家谱的世系表格批量导入系统。这三项里前端组件库的替换基本不影响业务代码API 设计不变就能平滑迁移。Redis 缓存和 Excel 导入导出是实打实的加分项值得写进开题报告和答辩 PPT。不过要注意缓存家族树时只要有人新增了家族成员就必须立刻删掉对应家族的缓存 Key否则会出现“新增了人但树里看不到”的翻车现场。-- 家族表 CREATE TABLE family ( id BIGINT PRIMARY KEY AUTO_INCREMENT, family_name VARCHAR(50) NOT NULL COMMENT 家族姓氏, ancestor_name VARCHAR(50) NOT NULL COMMENT 始祖姓名, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT家族信息表; -- 成员表 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, family_id BIGINT NOT NULL COMMENT 所属家族ID, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT NOT NULL DEFAULT 1 COMMENT 1男 0女, birth_date DATE NULL COMMENT 出生日期, death_date DATE NULL COMMENT 逝世日期, father_id BIGINT NULL COMMENT 父节点ID为NULL表示是始祖, spouse_id BIGINT NULL COMMENT 配偶成员ID, sort_order INT DEFAULT 0 COMMENT 兄弟间排序越小越靠前, generation INT DEFAULT 1 COMMENT 世代数始祖为第1代, remark VARCHAR(500) COMMENT 生平简介, UNIQUE KEY uk_father_name (father_id, name), KEY idx_family_generation (family_id, generation) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT家族成员表;这段建表 SQL 是整个项目的根基。member 表把 father_id 作为自关联外键这是家谱系统的核心设计——不是用一张独立的关系表存“父子关系”而是直接在成员行上挂父节点 ID。这样插入一个新人只需要知道他的父亲是谁不需要额外写关联表记录。参数说明gender 用 TINYINT 而不是 VARCHAR省空间且查询快sort_order 字段特别重要它决定了同一个父亲下多个儿子的显示顺序很多源码里没有这个字段导致兄弟排序变成按 id 排序和真实家谱的长幼次序对不上generation 字段是冗余字段会带来写操作时的一致性成本但换来的收益是查“某代有哪些人”不再需要递归一张 SQL 直接解决。这个取舍在后面的查询里你会看到价值。3. 建好表之后先把家族树的查询写对递归与迭代的两种实现3.1 递归查询代码最短但面试官必然会追问“深度”MyBatis Plus 里最直接的树形查询是写一个 Service 方法先查根节点father_id IS NULL再递归查每个节点的子节点。代码大概长这样public ListMemberVO buildTree(Long familyId) { // 1. 查出家族所有成员一次 IO 拉全 ListMember allMembers memberMapper.selectList( new LambdaQueryWrapperMember() .eq(Member::getFamilyId, familyId) .orderByAsc(Member::getSortOrder) ); // 2. 按 fatherId 分组父节点 ID 作为 Map 的 Key MapLong, ListMember childrenMap allMembers.stream() .filter(m - m.getFatherId() ! null) .collect(Collectors.groupingBy(Member::getFatherId)); // 3. 找出根节点始祖借此把全体成员串成树 ListMemberVO roots new ArrayList(); for (Member member : allMembers) { if (member.getFatherId() null) { roots.add(buildNode(member, childrenMap)); } } return roots; } private MemberVO buildNode(Member member, MapLong, ListMember childrenMap) { MemberVO vo new MemberVO(); BeanUtils.copyProperties(member, vo); ListMember children childrenMap.get(member.getId()); if (children ! null !children.isEmpty()) { for (Member child : children) { vo.getChildren().add(buildNode(child, childrenMap)); } } return vo; }这段代码里最关键的一行是childrenMap allMembers.stream().filter(...).collect(Collectors.groupingBy(Member::getFatherId))。它先把所有成员从数据库一次性查出来在内存里按 father_id 分组然后递归拼接。这样做避免了在循环里反复调用 selectById是树形查询的必会写法。参数说明LambdaQueryWrapper 的 eq 方法传的是字段和值MyBatis Plus 会帮你转成WHERE family_id ?。orderByAsc(Member::getSortOrder) 保证同一个父亲下的多个儿子按 sort_order 升序排列对应你在表结构里设置的长幼次序。这个递归在内存里做性能取决于家族总人数一个 2000 人的家族查完整棵树大约 50 到 100 毫秒毕设规模完全够用。注意这段代码有一个隐藏问题所有成员的父子关系必须是单线的也就是说一个人只能有一个 father_id。这对家谱是符合的但对过继、承嗣这类特殊家族关系就不够了。如果题目里要求处理“过继子同时挂在生父和养父名下”这个表结构要再加一个 relation_type 字段来区分属于进阶需求答辩时可以提一句你的设计考虑到了但没做留作后期扩展。3.2 迭代查询用栈代替递归防止大谱系栈溢出如果家族人数上万递归会顶到 JVM 默认的栈深度一般是 512KB 栈空间差不多几千层导致 StackOverflowError。虽然家谱很难有几千层但毕设答辩时老师为了表现专业很可能问一句“如果人很多会不会有问题”。这时候你给出迭代版本就能加分。public ListMemberVO buildTreeByStack(Long familyId) { ListMember allMembers memberMapper.selectList( new LambdaQueryWrapperMember().eq(Member::getFamilyId, familyId) ); // 构建 id → Member 的映射方便通过 fatherId 找到父节点对象 MapLong, MemberVO voMap new HashMap(); for (Member m : allMembers) { voMap.put(m.getId(), new MemberVO(m)); } ListMemberVO roots new ArrayList(); // 用栈来模拟递归的顺序先处理的节点先放入栈 DequeMemberVO stack new ArrayDeque(); for (Member m : allMembers) { if (m.getFatherId() null) { MemberVO root voMap.get(m.getId()); roots.add(root); stack.push(root); } } // 第二次遍历把每个节点挂到父亲的 children 里 for (Member m : allMembers) { if (m.getFatherId() ! null) { MemberVO parent voMap.get(m.getFatherId()); if (parent ! null) { parent.getChildren().add(voMap.get(m.getId())); } } } // 排序对每个父亲的 children 按 sortOrder 整理 voMap.values().forEach(v - v.getChildren().sort(Comparator.comparingInt(MemberVO::getSortOrder)) ); return roots; }该写法的核心思想是第一遍遍历找根第二遍遍历把所有节点挂到对应的父节点下面最后统一排序。整个过程没有递归调用时间复杂度 O(n)只做两次全量遍历任何规模都不会爆栈。这个版本相比递归版的好处是逻辑直白和面试官解释时说“两次遍历第一次归档父节点第二次组装 children”三句话讲清。坏处是代码读起来不如递归版优雅而且它要求voMap里必须包含所有成员如果数据库里存在悬空的子节点father_id 指向一个不存在的成员parent ! null的判断会跳过它导致这个节点在树里消失——这在递归版里是同样的行为但迭代版更好定位问题。3.3 查询“某某的第几代孙”generation 字段的用武之地家谱系统被问得最多的查询是“查某人的所有后代”或者“查家族第五代有哪些人”。如果没有 generation 字段你得从目标节点开始往下递归到指定深度有了它直接查family_id AND generation 5就能得到所有第五代成员。但要理解一个前提generation 必须在插入成员时就计算准确否则整个查询全错。public void addMember(Long fatherId, String name, Integer gender, String birthDate) { // 1. 查父亲他的世代数加 1 就是新成员的世代数 Member father memberMapper.selectById(fatherId); if (father null) { throw new BizException(父节点不存在); } Member child new Member(); child.setFatherId(fatherId); child.setFamilyId(father.getFamilyId()); child.setName(name); child.setGender(gender); child.setGeneration(father.getGeneration() 1); // 2. 如果父亲存在“正妻”则自动设为配偶简化逻辑 memberMapper.insert(child); // 3. 删除该家族的缓存树防止旧树继续被前端读到 redisTemplate.delete(family:tree: father.getFamilyId()); }addMember 的逻辑重点在于 generation 的继承父亲第几代儿子就是父代 1。fatherId 为空时则是入谱始祖generation 强制为 1。注意第 3 步的缓存删除这是最容易漏的一步漏了以后前端看到的树永远是第一次查询的旧数据排查半天才发现是缓存没清。参数说明birthDate 在这里用 String 接收方便前端把表单里的日期字符串直接传来。严格做法是定义 LocalDate 类型 DateTimeFormat 注解但毕设项目为了减少类型转换的报错概率先接收 String 再统一格式也不是不行。只不过答辩老师如果问到类型安全的问题你得诚实说是简化处理。4. 前端家族树渲染三种方案从最简单到最有排面4.1 方案一Element UI 的 el-table 树形表格最保底如果你前端基础一般最稳的方案是直接用 Element UI 的 el-table开启tree-props和row-key就能显示父子层级。el-table :datatreeData row-keyid :tree-props{ children: children } default-expand-all el-table-column propname label姓名 width180/el-table-column el-table-column propgeneration label世代 width80/el-table-column el-table-column propgender label性别 width80/el-table-column el-table-column propbirthDate label出生日期/el-table-column /el-table这里只需要保证后端返回的数据结构里每一个节点都带children数组el-table 会自动展开成层级行。default-expand-all 表示默认展开全部但如果家族人数很多建议改成按需展开性能会好些。row-key必填且必须是唯一的 id否则表格的展开状态会错乱。这个方案的优点是后端返回什么树前端就显示什么树不用自己写递归渲染缺点是表格形式的家族树不够直观传统家谱的“世系图”展示效果会打折。如果你的开题报告里写了“实现直观的家族树状展示”建议至少看一眼方案二。4.2 方案二Vue 递归组件渲染树形图核心加分项用一个 TreeNode.vue 组件递归渲染自己这是我认为家谱系统里最值得写进代码的亮点。template div classtree-node div classnode-content clickhandleClick(node) span classnode-name{{ node.name }}/span span classnode-gen第{{ node.generation }}代/span /div div classnode-children v-ifnode.children node.children.length TreeNode v-forchild in node.children :keychild.id :nodechild :levellevel 1 selecthandleChildSelect / /div /div /template script export default { name: TreeNode, props: { node: { type: Object, required: true }, level: { type: Number, default: 0 } }, methods: { handleClick(node) { this.$emit(select, node); }, handleChildSelect(node) { this.$emit(select, node); } } }; /script递归组件的核心在于组件模板里又引用自己TreeNode 的内部如果还有 children就再渲染一个 TreeNode。这个模式在 Vue 2 和 Vue 3 里都成立区别只是 name 声明方式。level 参数用来控制缩进比如padding-left: level * 20px这样层级越深节点越往右缩进自然形成树形结构。点击事件需要逐层向上 emit否则子组件的点击事件父组件接收不到。实际编码时注意一个坑递归组件必须在自己的name字段里声明组件名否则 Vue 2 里直接TreeNode会报 Unknown Custom Element。Vue 3 的script setup语法里递归组件要单独处理如果模板里直接用文件名当作组件名需要额外显式导入自己。4.3 方案三Canvas / SVG 横排世系图最吃力但最亮眼如果你的毕设需求里写了“世系图可视化”Canvas 方案是终极形态横轴表示世代纵轴表示同一代中的兄弟序列节点之间用贝塞尔曲线连接。视觉效果像真正的家谱挂轴但代码量大约 500 到 800 行包含节点坐标计算、连线路径、缩放平移和节点点击弹窗。没有足够时间不要轻易碰做出来确实有排面做不出来就是给自己挖坑。5. 家谱系统避坑清单5 个最常见的毕设翻车点5.1 兄弟排序错乱忘了处理 sortOrder现象同一个父亲下三个儿子录入顺序明明是 A、B、C树上却显示 C、A、B。原因没有给 son 排序设计字段查询时直接按数据库默认返回或者按 id 排序。在多次删除和插入后 id 顺序和长幼顺序脱钩。解决建表时保留sort_order字段前端新增成员表单里加一个“排行”数字输入默认取当前兄弟最大排序 1后端查询时在内存中排序对应 3.2 节代码里最后的Comparator.comparingInt(MemberVO::getSortOrder)。5.2 修改成员父亲后generation 变成脏数据现象把某成员从第 3 代改挂到第 5 代节点下保存后再查“第 4 代有哪些人”名单里多出这个人或缺失这个人。原因只更新了 father_id没有级联更新后代所有成员的 generation。比如某个节点有 6 个子孙改挂后这 6 个子孙的 generation 全部沿用了旧的代数。解决写一个 updateFather 的 Service 方法更新父节点时先查出该节点所有后代的 id 列表再批量更新 generation。批量更新公式新 generation 新父亲的 generation 1 原 generation - 原父亲的 generation - 1。Transactional(rollbackFor Exception.class) public void changeFather(Long memberId, Long newFatherId) { Member member memberMapper.selectById(memberId); Member oldFather memberMapper.selectById(member.getFatherId()); Member newFather memberMapper.selectById(newFatherId); int delta (newFather.getGeneration() 1) - member.getGeneration(); // 获取所有后代 id ListLong descendantIds getDescendantIds(memberId); if (!descendantIds.isEmpty()) { memberMapper.batchUpdateGeneration(descendantIds, delta); } member.setFatherId(newFatherId); member.setGeneration(newFather.getGeneration() 1); memberMapper.updateById(member); }该方法的batchUpdateGeneration一条 SQL 就能把所有后代的 generation 统一加上差值 delta。比如原第 3 代节点改挂到第 5 代父亲下新 generation 变为 6所有子孙的 generation 也整体 3。Transactional 保证两步操作要么都成功要么都回滚避免出现改了父亲没改代数的半成品状态。5.3 配偶关系只在女方字段上存导致查询时男方看不到配偶现象成员详情页里丈夫能看到妻子但妻子的详情页里看不到丈夫或者列表里的成员没有配偶列。原因表里只有 spouse_id只存了一个方向没有在插入时做双向绑定。解决插入或更新配偶关系时同时更新双方的 spouse_id。写一个简单的双向绑定逻辑不需要建立额外关联表。public void bindSpouse(Long memberIdA, Long memberIdB) { Member a memberMapper.selectById(memberIdA); Member b memberMapper.selectById(memberIdB); a.setSpouseId(b.getId()); b.setSpouseId(a.getId()); // 注意同时更新两条记录 memberMapper.updateById(a); memberMapper.updateById(b); }5.4 家谱树渲染空白后端返回了数据但前端树是空的现象浏览器 Network 里能看到 /api/member/tree 返回了一长串 JSON但页面上树是空白或只有第一代。原因最常见的两个。其一后端返回的字段名是 childrenList 而不是 childrenel-table 的 tree-props 默认找 children 字段找不到就返回空。其二后端返回的是把根节点包在 { root: [...] } 里的对象前端直接 :datatreeData 拿到的是对象而不是数组。解决后端保证返回最外层是数组字段名统一叫 children前端拿到响应后先打印console.log(res.data)确认结构再赋值。不确认结构就赋值是前端联调时最常见的翻车习惯。5.5 Excel 导入时把始祖导成了普通成员现象用户导入一张线下家谱表第一行是“始祖 张三”导入后却找不到张三这个人或者张三变成了某人的儿子。原因导入逻辑默认所有人都需要 father_id始祖行没有父亲被 filter 条件过滤掉了。解决导入时先识别 father_id 为空的行拆分为“先插入始祖”和“再插入非始祖”两批。注意导入时需要先全部插入完成再拿数据库生成的 id 去建立父子关系顺序不能反。6. 进阶收尾从家族树到人物关系图谱把毕设做出延展性如果你的毕设时间有余量我建议在家谱系统里补一个“人物关系图谱”页面输入任意两个成员的名字系统计算并展示他们之间的亲属关系路径比如“张三 — 父 — 张五 — 子 — 李四”。这个功能本质上是在 family_id 范围内对 father/spouse 关系做一次双向广度优先搜索BFS代码量不大但答辩时非常加分。public ListString findRelationPath(Long familyId, Long memberIdA, Long memberIdB) { // 先查出家族内所有成员与关系边构建邻接表 // key 是成员 idvalue 是邻居节点列表 MapLong, ListLong graph new HashMap(); ListMember all memberMapper.selectList( new LambdaQueryWrapperMember().eq(Member::getFamilyId, familyId) ); for (Member m : all) { ListLong neighbors graph.computeIfAbsent(m.getId(), k - new ArrayList()); if (m.getFatherId() ! null) { neighbors.add(m.getFatherId()); graph.computeIfAbsent(m.getFatherId(), k - new ArrayList()).add(m.getId()); } if (m.getSpouseId() ! null) { neighbors.add(m.getSpouseId()); graph.computeIfAbsent(m.getSpouseId(), k - new ArrayList()).add(m.getId()); } } // BFS 找最短路径 MapLong, Long prev new HashMap(); QueueLong queue new LinkedList(); queue.offer(memberIdA); prev.put(memberIdA, -1L); while (!queue.isEmpty()) { Long current queue.poll(); if (current.equals(memberIdB)) break; for (Long next : graph.getOrDefault(current, Collections.emptyList())) { if (!prev.containsKey(next)) { prev.put(next, current); queue.offer(next); } } } // 回溯路径 ListString pathNames new ArrayList(); if (prev.containsKey(memberIdB)) { Long step memberIdB; while (!step.equals(-1L)) { Member m memberMapper.selectById(step); pathNames.add(m.getName()); step prev.get(step); } Collections.reverse(pathNames); } return pathNames; }回到标题本身家谱管理系统这套源码真正的价值不在代码行数多而在它同时涉及了树形数据结构、递归/迭代两种遍历法、缓存的正确失效时机、Excel 批量导入和前端组件递归。把这几个点讲明白答辩基本立于不败之地。这也是我在做这个版本时最深的体会毕设项目的难度不是复杂度而是你把最简单的关系在边界条件下做对的能力。希望这篇笔记能帮你少走几步弯路把时间花在真正能体现工作量的事情上。本文还有配套的精品资源点击获取
返回列表