顶部 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 类型名都加业务前缀,如 HeaderTabs、HeaderTabsProps。业务组件类型最好带业务前缀,避免和三方库公共类型撞名;若组件名已用 Tabs,props 类型也最好同步叫 HeaderTabsProps,保持一致。
interface HeaderTabsProps extends Partial<TabsProps> {
data: AppRouteMenuItem[]
}三、包装三方组件时用 Partial<T> 承接官方 props
包装 el-tabs 时不必强制传完整 TabsProps,更合理的做法是继承官方 props 但让它们都变成可选,即用 Partial<TabsProps>。这样官方 Tabs 的默认 props 不需要每次全传,外层只补自定义新增字段(如 data)。这是包装三方组件时的通用技巧,不只适用于 Tabs;新增业务字段仍可保持必填,若某个官方 props 强依赖也可在扩展接口里单独收紧。
四、el-tab-pane 的 label 与 name 职责分离
把顶部 Tabs 每项绑定到路由数据时,实际在做两层映射:label 用来显示用户看到的标题,name 用来作为当前激活项或切换目标的唯一标识。当前项目里 label 用 item.meta.title(要走国际化),name 用 item.name(深层嵌套路由更稳定)。item.name 可能是 string | symbol | undefined,通常需要断言或兜底。
<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 统一记录 tabs 和 current。
export const useTabsStore = defineStore("tabs-state", () => {
const tabs = ref<AppRouteMenuItem[]>([])
const current = ref("")
return { tabs, current }
})只要一个状态会被多处消费,就更适合放到 store;这也是后续做关闭页签、缓存策略和持久化的前提。
六、addRoute 的核心是去重后再收集
addRoute 看起来简单,本质是“访问记录收集器”:先判断当前路由是否已在 tabs 集合里,没有才加入。去重条件通常用 item.name === route.name。
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,最终都会落成一次路由变化。所以最稳定的接入点不是点击事件本身,而是监听路由结果:
watch(
() => route.name,
() => {
tabsStore.addRoute(currentRoute)
tabsStore.current = route.name as string
},
{ immediate: true }
)监听“路由结果”比监听“某个点击入口”更稳,统一覆盖多种导航方式。current 最好和 el-tabs 的 v-model 用同一份标识;不加 immediate 首屏进入时顶部 Tabs 可能为空。
八、store 默认内存态,刷新消失不是 bug
页面切换时顶部 Tabs 正常增加,但刷新后全部没了——这不是 Pinia 失效,而是默认 store 状态存在内存里,刷新就重新初始化。本节只完成了路由状态收集,还没接持久化,这也是“缓存持久化”被留到下一节的原因。想保留刷新后的 Tabs,需要接本地存储或持久化插件。
九、分阶段接线:先显示,再交互,最后持久化
本版只完成了“访问记录显示”和 current 同步,点击切换和刷新持久化尚未完成。推进顺序很合理:第一步先把访问记录显示出来,第二步接点击切换,第三步做持久化和关闭策略。这比一开始把所有能力混在一起清晰得多。只要数据链路先打通,后面补点击事件和持久化都不晚。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 自定义 Tabs 组件类型报冲突 | 业务 props 类型名与官方 TabsProps 撞名 | 改成 HeaderTabsProps 等带业务前缀名称 |
el-tab-pane 的 name 类型报错 | 路由 name 可能是 string | symbol | undefined | 统一转 string 并兜底 |
| 顶部 Tabs 一开始是空的 | 路由监听只在变化后执行,没首屏初始化 | watch 加 { immediate: true } |
| 同一路由被重复添加 | addRoute 没先去重 | 以 route.name 为基准先 some(),再决定是否 push |
| 刷新后 Tabs 全丢了 | Pinia 默认内存态,未持久化 | 下一节接本地存储或持久化插件 |
| Tabs 能显示但点击没反应 | 只完成数据收集,未接切换事件 | 给 v-model 或 tab-click 接命名路由跳转 |