{T}

顶部 Tabs 快捷导航与 Pinia 路由状态管理

概述

这一节在 Header 下方增加一组顶部 Tabs 快速导航,定位是“已访问页面集合”的快速切换入口(类似 TagsView),而不是重复一份左侧菜单。核心架构动作不是渲染一个 Tabs 组件,而是把“当前访问过哪些页面”从视图局部状态提升为全局 Pinia store 状态,再由路由变化统一驱动收集。本版只完成数据链路(收集并展示),点击切换和刷新持久化留到后续。

学习目标

  • 理解顶部 Tabs 的职责是“已访问页面的快速返回与并行切换”,而非替代左侧菜单。
  • 给业务组件类型加业务前缀(如 HeaderTabsProps),避免与 Element Plus 内置 TabsProps 命名冲突。
  • Partial<TabsProps> 承接三方组件官方 props,降低包装组件的传参负担。
  • 把路由变化通过 watch(route) 流入 tabsStore,并用 addRoute 去重收集。
  • 认清 Pinia 默认内存态、刷新丢失是正常现象,持久化是独立知识点。

一、顶部 Tabs 是已访问页面的快速切换入口

顶部 Tabs 位于 Header 下方、面包屑下方,承担的不是替代左侧菜单,而是给用户补一层更高频的页面切换入口:点过的页面收集成一组 Tabs 放在顶部,后续无需回左侧菜单找路径,直接点顶部切换。这种设计在后台系统常见,更接近 TagsView / 多页签工作台。适合放“已访问页面”,核心是快速返回和多页面并行切换,后续可扩展关闭、刷新、右键菜单。

二、避开 Element Plus 内置类型名,加业务前缀

自定义 Tabs 组件时,业务侧 props 类型名容易和官方 TabsProps 冲突。解决方式直接:组件名、类型名、props 类型名都加业务前缀,如 HeaderTabsHeaderTabsProps。业务组件类型最好带业务前缀,避免和三方库公共类型撞名;若组件名已用 Tabs,props 类型也最好同步叫 HeaderTabsProps,保持一致。

ts
interface HeaderTabsProps extends Partial<TabsProps> {
  data: AppRouteMenuItem[]
}

三、包装三方组件时用 Partial<T> 承接官方 props

包装 el-tabs 时不必强制传完整 TabsProps,更合理的做法是继承官方 props 但让它们都变成可选,即用 Partial<TabsProps>。这样官方 Tabs 的默认 props 不需要每次全传,外层只补自定义新增字段(如 data)。这是包装三方组件时的通用技巧,不只适用于 Tabs;新增业务字段仍可保持必填,若某个官方 props 强依赖也可在扩展接口里单独收紧。

四、el-tab-panelabelname 职责分离

把顶部 Tabs 每项绑定到路由数据时,实际在做两层映射:label 用来显示用户看到的标题,name 用来作为当前激活项或切换目标的唯一标识。当前项目里 labelitem.meta.title(要走国际化),nameitem.name(深层嵌套路由更稳定)。item.name 可能是 string | symbol | undefined,通常需要断言或兜底。

Vue SFC
<el-tab-pane
  v-for="item in data"
  :key="item.name"
  :label="$t(String(item.meta?.title ?? ''))"
  :name="item.name as string"
/>

五、路由变化流入 Pinia store,而非组件本地数组

顶部 Tabs 本质上属于整个后台布局的共享导航状态,会被 Header、内容区、路由切换共同消费,所以比起在 Header 里写本地数组,更合理的方案是建一个 tabsStore 统一记录 tabscurrent

ts
export const useTabsStore = defineStore("tabs-state", () => {
  const tabs = ref<AppRouteMenuItem[]>([])
  const current = ref("")
  return { tabs, current }
})

只要一个状态会被多处消费,就更适合放到 store;这也是后续做关闭页签、缓存策略和持久化的前提。

六、addRoute 的核心是去重后再收集

addRoute 看起来简单,本质是“访问记录收集器”:先判断当前路由是否已在 tabs 集合里,没有才加入。去重条件通常用 item.name === route.name

ts
function addRoute(route: AppRouteMenuItem) {
  const exists = tabs.value.some((item) => item.name === route.name)
  if (exists) return
  tabs.value.push({ ...route })
}

建议 action 名称直接体现意图(addRoute / removeRoute / setCurrent);去重字段要和 Tabs 的 name 保持一致;用浅拷贝存储路由数据,避免后续引用被意外联动修改。

七、watch(route) 是驱动 Tabs 收集的最稳定接线点

什么时候往顶部 Tabs 加一项?答案是路由发生变化时——因为用户点左侧菜单、面包屑、顶部 Tabs,最终都会落成一次路由变化。所以最稳定的接入点不是点击事件本身,而是监听路由结果:

ts
watch(
  () => route.name,
  () => {
    tabsStore.addRoute(currentRoute)
    tabsStore.current = route.name as string
  },
  { immediate: true }
)

监听“路由结果”比监听“某个点击入口”更稳,统一覆盖多种导航方式。current 最好和 el-tabsv-model 用同一份标识;不加 immediate 首屏进入时顶部 Tabs 可能为空。

八、store 默认内存态,刷新消失不是 bug

页面切换时顶部 Tabs 正常增加,但刷新后全部没了——这不是 Pinia 失效,而是默认 store 状态存在内存里,刷新就重新初始化。本节只完成了路由状态收集,还没接持久化,这也是“缓存持久化”被留到下一节的原因。想保留刷新后的 Tabs,需要接本地存储或持久化插件。

九、分阶段接线:先显示,再交互,最后持久化

本版只完成了“访问记录显示”和 current 同步,点击切换和刷新持久化尚未完成。推进顺序很合理:第一步先把访问记录显示出来,第二步接点击切换,第三步做持久化和关闭策略。这比一开始把所有能力混在一起清晰得多。只要数据链路先打通,后面补点击事件和持久化都不晚。


常见问题

问题原因解决方案
自定义 Tabs 组件类型报冲突业务 props 类型名与官方 TabsProps 撞名改成 HeaderTabsProps 等带业务前缀名称
el-tab-panename 类型报错路由 name 可能是 string | symbol | undefined统一转 string 并兜底
顶部 Tabs 一开始是空的路由监听只在变化后执行,没首屏初始化watch{ immediate: true }
同一路由被重复添加addRoute 没先去重route.name 为基准先 some(),再决定是否 push
刷新后 Tabs 全丢了Pinia 默认内存态,未持久化下一节接本地存储或持久化插件
Tabs 能显示但点击没反应只完成数据收集,未接切换事件v-modeltab-click 接命名路由跳转

延伸阅读