ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Halo Console 菜单项父级编辑实战:基于 `spec.parent` 层级模型的移动与位置更新解析

Halo Console 菜单项父级编辑实战:基于 `spec.parent` 层级模型的移动与位置更新解析 Halo Console 菜单项父级编辑实战基于spec.parent层级模型的移动与位置更新解析【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo本篇围绕 Halo 开源项目仓库根目录halo的菜单层级改造中的一项 Console 前端能力展开在菜单项编辑弹窗中直接修改已有 MenuItem 的父级。文章以菜单层级规范menu-hierarchy中「Console edits menu item parents」需求为骨架结合前端弹窗组件与工具函数的真实实现讲解父级选择器的初始化与过滤、与后端位置更新 API 的配合、失败恢复策略以及设计取舍。读完你将掌握 Halo 中菜单项换父级完整的数据流从MenuItem.spec.parent出发、经规范树canonical tree展示、到调用updateMenuItemPosition完成落库。背景为什么需要编辑父级能力Halo 的菜单层级经历过一次数据模型迁移详见 menu-hierarchy 完整规范旧的层级结构存放在Menu.spec.menuItems根级菜单项引用与MenuItem.spec.children子级菜单项引用中迁移后一个 MenuItem 通过两个新字段表达归属与嵌套MenuItem.spec.menuName所属菜单的Menu.metadata.name表示这个菜单项属于哪个菜单MenuItem.spec.parent父菜单项的metadata.name若未设置或为 null则该菜单项是所属菜单的根级菜单项。旧字段Menu.spec.menuItems与MenuItem.spec.children在 API schema 中保留但标记为 deprecated运行时构建菜单树一律以spec.menuNamespec.parent为准绝不回退到旧字段。在这一模型下Halo Console 创建菜单项时早已支持选择父级但编辑一个已存在菜单项时却无法修改其父级——这正是本次变更变更提案要补齐的能力。由于层级已统一收敛到spec.parent编辑弹窗可以安全地开放父级修改而不必触碰任何旧版 children 数组。核心需求五个行为场景本次变更对应的需求文档spec.md将Console 可编辑菜单项父级拆解为五条 MUST/SHALL 语义场景它们是实现与测试的验收基准1. 编辑弹窗展示父级候选管理员在所选菜单中编辑一个已有 MenuItem 时弹窗必须展示父级选择器并满足选择器初始值取自该菜单项当前的spec.parent当spec.parent未设置或为 null 时初始值为根级选项候选父级必须派生自所选菜单的规范树canonical Console MenuItem tree候选必须排除当前菜单项自身及其全部后代防止把菜单项移动到自己的子树下形成环。2. 移动到所选父级管理员改了父级并保存后Console 需要先通过 MenuItem 普通更新 API 保存常规字段再发送一次单个菜单项位置更新携带所选菜单名作为menuName、所选父级作为parentName且beforeName不设置或为 null移动后的菜单项被追加到目标兄弟列表末尾保存成功后用后端返回的规范树刷新/替换本地树。3. 移动到根级若管理员在弹窗中选择根级选项则位置更新中parentName不设置或为 null移动后的菜单项成为所选菜单的根级菜单项。4. 父级未变化若保存时选中的父级与原来一致则不发位置更新请求仅保存普通字段原有层级位置保持不变。5. 父级移动失败时的降级若普通字段保存成功、而随后的位置更新失败Console 必须重新加载所选菜单的规范树不保留未被后端确认的父级选择为持久化结构不回滚已经保存成功的普通菜单项字段不做部分成功补偿以后端为唯一事实来源。实现剖析MenuItemEditingModal 的数据流前端实现集中在 MenuItemEditingModal.vue。先看其弹窗挂载阶段对表单与父级状态的初始化onMounted(() { if (props.menuItem) { formState.value cloneDeep(props.menuItem); const { targetRef } formState.value.spec; if (targetRef) { selectedRefName.value targetRef.name; selectedRefKind.value targetRef.kind as string; } } selectedParentMenuItem.value props.parentMenuItem?.metadata.name || props.menuItem?.spec.parent || ; originalParentMenuItem.value props.menuItem?.spec.parent || ; setFocus(displayNameInput); });两处关键细节selectedParentMenuItem当前选中与originalParentMenuItem原父级分别记录后者用于保存时判断父级是否真的变化父级默认值优先取当前菜单项的spec.parent为空则退化为根级空字符串与场景 1 的要求完全对应。excludedParentNames计算属性则为过滤候选提供输入const excludedParentNames computed(() { return props.menuItem?.metadata.name ? [props.menuItem.metadata.name] : []; });结合 utils/index.ts 的filterMenuItemTreeNodes一旦某节点名字命中排除集合整棵子树即被移除——因此只需传入当前菜单项名字即可同时排除它自身及其全部后代满足排除自身 后代的规范要求。保存流程先普通字段、再条件性移动handleSaveMenuItem是保存主流程见 MenuItemEditingModal.vue。核心逻辑if (isUpdateMode) { const { data } await coreApiClient.menuItem.updateMenuItem({ name: formState.value.metadata.name, menuItem: formState.value, }); const positionRequest buildMenuItemParentMovePosition( formState.value.metadata.name, originalParentMenuItem.value, selectedParentMenuItem.value ); if (positionRequest) { try { const { data: menuItemTree } await consoleApiClient.menuItem.updateMenuItemPosition({ name: positionRequest.name, menuItemPositionRequest: { menuName: props.menu.metadata.name, parentName: positionRequest.parentName, beforeName: positionRequest.beforeName, }, }); emit(saved, data, menuItemTree); } catch (e) { console.error(Failed to update menu item parent, e); emit(saved, data); Toast.error(t(core.common.toast.save_failed_and_retry)); return; } } else { emit(saved, data); } }将其与规范场景一一对应场景判定依据动作父级未变buildMenuItemParentMovePosition返回undefined只保存普通字段不发位置更新父级改变返回带parentName的请求调updateMenuItemPositionbeforeName为空 → 追加到目标兄弟末尾移动到根级parentName为空位置更新请求中parentName同样为空菜单项成为根级位置更新失败异常分支仍emit(saved, data)通知父组件刷新规范树并 Toast 报错不回滚普通字段menuItemPositionRequest的数据契约对应 api-client 模型export interface MenuItemPositionRequest { /** target next sibling MenuItem metadata.name, or null to append */ beforeName?: string; /** selected Menu metadata.name */ menuName: string; /** target parent MenuItem metadata.name, or null for root */ parentName?: string; }关键工具函数判断父级是否真的变了buildMenuItemParentMovePosition 负责把原父级 vs 新父级翻译成是否发起移动export function buildMenuItemParentMovePosition( menuItemName: string, previousParentName?: string, selectedParentName?: string ): MenuItemMovePosition | undefined { const normalizedPreviousParentName previousParentName || undefined; const normalizedSelectedParentName selectedParentName || undefined; if (normalizedPreviousParentName normalizedSelectedParentName) { return undefined; } return { name: menuItemName, parentName: normalizedSelectedParentName, beforeName: undefined, }; }通过|| undefined把空串统一归一到 undefined从而让根级→根级父级A→父级A都落到父级未变化、不发位置更新分支只有真正不同的父级才构造请求且beforeName恒为空把精确排序的职责让渡给拖拽drag-and-drop流程。树感知的父级选择器父级下拉由两个组件协同渲染MenuItemParentSelect.vue通过createInput注册为 FormKit 输入menu-item-parent-select接收menuItemTree与excludedNames两个 prop内部用filterMenuItemTreeNodes预过滤后以树节点为单位渲染下拉MenuItemParentSelectNode.vue递归渲染节点及其children保证选择项在 UI 上保持层级缩进让同一菜单的、可选的规范树直观呈现。该选择器采用了菜单域内专用输入的设计——因为父级候选永远来自当前所选菜单的规范树本质是菜单域语义不适合下沉到 ui/src/formkit 的通用输入体系对比可参考 category 等其它域组件的组织方式。选择器的点击语义同样支持回到根级再次点击当前选中项会将其清空值变为空串即根级。设计取舍为什么走位置更新 API 而非直接改 spec.parentdesign.md 记录了四条关键决策理解它们有助于读者在自己实现同类树节点换父级功能时避开坑复用updateMenuItemPosition承载父级变更。若在updateMenuItem时直接改formState.spec.parent会绕过后端移动校验且不会规范化同级兄弟的 priority复用位置更新 API 则把校验与排序归一逻辑放在拖拽同一条后端路径上。父级移动一律追加到目标兄弟末尾beforeName: null。换父级是简单放置操作精确排序继续由拖拽承担避免在弹窗里重复造一套排序 UI 与测试面。父级树在前端过滤。候选直接来自规范树并排除自身与后代让永远无法保存成功的选项不出现在 UI 中同时后端校验仍是权威兜底——即便前端过滤有遗漏非法层级写入也会被拒绝。普通字段保存与父级移动保持两个独立操作。两者之间可能部分成功此时刷新规范树并把后端当作事实来源而不是在前端尝试回滚已保存的普通字段要实现全有或全无语义需要后端提供复合更新接口这超出该 UI 变更范围。边界与风险规范树永远是事实来源从 menu-hierarchy 完整规范 还能提炼出一组与本功能强相关的后端保障对应Console menu item tree APIs与Console menu management writes the new hierarchy fields两个需求树的读取是只读视图Console 树 API 返回的children只是视图数据绝不回写MenuItem.spec.children非法 parent 引用被容错为根级spec.parent缺失、指向自身、指向所选菜单之外、或形成父链环时相关菜单项会被渲染为根级其余合法后代继续展示排序规则固定同菜单同父级的兄弟项按 priority、创建时间戳、metadata.name 排序兄弟 priority 在成功移动后被重算为从 0 开始的连续整数且只持久化spec.parent或spec.priority发生变化的菜单项所有权不可迁移位置更新要求被移动菜单项的spec.menuName必须等于请求中的menuName移动过程绝不改写spec.menuName菜单项不能靠拖拽/改父级跨菜单搬家。正因为这些后端约束始终在线前端编辑弹窗只需做好候选过滤 条件触发 失败刷新三件事非法层级结构的防御可以放心交给服务端。测试与验收本次变更附带的工程验收项记录在 tasks.md可作为复现验证的清单组件层更新 MenuItemEditingModal.vue使其在 update 与 create 两种模式下都展示树感知的父级选择器父级过滤候选保留根级与同菜单菜单项排除当前项及全部后代原父级追踪记录原父级保证未变更时不会误触发层级移动保存行为父级不变 / 改到其它父级 / 改到根级三种分支行为正确且移动只在父级真正改变时调用updateMenuItemPosition树刷新保存成功或父级移动失败后都以后端返回的规范树为准刷新界面。其中工具函数的既有单测位于 utils/tests/index.spec.ts任务要求补充针对父级选项过滤排除自身与后代以及父级不变/变更/改为根级的聚焦前端测试并依次通过pnpm -C ui format、相关菜单前端单测、pnpm -C ui typecheck pnpm -C ui lint以及openspec validate support-menu-item-parent-editing --strict校验需求与实现一一对应。小结编辑菜单项父级是 Halo 菜单层级迁移parent-reference 模型收尾的一次小而完整的前端增强。其工程价值可归纳为三条原则语义收敛层级只认spec.menuNamespec.parent前端绝不写废弃的Menu.spec.menuItems/MenuItem.spec.children单一权威无论拖拽还是弹窗改父级都汇入同一条updateMenuItemPosition后端路径排序与校验由后端统一完成前端始终以后端返回的规范树为事实来源失败可恢复面对普通字段已保存、父级移动失败的部分成功用刷新树 不保留未确认选择 不回滚普通字段来保持界面与存储的一致性避免在只读语义不清时做危险补偿。对需要实现同类树编辑分类、菜单、导航的开发者而言这条树感知选择器 单点位置更新 规范树回填的实现路径可以直接作为可复用范本。【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表