首页
/ Halo 控制台分类树管理重构指南:从「前端建树 + 批量补丁」到「后端权威树 + 单点位移」

Halo 控制台分类树管理重构指南:从「前端建树 + 批量补丁」到「后端权威树 + 单点位移」

2026-09-08 20:03:13作者:平淮齐Percy

导读

这篇文章围绕 Halo 开源建站工具中一个已经归档并完全落地的架构变更——simplify-console-category-tree-management(2026-07-03)展开。该变更把控制台(Console)分类树的管理边界整体后移:后端新增"权威分类树读取 + 单分类相对位置移动"两类 Console API,前端则从"拉平列表自行建树、拖拽后全量重算 priority、并发发送批量 JSON Patch"的旧模式,收敛为"消费后端树、只保留交互状态、一次拖拽对应一次 position 更新"。读完本文,你将掌握这套接口的端点语义、请求/响应 DTO、后端校验与优先级重算的实现原理,以及前端 usePostCategory() 共享数据源如何被重构消费,可直接复用到 Halo 插件或二次开发中。

核心原始资料见 proposal.mddesign.mdtasks.md

一、变更动机:为什么控制台分类树必须回归后端权威

Halo 的分类层级在完成 parent 引用迁移后,运行时事实来源已经是 Category.spec.parent(完整行为约束见 category-hierarchy 规范)。但在此变更之前,控制台的分类管理仍然把大量层级逻辑留在前端 Vue 工具函数里,典型的旧流程是:

  1. 拉取扁平的 Category 列表;
  2. 在前端手工拼装一棵"可编辑树";
  3. 用户拖拽后,前端重新计算每个兄弟节点的 spec.priority
  4. 再把整棵树重新压平回 Category 列表;
  5. 最后并发发送一批 parent / priority 的 JSON Patch 请求。

这个流程有明确的痛点:

  • 前端重复实现"规范树构建"。树的排序规则、非法父链处理等本应是后端唯一权威逻辑,却在 UI 里再写一遍,容易与后端(主题侧、公共 API 侧)不一致;
  • 存在"批量保存的部分成功状态"。并发多请求 Patch 只要有一个失败,就会留下只有部分节点被更新的中间态,且前端难以回滚;
  • 职责边界不清Category 扩展对象里混入了"树的 children 视图数据"。

设计文档 design.md 指出:控制台菜单层级(menu hierarchy)此前已经完成了同样的边界重构——由后端 Console API 返回规范树并接受单次相对移动请求,前端只保留交互状态。分类管理应当跟进这套模型,唯一差异是分类没有"所属菜单"这样的宿主字段,移动目标纯粹由 parentName 描述。

二、变更范围总览(What Changes)

proposal 中列出的改动面可以归纳为五条主线:

  1. 新增 Console API:读取一棵权威分类树 + 按相对位置移动一个分类;
  2. 树的响应形态:树节点返回为 { category, children }children 只是视图数据,不再写回 Category.spec.children
  3. 职责后移:拖拽层级校验(成环、自引用、非法相对兄弟)与兄弟 priority 重算全部移交后端;
  4. 前端重构:分类管理视图与共享的分类选择组件改为消费后端树,仅在需要搜索/展示时本地做 flatten;
  5. 删除旧的持久化逻辑:移除前端"重置 priority、把树压平成 Categories、批量打 parent/priority JSON Patch"的层级保存代码。

同时明确保留的既有行为包括:分类的创建、更新、删除、UI 权限字符串均不改变。proposal 的 Capabilities 一节也说明:这次没有新增任何 capability,被修改的只有一个——category-hierarchy 中"Console 分类管理"从"前端建树 + 批量层级补丁"变为"后端提供权威树 + 单条目位置更新"。

三、核心设计决策:四个关键选择

设计文档记录了四条支撑本次改动的关键决策,理解它们才能理解接口为什么这样设计。

决策一:独立的两类端点,而非"提交整棵树"

变更新增两个 Console 端点,路径如下:

  • GET /apis/api.console.halo.run/v1alpha1/categories/-/tree:列出可编辑的权威分类树;
  • PUT /apis/api.console.halo.run/v1alpha1/categories/{name}/position:按目标父节点与下一个兄弟节点移动单个分类。

之所以采用"树只读 + position 单点写",是因为一次用户拖拽在本质上就是"一次相对移动"。设计文档明确指出,曾考虑过"仍返回树、但保留前端批量 Patch"的折中方案,但它只移除了部分复杂度,且部分保存失败的问题依然存在,因此被否决。

决策二:用专用 Console DTO,而非复用它

新的 CategoryTreeNode DTO 形状是 { Category category; List<CategoryTreeNode> children; },而不是主题侧的 CategoryTreeVo。原因在于:

  • CategoryTreeVo 面向主题输出,带有 parentName、文章数投影等主题关注点;
  • Console 编辑场景需要的是"原始 Category 扩展对象 + 只读的 children 子节点";
  • 曾考虑过直接在 Category 对象上扩展 children 字段返回,但这会模糊"扩展对象状态"与"可编辑树视图数据"的边界。

从源码看,这一决策落实为两个独立类:CategoryTreeNode.javacategory + children 字段均标记为必填)与 CategoryPositionRequest.java(两个字段均可为空):

@Schema(name = "CategoryPositionRequest")
public record CategoryPositionRequest(
        @Nullable String parentName, // 目标父 Category 的 metadata.name,null 表示根级
        @Nullable String beforeName  // 目标前一个兄弟 Category 的 metadata.name,null 表示追加到末尾
) {}

决策三:校验与优先级重算全部交给后端服务

position 请求体只有 parentNamebeforeName 两个可选字段,含义如下:

  • 缺省 parentName → 目标为根级;
  • 缺省 beforeName → 追加到目标兄弟列表末尾;
  • 同时缺省 → 移到根级末尾。

后端服务负责全部校验:父分类必须存在、目标不能是自己或自己的后代、beforeName 必须在移动生效后的目标兄弟列表中是真实兄弟。随后服务为受影响的兄弟列表重算连续的整数 priority,并且只更新那些 parent 或 priority 确实发生变化的 Category。反之,如果让前端先算好所有新 priority 再提交 Patch 列表,就"保留了当前的失败模式,并在 UI 里复制了规范排序规则"。

决策四:前端只从后端树派生视图辅助数据

共享状态 usePostCategory() 应调用 Console tree API,同时暴露未压平的 categoriesTree 和压平后的 categories。搜索、选中值解析、分类筛选、键盘导航、路径标签等纯视图需求,仍可用前端的 flatten / 路径辅助函数;拖拽则应保存"拖拽前的树快照",从前后两棵树推导出唯一一条 position 请求,成功后用响应树直接替换本地状态。之所以不让各消费方各自调不同端点,是为了避免分类状态被切碎、并让树构建逻辑残留在 categorySelect 等次级消费者里。

四、后端实现剖析:源码级验证

接口与服务的实际实现位于 CategoryEndpoint.javaCategoryConsoleService.java(同目录下的 MenuConsoleService.java / MenuItemPositionRequest.java 是先行落地的菜单同构实现,可对照阅读)。

4.1 端点注册:Springdoc 函数式路由

CategoryEndpoint 实现了 CustomEndpoint 接口,在 endpoint() 中注册两条路由,均带 OpenAPI 元数据与 tag CategoryV1alpha1Console

  • ListCategoryTree:GET categories/-/tree,响应为 CategoryTreeNode 数组;
  • UpdateCategoryPosition:PUT categories/{name}/position,path 参数 name 为分类的 metadata.name,请求体为 CategoryPositionRequest,响应同样是完整的权威树(数组)。

注意响应返回的是移动后整棵规范树,而不是 200 + 空体——这正是"前端用响应树替换本地树"的依据,见源码中的 flatMap(tree -> ServerResponse.ok().bodyValue(tree))。请求体缺失时,端点会抛出 ServerWebInputException("Request body is required.")

4.2 读树:从扁平列表构造权威树

listTree()listAll 所有 Category,再交给静态方法 listToTree 构造树,核心逻辑有两点值得展开:

其一,非法父链一律当根处理,保证 Console 始终可用。 validParentMap 只会把"父节点存在、且父名不等于自己"的节点挂到父节点下;父缺失、父为自身、父不存在的情况都会被自动提升为根。随后 cyclicNames 用路径探测(visiting 集合)找出所有成环节点,成环节点同样在 listToTree 中作为根渲染。这与 spec 中"parent 引用缺失/自引用/指向不存在/成环时把该分类当作根分类渲染"的要求一一对应,也能防止导入的脏数据把整棵子树"藏"起来。

其二,兄弟排序遵循规范顺序。 sortTree 使用 defaultCategoryComparator() 递归排序:先按 spec.priority 升序,再按创建时间(creationTimestamp,空值排后),最后按 metadata.name,与 spec 中"按 priority、创建时间戳、metadata name 排序"的约束完全一致。

树中的 children 只是本次响应中的视图数据,服务永远不会把它写回 Category.spec.childrenspec.children 仍是迁移兼容期内的废弃字段,见 category-hierarchy spec)。

4.3 移树:一次请求内完成的校验、重排与回写

单次移动的核心是 applyMove,执行顺序非常清晰:

  1. 取出被移动分类,不存在则抛 NotFoundException
  2. 校验父目标:不能移到自己下面;父必须存在于列表;通过沿父链上溯的 isDescendant 检查目标是否为自己后代(上溯过程还会发现成环父链并抛错);beforeName 必须存在;
  3. 计算目标插入位置:先取出排除自身后的目标兄弟列表(已按规范排序),默认插到末尾;若给了 beforeName 则插到该兄弟之前;如果 beforeName 不在目标兄弟列表中,抛 ServerWebInputException("Before Category is not a target sibling.")
  4. 重算目标兄弟列表 priorityassignPriorities 从 0 开始连续编号并统一写入 spec.parent
  5. 重算原兄弟列表:仅当父节点发生改变时,对原兄弟列表再做一次同样的连续编号,保证移动后原列表不留空洞;
  6. 只更新变化项:把每个分类与移动前的 HierarchyState(parentName, priority) 快照比对,hasHierarchyChanged 仅过滤出真正变化的分类,再逐个 client.update
  7. 返回权威树:更新完成后由当前内存中的列表重新 listToTree 返回。

其中 assignPriorities 的实现也处理了 spec == null 的边界(先补一个空 CategorySpec 再写 parent/priority)。

4.4 乐观锁重试与冲突上报

由于拖拽是典型的"读-改-写",并发编辑会互相冲突。服务对 updatePosition 做了防御:在 updatePosition 中,move 一旦抛出 OptimisticLockingFailureException,会用 Retry.backoff(1, 100ms) 重试一次;若仍失败(isRetryExhausted),则统一映射为 HTTP 409 Conflict,提示"Category position update conflicted"。前端拿到 409 后应主动 refetch 权威树,而不是保留未被确认的本地拖拽态。

4.5 RBAC:新增资源,但不动 UI 权限语义

本变更在分类角色模板中新增了两类资源:categories/tree(读树)与 categories/position(移动)。需要特别留意的是 design.md 中的显式约束:本变更不把 Console UI 的权限字符串从 system:posts:* 改成 system:categories:*,控制台路由/按钮仍沿用旧的 system:posts:* 权限;这是一次独立的权限清理工作,不能夹带在本变更里。

五、前端重构:从建树者到状态消费者

5.1 usePostCategory() 成为唯一共享数据源

重构后的共享组合式函数位于 use-post-category.ts,它通过生成的 Console API 客户端读取权威树:

const { data } = await consoleApiClient.content.category.listCategoryTree();
return data;

返回给调用方的状态有五个:

  • categoriesTree:后端返回的权威 CategoryTreeNode[],即"可编辑树本身";
  • previousCategoriesTree:每次成功载入树时保存的深拷贝快照,供拖拽后推导位移请求、判断是否发生"单点移动";
  • categories:由 flattenCategoryTreeNodes(tree) 压平得到的扁平 Category[],专供搜索、下拉选中值解析、筛选等视图需要;
  • isLoading:查询加载态;
  • handleFetchCategories(即 refetch)与 setCategoriesTree:用于失败后重新拉取 / 主动替换本地树。

其中 flattenCategoryTreeNodes 等树工具函数集中在 categories/utils/index.ts,并配有独立单元测试(见该目录的 __tests__/)。

值得注意的一个细节:该 composable 的查询还设置了 refetchInterval——当压平后的分类中检测到存在 deletionTimestampstatus.permalink 缺失的"异常分类"时,会以 1 秒间隔自动轮询刷新,保证删除中/发布异常的分类能尽快反映到树上。

5.2 拖拽保存:一次 move,替换整棵响应树

新的保存语义与旧的"批量 Patch"完全不同:

  1. 拖拽开始前保留 previousCategoriesTree
  2. 拖拽结束后,利用树工具从"旧树 → 新树"推导唯一一条请求 { name, parentName, beforeName }
  3. 调用 consoleApiClient.content.category.updateCategoryPosition(name, { parentName, beforeName }) 发送单条 position 请求;
  4. 成功后用响应中返回的权威树替换本地树(同时刷新 previousCategoriesTree);
  5. 如果推导不出"单一移动"能解释的变化(例如一次拖拽竟然影响了多个节点),或请求失败,则直接 refetch 权威树——绝不把歧义/未确认的本地状态当作已持久化的结构

把分类"移动到根级"也不再由前端发 remove /spec/parent 的 JSON Patch,而是发送 parentName 为空的 position 请求,由后端在 assignPriorities 中统一处理。

5.3 消费方全部切换数据来源

tasks.md 明确要求把"前端扁平列表自建树"的路径全部替换为后端 CategoryTreeNode 消费。从源码引用关系看,实际受影响并已完成切换的消费方包括:

同时,旧的"从扁平 Categories 建树、为持久化重置 priority、为批量保存压平树、生成层级 JSON Patch"等辅助函数被移除或停用,前端单元测试也相应改为覆盖 flatten、路径查找、position 请求推导以及"无操作/歧义拖拽"的处理。

六、测试与验证清单

本次改动在质量和回归验证上有明确的收尾标准,全部来自 tasks.md

  • 后端:新增针对树排序、非法父链处理、移到根级、追加移动、兄弟插入、成环拒绝、非法相对节点拒绝、优先级重算、仅持久化变化项以及端点请求/响应行为的测试。实现证据见 CategoryConsoleServiceTest.javaCategoryEndpointTest.java
  • 前端:针对分类树工具与受影响分类选择行为的单元测试;
  • 静态检查与构建pnpm -C ui typecheck && pnpm -C ui lint./gradlew spotlessCheckgit diff --check
  • 规范校验openspec validate simplify-console-category-tree-management --strict

七、风险、权衡与边界

设计文档记录的既有取舍同样值得写代码时注意:

  • 并发冲突:拖拽与批量 Patch 一样都可能冲突,但这里的应对更优雅——后端乐观锁重试一次,仍失败返回 409,前端收到后重新拉取权威树;
  • 脏数据导入:非法父链不会让节点消失,而是被降级为根渲染,保证 Console 始终可操作;
  • 多节点异常变更:前端推导不出"单次移动"就 refetch,拒绝把歧义状态写回;
  • 剩余的边界:分类创建时的初始 priority 仍在前端计算,本变更刻意不新增 Console 专用创建 API,属于留待后续"Console create API"消除的已知剩余项;
  • 明确不做的事:不删除/重写废弃的 Category.spec.children,不复用主题侧 CategoryTreeVo 做 Console 载荷,不改变分类删除语义,不改变 UI 权限字符串。

八、迁移与回滚

变更采用"纯代码级"推进方式,分五步落地(均已在 tasks.md 中完成标记):后端 DTO/服务/端点/RBAC/测试 → 重新生成 OpenAPI 与 UI API 客户端(./gradlew generateOpenApiDocspnpm -C ui api-client:gen)→ 更新 usePostCategory() 与消费方 → 用 position 单点保存替换批量保存 → 删除旧工具并更新单测。整个流程不引入任何数据迁移Category.spec.parent 的既有存储不变,因此回滚只需代码级还原,无需处理存量数据。

如果你打算在插件或二次开发里复刻这套模式,建议直接对照同目录下先行的菜单实现(MenuConsoleService.java)与本变更的分类实现,二者在"规范树接口 + 相对位移接口 + 前端快照推导单点请求"的架构上是完全同构的。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391