9.2 KiB
web-admin 侧栏非叶子目录 + 右侧叶子组 Tab 设计
日期:2026-10-04
状态:待审阅(实现前须产品确认本文)
交互 Demo:docs/demos/sidebar-group-leaf-tabs-demo.html
范围:web-admin 全站业务菜单导航。默认壳层为单列树形侧栏(MenuTypeEnum.LEFT),只展示非叶子;无独立一级竖向 Tab。不含小程序、不含后端菜单 API 变更。
背景与目标
当前侧栏同时展示目录和叶子,内容区一次只挂一个页面(默认 showWorkTab: false)。用户希望:
- 左侧树只展示非叶子;
- 点击最后一层非叶子后,右侧用 Tab 展示该层全部可见叶子;
- 换另一个最后一层非叶子时,整组 Tab 替换;
- 默认只加载第一个(或 URL 已指向的)叶子,点哪个 Tab 再加载哪个界面。
报销管理是典型三层(报销管理 → 基础配置 → 费用项目等)。CRM 为三层:CRM管理 → 产品/报价单/合同/客户分组 → 组内叶子;点击分组后右侧 Tab 只含该组可见叶子。
已确认决策
| 项 | 决策 |
|---|---|
| 覆盖范围 | 整站业务菜单,不是只做报销 |
| Tab 形态 | 整组固定 Tab:不能单个关闭;换组整组替换 |
| 刷新 | 以 URL 为准,停在当前叶子或隐藏页 |
| 加载 | 默认只挂载当前路由页;点过的同组叶子 keep-alive;换组清空缓存 |
| 一级下全是叶子 | 一级当作最后一层非叶子;Tab 只含有权限且未隐藏的叶子 |
隐藏页(isHide) |
不进 Tab 条;内容区切到该页;父叶子 Tab 保持选中 |
| 未保存拦截 | 不做;与现有点侧栏换页一致 |
| 实现路径 | 方案 1:布局层组 Tab 条 + 现有单 router-view + keep-alive。不打开、不改造 WorkTab 打开记录 |
| 单叶子组 | 不显示组 Tab 条,直接展示该叶子页 |
| 中间目录 | 只展开/收起,不改右侧组 |
信息架构
侧栏(非叶子) 内容区
┌─────────────────┐ ┌──────────────────────────────────┐
│ 报销管理 │ 展开 │ Tab: 费用项目 | 工作流(备用金) | … │
│ 基础配置 ● │ ──点击──► │ (仅当前/已访问叶子真正挂载) │
│ 备用金 │ │ 页面:费用项目管理 │
│ 报销 │ └──────────────────────────────────┘
│ CRM管理 │ 展开 │ │
│ 产品管理 ● │ ──点击──► (无 Tab,直接产品管理页) │
│ 报价单管理 ● │ ──点击──► Tab: 报价单管理 | 报价单审批 │
│ 合同管理 ● │ ──点击──► Tab: 合同管理 | 合同审批 | 调价函管理 │
│ 客户管理 ● │ ──点击──► (无 Tab,直接客户管理页) │
└─────────────────┘
可见菜单
参与侧栏和组 Tab 计算的节点必须同时满足:
- 当前用户有权限;
meta.isHide不为 true;- 非登录/403/404/门户首页等无业务组页面。
非叶子 / 最后一层非叶子
在过滤后的树上:
- 非叶子:至少有一个可见子节点。
- 最后一层非叶子:可见子节点里没有目录(子节点都是叶子),或为「混合节点」(见下)。
- 中间非叶子:可见子节点里至少还有一个非叶子。点击只展开树,不
push叶子、不换组。
混合节点
可见子节点里既有目录又有叶子时:该节点标题点击打开其直接叶子组成的 Tab 组;展开箭头只负责展开/收起子目录。子目录仍留在侧栏,可再点进各自的组。否则这些叶子无法从侧栏进入。
当前组
由当前路由向上找最近的「会打开 Tab 的节点」(最后一层非叶子或混合节点)。该节点的直接可见叶子= Tab 全集,顺序与菜单 order 一致。
隐藏页不进入全集。隐藏页通过 meta.activePath(没有则用 path 最长前缀匹配组内叶子)归属到某一个叶子,该叶子高亮。
首页、登录、错误页、无匹配组:不渲染组 Tab。
交互
- 点击最后一层非叶子 / 混合节点:若当前路由已属于该组(含其隐藏页),不打断 URL;否则
router.push该组第一个可见叶子。 - 点击组内 Tab:
router.push该叶子 path。已是当前叶子则不重复导航。 - 换组:Tab 条换成新组;上一组
keep-alive清空。 - 进入隐藏页:Tab 条不变;父叶子高亮;内容为隐藏页。返回/提交成功仍由页面自己
push回列表。 - 从隐藏页点另一个叶子 Tab:直接切走,无确认框。
- 浏览器刷新 / 书签:按 URL 恢复页面;侧栏展开到对应组并选中;画出整组 Tab;只挂载 URL 这一页(不必先经过组内第一个叶子)。
- 直达无权限 URL:现有 403/跳转;不生成假 Tab。
加载与缓存
当前 ArtPageContent 仅在 meta.keepAlive === true 时缓存,且 showWorkTab === false 时 keepAliveMax 为 1。组 Tab 需要覆盖这一策略:
| 场景 | 行为 |
|---|---|
| 首次进入组 | 只创建当前路由组件 |
| 同组点过的可见叶子 | 纳入 keep-alive include(不要求页面 meta.keepAlive) |
| 换组 | 清空本组 include,卸载上一组叶子 |
| 隐藏页 | 默认不纳入组 include;若该页自身 keepAlive: true(如用户详情)则仍按其 meta |
| 上限 | keepAliveMax ≥ 当前组可见叶子数(本组同时缓存的叶子不超过组大小) |
组 Tab 的缓存与 WorkTab 的 opened 列表无关。showWorkTab 保持默认 false。
视觉
- 组 Tab 在内容区顶部:顶栏/面包屑之下、业务页之上。
- Fresh Mint:对齐案例仓储「库存信息表」
.subnav——白底描边圆角轨道(10px)+ 内嵌 8px 圆角项;选中为--primary-bg填充与--primary-dark字色;无关闭图标。不得使用案例#167c4a。 - 侧栏宽度、一级竖 Tab、树区规则仍遵循
docs/web-admin-ui-spec-fresh-mint.md§3;本能力只改树节点过滤与内容区 Tab。 - 规范文档与 OpenSpec
ui-conventions实现时同步补「侧栏非叶子 + 组 Tab」,避免与「默认不展示 WorkTab」条文冲突:组 Tab 不是 WorkTab。
架构(方案 1)
菜单树 + 当前 route
│
▼
resolveLeafGroup(route, menu) → { groupId, groupNode, leaves[], activeLeafId }
│
├─► Sidebar:只渲染非叶子;高亮 groupNode
└─► GroupLeafTabs:渲染 leaves[];点击 push
│
▼
ArtPageContent
KeepAlive include = 当前组已访问叶子组件名
RouterView 仍只挂当前匹配页
建议落点(实现时可微调路径,职责不变)
| 单元 | 职责 |
|---|---|
utils/router/leaf-group.ts(名称可调整) |
纯函数:判非叶子、解析当前组、隐藏页归属、第一片叶子 path |
侧栏(art-sidebar-menu / 双列子树 / PORTAL 子导航) |
过滤叶子;最后一层/混合节点点击走「进组」而不是进单个叶子 |
GroupLeafTabs 组件 |
固定 Tab 条,无关闭 |
ArtPageContent |
挂 Tab 条;按组维护 keep-alive include / max |
隐藏路由 meta.activePath |
补齐常用表单/详情,避免高亮丢失 |
| UI 规范 + OpenSpec | 记录交互,明确组 Tab ≠ WorkTab |
不改业务列表内部 CRUD;不改后端 sys_menu 结构(除非实现时发现个别 activePath 必须写在路由 meta,前端即可)。
错误与边界
- 组内可见叶子数为 0:不画 Tab,走现有空菜单/无权限。
- 门户首页、全屏页(
isFullPage):无组 Tab。 - 混合节点:Tab 仅含该节点直接叶子,不含孙节点。
- 角标:若叶子在侧栏被拿掉,原叶子角标改挂到其所属最后一层非叶子(或 Tab 项上)。实现时与现有
menu-badge对齐,避免角标消失。
验收
- 报销 → 基础配置:三个 Tab,只加载第一个;点第二个才出页面;再点备用金,上一组卸载,换成申请/审批/发放。
- 刷新停在当前叶子或隐藏表单 URL;Tab 条仍是该组;父叶子高亮。
- CRM:点一级只出现有权限且未隐藏的叶子;进报价表单时「报价单管理」仍高亮;返回列表不丢组。
- 无权限叶子不出现;直达无权限 URL 仍 403。
- 同组来回切换已访问叶子不重复打首屏列表请求(网络面板可核对);换组后旧页请求不再因缓存而显示。
非目标
- 不恢复可关闭、可积压的 WorkTab 历史页签。
- 不做全局表单脏检查。
- 不把隐藏页(表单/详情)做成弹窗。
- 不强制给 CRM/系统管理补二级目录(一级可当最后一层)。
- 不改小程序导航。