分类和标签都给文章提供组织方式,但分类有父子层级、最大深度、环路和删除约束;标签通常是扁平集合,还可能已被文章引用。本文对照 Vue 管理后台的两个页面,说明如何把契约数据转换为可操作的管理界面。

本文代码快照:Vue 后台提交
d3ee41a。主要参考src/pages/categories/CategoryTreePage.vue、src/pages/tags/TagListPage.vue、对应 API 模块和冻结 OpenAPI。
前言:树形 UI 不等于后端一定存树
管理后台展示分类树,读者容易直接假设数据库和 API 也必须返回同一种嵌套结构。事实上,接口可以按契约返回嵌套 children,也可以返回平面节点及 parentId;页面负责在必要时转换数据。把视图结构与持久化模型分开,能避免为了 UI 而随意改契约。
这个项目的公开分类树端点返回递归 CategoryNode[],其中包含 children。编辑和删除端点则以单个分类实体及 parentId 工作。页面读取树用于展示和构造父级选项,提交时发送契约规定的字段。标签端点返回扁平列表,无父子关系,因此使用普通表格。

从嵌套树投影成表格行
分类页用递归 walk 遍历 CategoryNode[],生成带 depth、parentId 和 hasChildren 的 Row:
type Row = Category & { depth: number; hasChildren: boolean }
const rows = computed(() => {
const output: Row[] = []
const walk = (nodes: CategoryNode[], depth = 0, parentId: number | null = null) => {
nodes.forEach((node) => {
if (node.id == null || node.name == null || node.slug == null) return
output.push({
id: node.id,
name: node.name,
slug: node.slug,
description: node.description,
parentId,
sortOrder: node.sortOrder,
depth,
hasChildren: Boolean(node.children?.length),
})
walk(node.children ?? [], depth + 1, node.id)
})
}
walk(tree.data.value ?? [])
return output
})
这个过程叫视图投影:原始树仍由查询缓存持有,表格行是由它计算出来的展示结构,不需要再复制一份全局状态。depth 用来缩进和显示层级,parentId 用于编辑时回填父级,hasChildren 决定是否允许删除。
如果 UI 改为真正的树形表格,投影结构可能不需要;若 API 返回扁平节点,则可以先按 parentId 构建树,也可以直接计算缩进显示。选择应服务于交互和数据规模,不是因为“分类叫树”就必须用某个组件。

父级选择必须阻止成环和超深
编辑分类时,不能把自身设为自己的父级,也不能把某个子孙节点设为父级,否则会形成环。页面遍历树,定位当前编辑节点及其所有后代,加入 blocked 集合;这些节点不会出现在父级选项中。另有最大深度 CATEGORY_MAX_DEPTH=4,已处于最深层的节点不能再作为父级。
父级下拉使用缩进名称呈现层级,顶部选项表示根分类。提交时将 'root' 转换为契约中的 parentId: null,其他项转换成数字 ID。UI 的前置限制能减少明显错误,但后端仍必须验证深度和不成环,因为请求可以绕过浏览器。
深度计算容易出现 off-by-one:项目把根节点 depth=0 显示成第 1 层,允许的最大层数为 4,因此可成为 parent 的行满足 depth < CATEGORY_MAX_DEPTH - 1。用具体树逐层验证比只看公式更稳妥。
删除限制从契约转成可理解的操作反馈
分类删除要求没有子分类且没有文章关联,不做级联删除。页面发现 hasChildren 时禁用删除按钮,并在删除确认文案中说明分类下存在文章时后端会拒绝。后端返回冲突后,页面把错误显示给操作者。
为什么同时做前端限制和服务端检查?前端可以提前解释,后端承担最终数据完整性约束。仅禁用子分类删除不代表已验证没有关联文章;接口可能仍返回 409,前端应保留错误反馈,而不是在成功前乐观地移除行。
分类保存成功后失效 ['categories'] 查询,编辑表单和文章编辑器的选项可以在下次使用时获取新树。删除同样失效该 key,保持缓存与后端一致。
分类表单字段与 slug 校验
表单提交前验证名称非空,slug 匹配 ^[a-z0-9-]{1,64}$。创建或更新都把空描述映射为 null,根节点 parentId 映射为 null,同时携带 sortOrder。编辑操作先从投影行回填当前字段,保存后按照新父级关系刷新树。
slug 是 URL/标识字段,不宜在 UI 里仅靠提示文字;前端校验即时给反馈,后端仍要保证唯一性及格式。若契约要求局部更新时省略字段表示“保留”,而传 null 表示清空,表单 payload 必须精确区分它们。此项目当前页面选择显式回传 parentId,避免更改其他字段时意外变根。
标签为什么是平表?
标签页使用 GET /tags 返回扁平 Tag 数组,列出名称、slug 和文章数。创建/编辑共用表单;删除按钮在 articleCount > 0 时禁用,避免前端发出契约明确拒绝的删除操作。保存/删除成功后失效 ['tags'] 缓存。
标签不需要递归 walk,也不需要 parentOptions。分类和标签都可被文章关联,但数据结构和约束不同,不应该为了“管理页面统一”把标签也做成树,或强行共享一个过度通用的表单模型。
文章编辑器中标签列表作为多选/可输入选项;分类则需要树形路径或层级缩进来帮助用户理解。两个页面分别维护业务化交互,但可复用相同 slug 规则、API 风格和成功反馈模式。
数据量增大之后的取舍
当前分类深度上限为四级、分类通常是站点内容结构,先把全树读取后在前端投影可以很简单。若节点规模很大,或者不同角色只能看到部分节点,就需要分页、搜索、按父级延迟加载或服务端树查询;此时不能无条件下载整棵树。
排序也有独立语义:sortOrder 是节点字段,页面显示并编辑它。若同级排序规则由服务端处理,前端就不应仅按当前行顺序假设保存后仍保持顺序。需要在契约中确认排序更新方式,再设计拖动排序或数字输入。
分类树修改涉及父子与文章关联,建议对纯转换函数写单测:树变平、depth 递增、parentId 继承、环路排除和最大深度边界。组件测试再覆盖保存 payload 与禁用删除状态。不要只测试一个固定 mock,漏掉多层后代。
小结:契约决定结构,视图层做恰当投影
分类树要处理递归遍历、层级深度、父级候选、环路保护、子分类和文章关联约束;标签以扁平集合管理,重点是字段校验和文章引用保护。视图投影可以辅助表格展示,但不应污染 API 契约或复制为第二份事实数据。
下一篇将把视线扩展到用户:同一后台如何同时容纳管理员/编辑的用户治理能力和会员自己的资料、密码、通知、点赞、收藏页面,并确保路由边界清楚。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/FungLeo/article/details/170772790




