@tker/layout
v1.1.9
Published
一个灵活的 Vue 3 中后台系统布局框架,支持适配器模式,可与任意 UI 组件库集成。
Downloads
1,306
Readme
@tker/layout
一个灵活的 Vue 3 中后台系统布局框架,支持适配器模式,可与任意 UI 组件库集成。
⚠️ Vite 项目必须配置预编译排除! 见下方 "Vite 预编译配置" 章节
特性
- 适配器模式:通过适配器注入任意 UI 组件,不与特定 UI 库绑定
- 单例管理:adapterManager 和 layoutManager 单例管理适配器和布局数据
- 自动路由监听:Layout 内部自动监听 vue-router,无需手动设置 activePath
- 内置 NaiveUI 适配器:提供菜单、面包屑、标签页的内置适配器
- 自动数据管理:菜单、面包屑、标签页数据由框架内部自动维护
- 多种布局模式:支持侧边栏菜单(side-menu)和顶部菜单(top-menu)
- TypeScript 支持:完整的类型定义
安装
pnpm add @tker/layout⚠️ Vite 预编译配置(必须)
重要:本步骤必须在安装后立即完成,否则所有功能都将失效!
本包使用单例模式管理状态(adapterManager、layoutManager)。Vite 默认会对依赖进行预编译优化,这会导致单例分离问题:预编译后的模块与运行时模块被当作两个独立的实例,导致适配器和状态无法正确共享。
必须配置
在 vite.config.ts 中添加以下配置:
import { defineConfig } from "vite";
export default defineConfig({
optimizeDeps: {
exclude: ["@tker/layout"], // 必须!排除预编译以保持单例
},
});问题表现
如果不配置,会出现以下问题:
- ❌
addLogoAdapter等适配器注入后,组件无法获取到适配器 - ❌
setMenus设置菜单数据后,组件显示空菜单 - ❌ 状态更新不同步,数据丢失
其他构建工具
- Webpack:无需额外配置
- Rollup:无需额外配置
- esbuild:可能需要类似配置
推荐的项目组织方式
前置条件检查(Vite 项目):
- ✅ 已安装
@tker/layout- ✅ 已在
vite.config.ts配置optimizeDeps.exclude: ["@tker/layout"](必须!)Webpack/Rollup 项目无需此配置。
为了更好地组织 layout 相关代码,建议采用以下目录结构:
src/
├── layout/
│ ├── index.ts # 导出 setupLayout 函数
│ ├── components/ # 自定义适配器目录
│ │ ├── LogoAdapter.vue # 自定义 Logo 适配器
│ │ ├── ToolbarAdapter.vue # 自定义工具栏适配器(可选)
│ │ └── UserAvatarAdapter.vue # 自定义用户头像适配器(可选)
│ └── menuData.ts # 菜单数据配置(可选,可拆分)
├── App.vue # 使用 Layout 组件包裹 router-view
├── main.ts # 初始化时调用 setupLayout
└── router/
│ └── index.ts # 路由配置1. 创建 layout 目录
将 layout 相关的配置和自定义适配器集中管理:
src/layout/index.ts - 初始化配置
import type { MenuItem } from "@tker/layout";
import { h } from "vue";
import {
addBreadcrumbAdapter,
addLogoAdapter,
addMenuAdapter,
addTabAdapter,
setMenus,
NaiveBreadcrumbAdapter,
NaiveMenuAdapter,
NaiveTabAdapter,
} from "@tker/layout";
import { NIcon } from "naive-ui";
// 自定义适配器
import LogoAdapter from "./components/LogoAdapter.vue";
// 菜单数据
const menuData: MenuItem[] = [
{
path: "/dashboard",
title: "仪表盘",
icon: () => h(NIcon, null, { default: () => "📊" }),
},
{
path: "/system",
title: "系统管理",
icon: () => h(NIcon, null, { default: () => "⚙️" }),
children: [
{ path: "/system/user", title: "用户管理" },
{ path: "/system/role", title: "角色管理" },
],
},
];
/**
* 初始化 Layout 配置
*/
export function setupLayout(): void {
// 1. 注入适配器组件
addLogoAdapter(LogoAdapter); // Logo 需自行实现
addMenuAdapter(NaiveMenuAdapter);
addBreadcrumbAdapter(NaiveBreadcrumbAdapter);
addTabAdapter(NaiveTabAdapter);
// 2. 设置菜单数据
setMenus(menuData);
}src/layout/components/LogoAdapter.vue - 自定义 Logo
<script setup lang="ts">
import { computed } from "vue";
interface Props {
collapsed: boolean;
}
const props = defineProps<Props>();
const logoStyle = computed(() => ({
width: props.collapsed ? "64px" : "200px",
overflow: "hidden",
}));
</script>
<template>
<div class="logo" :style="logoStyle">
<span v-if="!props.collapsed" class="logo__text">MyApp</span>
<span v-else class="logo__icon">M</span>
</div>
</template>
<style scoped>
.logo {
display: flex;
align-items: center;
justify-content: center;
height: 48px;
transition: width 0.3s;
}
.logo__text {
font-size: 18px;
font-weight: bold;
}
.logo__icon {
font-size: 24px;
font-weight: bold;
}
</style>2. 在 App.vue 中使用 Layout
<script setup lang="ts">
import { Layout } from "@tker/layout";
</script>
<template>
<layout>
<router-view />
</layout>
</template>3. 在 main.ts 中初始化
import { createApp } from "vue";
import App from "./App.vue";
import { setupLayout } from "./layout";
import { setupRouter } from "./router";
const app = createApp(App);
setupRouter(app);
setupLayout(); // 在 createApp 之后调用
app.mount("#app");4. 路由配置
Layout 会自动监听 vue-router,路由配置无需特殊处理:
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
children: [
{ path: "", redirect: "/dashboard" },
{ path: "/dashboard", name: "Dashboard", component: Dashboard },
{ path: "/system/user", name: "User", component: User },
// ...
],
},
];这种组织方式的优点
- 职责分离:layout 配置独立于业务组件
- 易于维护:菜单数据、适配器集中管理
- 初始化顺序清晰:
main.ts中统一调用setupLayout() - 便于扩展:新增自定义适配器只需在
layout/目录添加文件
工作原理
- adapterManager(单例):管理所有适配器组件,初始化时注入
- layoutManager(单例):管理菜单、面包屑、标签页、布局模式等数据
- Layout 组件:
- 自动监听 vue-router 路由变化
- 根据路由 path 自动设置 activePath
- 自动生成面包屑和打开标签页
- 内部维护
pathParamsMap存储地址栏参数
导航行为
点击菜单、Tab、面包屑时会触发实际的页面跳转(router.push),并自动保持原有地址栏参数:
内部参数映射(pathParamsMap)
Layout 内部维护一个 path -> fullPath 的映射表,自动存储和同步地址栏参数:
用户访问 /user?id=1 → pathParamsMap['/user'] = '/user?id=1'
用户跳转到 /user?id=2 → pathParamsMap['/user'] = '/user?id=2'(更新)
关闭 Tab → 同步清除 pathParamsMap['/user']跳转逻辑
- 菜单点击:查询
pathParamsMap,有参数则用fullPath跳转,否则用path - Tab 点击:查询
pathParamsMap,用对应的fullPath跳转 - 面包屑点击:检查是否具体页面(无 children),再查询
pathParamsMap跳转
对适配器透明
适配器只需 emit path,框架内部自动处理参数:
TabAdapterEmits.select→ emitpath- 跳转时框架调用
getFullPath(path)获取完整路径
API
适配器注入
从 @tker/layout 导入:
import {
addLogoAdapter,
addMenuAdapter,
addBreadcrumbAdapter,
addTabAdapter,
addToolbarAdapter,
addUserAvatarAdapter,
} from "@tker/layout";| 方法 | 参数 | 说明 |
|------|------|------|
| addLogoAdapter | (component: Component) | 注入 Logo 适配器 |
| addMenuAdapter | (component: Component) | 注入菜单适配器 |
| addBreadcrumbAdapter | (component: Component) | 注入面包屑适配器 |
| addTabAdapter | (component: Component) | 注入标签页适配器 |
| addToolbarAdapter | (component: Component) | 注入工具栏适配器 |
| addUserAvatarAdapter | (component: Component) | 注入用户头像适配器 |
配置和数据管理
从 @tker/layout 导入:
import {
setMenus,
setHomePath,
setLayoutMode,
setMaxTabs,
setExpandedWidth,
setCollapsedWidth,
toggleCollapse,
closeTab,
closeOtherTabs,
closeAllTabs,
setTabFixed,
setBreadcrumb,
resetBreadcrumb,
} from "@tker/layout";| 方法 | 参数 | 说明 |
|------|------|------|
| setMenus | (data: MenuItem[]) | 设置菜单数据(必须) |
| setLayoutMode | (mode: 'side-menu' \| 'top-menu') | 设置布局模式(可选,默认 side-menu) |
| setHomePath | (path: string) | 设置首页路径,首页 tab 固定在第一位且不可关闭 |
| setMaxTabs | (max: number) | 设置最大标签页数量(可选,默认 10) |
| setExpandedWidth | (width: string) | 设置侧边栏展开宽度(可选,默认 '200px') |
| setCollapsedWidth | (width: string) | 设置侧边栏收起宽度(可选,默认 '64px') |
| toggleCollapse | () => void | 切换菜单折叠状态 |
标签页管理(可选)
Layout 会自动根据路由打开标签页,以下方法用于特殊场景:
| 方法 | 参数 | 说明 |
|------|------|------|
| closeTab | (path: string) | 关闭指定标签页 |
| closeOtherTabs | (path?: string) | 关闭其他标签页 |
| closeAllTabs | () => void | 关闭所有标签页 |
| setTabFixed | (path: string, fixed: boolean) | 设置标签页固定状态 |
面包屑管理(可选)
Layout 会自动根据路由生成面包屑,以下方法用于特殊场景:
| 方法 | 参数 | 说明 |
|------|------|------|
| setBreadcrumb | (data: BreadcrumbItem[]) | 手动设置面包屑 |
| resetBreadcrumb | () => void | 重置为自动生成的面包屑 |
布局模式
支持两种布局模式:
| 模式 | 说明 |
|------|------|
| side-menu | 侧边栏菜单布局(默认) |
| top-menu | 顶部菜单布局 |
import { setLayoutMode } from "@tker/layout";
setLayoutMode("top-menu");数据类型
以下类型定义中的 Component 需从 vue 导入:
import type { Component } from "vue";MenuItem
interface MenuItem {
path: string; // 路径(唯一标识)
title?: string; // 标题
icon?: Component | string; // 图标
children?: MenuItem[]; // 子菜单
[key: string]: any; // 其他自定义字段
}TabItem
interface TabItem {
path: string; // 菜单路径(不含参数)
title: string; // 标题
icon?: Component | string; // 图标
fixed?: boolean; // 是否固定(固定标签不可关闭)
}注意:Tab 不存储 fullPath,参数由框架内部的 pathParamsMap 维护。
BreadcrumbItem
interface BreadcrumbItem {
path: string; // 路径
title: string; // 标题
}自定义适配器
框架不提供任何内置适配器,所有适配器均需用户自行实现。以下是各适配器的接口定义和实现示例。
LogoAdapter
作用:显示系统 Logo,位于顶栏左侧。当菜单折叠时,Logo 宽度会相应变化。
接收的 Props
| 属性 | 类型 | 说明 |
|------|------|------|
| collapsed | boolean | 菜单是否折叠。用途:根据折叠状态显示不同内容(如折叠时显示简短标识,展开时显示完整 Logo) |
| width | string | 当前宽度(如 '200px' 或 '64px')。用途:直接设置 Logo 容器宽度,无需自行计算 |
无 Events
Logo 适配器不需要发出事件。
实现示例
<script setup lang="ts">
interface Props {
collapsed: boolean;
width: string;
}
const props = defineProps<Props>();
</script>
<template>
<div class="logo" :style="{ width: props.width, overflow: 'hidden' }">
<!-- 折叠时显示简短标识,展开时显示完整内容 -->
<img v-if="!props.collapsed" src="/logo-full.png" alt="Logo" />
<img v-else src="/logo-mini.png" alt="Logo" />
</div>
</template>
<style scoped>
.logo {
display: flex;
align-items: center;
justify-content: center;
height: 48px;
transition: width 0.3s;
}
.logo img {
max-width: 100%;
max-height: 32px;
}
</style>MenuAdapter
作用:显示侧边栏或顶部菜单,用于导航。
接收的 Props
| 属性 | 类型 | 说明 |
|------|------|------|
| menuData | MenuItem[] | 菜单数据。用途:渲染菜单树结构,每个 MenuItem 包含 path(唯一标识)、title、icon、children 等 |
| activePath | string | 当前激活的菜单路径。用途:高亮当前选中的菜单项 |
| collapsed | boolean | 菜单是否折叠。用途:折叠时隐藏菜单文字,只显示图标;仅 side 模式有效 |
| mode | 'side' \| 'top' | 菜单模式。用途:side 为垂直菜单(侧边栏),top 为水平菜单(顶栏) |
| width | string | 当前宽度(如 '200px' 或 '64px')。用途:直接设置菜单容器宽度,折叠状态下即为收起宽度 |
需要发出的 Events
| 事件 | 参数 | 说明 |
|------|------|------|
| select | path: string | 用户点击菜单项时触发。必须发出,框架会根据此事件更新路由、面包屑、标签页 |
| toggleCollapse | 无 | 用户点击折叠按钮时触发。可选,用于切换侧边栏折叠状态 |
实现示例(使用 NaiveUI NMenu)
<script setup lang="ts">
import type { Component } from "vue";
import { computed, h } from "vue";
import { NMenu } from "naive-ui";
import { getMenuItemIcon, getMenuItemTitle } from "@tker/layout";
interface MenuItem {
path: string;
title?: string;
icon?: Component | string;
children?: MenuItem[];
[key: string]: any;
}
interface Props {
menuData: MenuItem[];
activePath: string;
collapsed: boolean;
mode: 'side' | 'top';
width: string;
}
interface Emits {
(e: 'select', path: string): void;
(e: 'toggleCollapse'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// 将 MenuItem 转换为 NMenu 的 options 格式
function transformMenuOptions(items: MenuItem[]): any[] {
return items.map((item) => {
const option: any = {
key: item.path,
label: getMenuItemTitle(item),
};
const icon = getMenuItemIcon(item);
if (icon) {
option.icon = typeof icon === "string" ? () => icon : () => h(icon as any);
}
if (item.children?.length) {
option.children = transformMenuOptions(item.children);
}
return option;
});
}
const menuOptions = computed(() => transformMenuOptions(props.menuData));
// 框架 mode ('side' | 'top') 转换为 NMenu mode ('vertical' | 'horizontal')
const naiveMode = computed(() =>
props.mode === "side" ? "vertical" : "horizontal",
);
// 解析 width 为数值(去掉 px)
const collapsedWidthValue = computed(() => {
const width = props.width || "64px";
return parseInt(width.replace("px", ""), 10) || 64;
});
function handleSelect(key: string) {
emit("select", key); // 必须发出,框架会处理路由跳转
}
function handleToggleCollapse() {
emit("toggleCollapse"); // 可选,切换折叠状态
}
</script>
<template>
<div class="menu" :class="{ 'menu--collapsed': collapsed }">
<n-menu
:options="menuOptions"
:value="activePath"
:mode="naiveMode"
:collapsed="collapsed"
:collapsed-width="collapsedWidthValue"
@update:value="handleSelect"
/>
<!-- 折叠按钮(仅 side 模式需要) -->
<button
v-if="mode === 'side'"
class="menu__collapse-btn"
@click="handleToggleCollapse"
>
{{ collapsed ? "展开" : "折叠" }}
</button>
</div>
</template>
<style scoped>
.menu {
height: 100%;
display: flex;
flex-direction: column;
}
.menu--collapsed {
overflow: hidden;
}
.menu__collapse-btn {
padding: 8px;
border: none;
background: transparent;
cursor: pointer;
text-align: center;
}
</style>BreadcrumbAdapter
作用:显示面包屑导航,位于顶栏左侧(side-menu 模式下)。
接收的 Props
| 属性 | 类型 | 说明 |
|------|------|------|
| breadcrumbData | BreadcrumbItem[] | 面包屑数据。用途:渲染面包屑路径,数组按层级顺序排列,最后一项为当前页面 |
需要发出的 Events
| 事件 | 参数 | 说明 |
|------|------|------|
| select | path: string | 用户点击面包屑项时触发。必须发出,框架会根据此事件更新路由。注意:最后一项(当前页面)通常不可点击 |
实现示例(使用 NaiveUI NBreadcrumb)
<script setup lang="ts">
import { NBreadcrumb, NBreadcrumbItem } from "naive-ui";
interface BreadcrumbItem {
path: string;
title: string;
}
interface Props {
breadcrumbData: BreadcrumbItem[];
}
interface Emits {
(e: 'select', path: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function handleClick(path: string) {
emit("select", path); // 必须发出,框架会处理路由跳转
}
</script>
<template>
<n-breadcrumb>
<n-breadcrumb-item
v-for="(item, index) in props.breadcrumbData"
:key="item.path"
@click="index < props.breadcrumbData.length - 1 && handleClick(item.path)"
>
{{ item.title }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>TabAdapter
作用:显示标签页,位于内容区顶部(side-menu 模式下),用于快速切换已打开的页面。
接收的 Props
| 属性 | 类型 | 说明 |
|------|------|------|
| tabData | TabItem[] | 标签页数据。用途:渲染标签页列表,每个 TabItem 包含 path、title、icon、fixed |
| activeTab | string | 当前激活的标签页路径。用途:高亮当前选中的标签页 |
需要发出的 Events
| 事件 | 参数 | 说明 |
|------|------|------|
| select | path: string | 用户点击标签页时触发。必须发出,框架会通过 pathParamsMap 获取 fullPath 进行跳转(保持原有参数) |
| close | path: string | 用户关闭标签页时触发。必须发出,框架会移除该标签页并同步清除 pathParamsMap |
| closeOther | path?: string | 用户关闭其他标签页时触发。可选,path 为保留的标签页 |
| closeAll | 无 | 用户关闭所有标签页时触发。可选,框架会关闭所有非固定标签页 |
| setFixed | path: string, fixed: boolean | 用户设置标签页固定状态时触发。可选,固定标签不可关闭 |
实现示例(使用 NaiveUI NTabs)
<script setup lang="ts">
import type { Component } from "vue";
import { NTabPane, NTabs } from "naive-ui";
interface TabItem {
path: string;
title: string;
icon?: Component | string;
fixed?: boolean;
}
interface Props {
tabData: TabItem[];
activeTab: string;
}
interface Emits {
(e: 'select', path: string): void;
(e: 'close', path: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function handleSelect(path: string) {
emit("select", path); // 只 emit path,框架自动处理参数
}
function handleClose(path: string) {
emit("close", path);
}
</script>
<template>
<n-tabs
:value="props.activeTab"
type="card"
closable
@update:value="handleSelect"
@close="(name: string) => handleClose(name)"
>
<n-tab-pane
v-for="tab in props.tabData"
:key="tab.path"
:name="tab.path"
:tab="tab.title"
:closable="!tab.fixed"
/>
</n-tabs>
</template>ToolbarAdapter
作用:显示工具栏,位于顶栏右侧,用于放置快捷操作按钮(如刷新、全屏等)。
无 Props
Toolbar 适配器不接收任何 props,完全由用户自定义内容。
无 Events
Toolbar 适配器不需要发出事件,操作由用户自行处理。
实现示例
<script setup lang="ts">
import { NButton, NSpace } from "naive-ui";
function handleRefresh() {
// 用户自行处理刷新逻辑
}
function handleFullscreen() {
// 用户自行处理全屏逻辑
}
</script>
<template>
<n-space>
<n-button size="small" @click="handleRefresh">刷新</n-button>
<n-button size="small" @click="handleFullscreen">全屏</n-button>
</n-space>
</template>UserAvatarAdapter
作用:显示用户头像和下拉菜单,位于顶栏最右侧。
无 Props
UserAvatar 适配器不接收任何 props。
可选的 Events
框架不监听 UserAvatar 适配器的任何事件,以下事件仅供参考,用户可选择 emit 或直接在组件内处理逻辑:
| 事件 | 参数 | 说明 |
|------|------|------|
| settings | 无 | 用户点击"设置"时触发。可选,用户可直接在组件内调用设置逻辑 |
| logout | 无 | 用户点击"退出登录"时触发。可选,用户可直接在组件内调用退出逻辑 |
实现示例(使用 NaiveUI NDropdown + NAvatar)
<script setup lang="ts">
import { useRouter } from "vue-router";
import { NAvatar, NDropdown } from "naive-ui";
const router = useRouter();
const dropdownOptions = [
{ label: "个人设置", key: "settings" },
{ label: "退出登录", key: "logout" },
];
function handleSelect(key: string) {
if (key === "settings") {
router.push("/settings"); // 直接处理
} else if (key === "logout") {
// 直接调用退出逻辑
console.log("退出登录");
}
}
</script>
<template>
<n-dropdown :options="dropdownOptions" @select="handleSelect">
<n-avatar round size="small">
<slot>U</slot>
</n-avatar>
</n-dropdown>
</template>内置 NaiveUI 适配器
框架提供以下内置适配器,可从 @tker/layout 直接导入:
import {
NaiveBreadcrumbAdapter,
NaiveMenuAdapter,
NaiveTabAdapter,
} from "@tker/layout";| 适配器 | 说明 |
|--------|------|
| NaiveMenuAdapter | 菜单适配器,使用 NMenu |
| NaiveBreadcrumbAdapter | 面包屑适配器,使用 NBreadcrumb |
| NaiveTabAdapter | 标签页适配器,使用 NTabs |
注意:Logo、Toolbar、UserAvatar 适配器与业务紧密相关,框架不提供内置实现,需用户自行开发。
License
MIT
