Vue3后台管理系统布局实战:从零搭建Element Plus左右布局(含Pinia状态管理)

Vue3后台管理系统布局实战:从零搭建Element Plus左右布局(含Pinia状态管理)

最近在重构公司的一个内部运营平台,技术栈选型时,团队一致决定采用 Vue3 + TypeScript + Element Plus 的组合。这个决定背后有几个考量:Vue3 的 Composition API 在复杂业务逻辑组织上优势明显,TypeScript 能大幅提升代码的可维护性,而 Element Plus 作为成熟的企业级 UI 库,其丰富的组件和良好的设计规范能极大加速开发。项目启动后,我负责搭建整个系统的骨架------也就是那个经典的左右布局后台管理系统界面。这听起来像是前端开发里的"Hello World",但真正动手时,你会发现从路由配置、状态管理到主题切换,每一个环节都有不少值得琢磨的细节。今天,我就把自己从零搭建这套布局的完整过程,以及其中遇到的一些"坑"和解决方案,毫无保留地分享给你。

无论你是刚接触 Vue3 的前端新人,还是想从 Vue2 迁移过来的老手,这篇文章都会带你走一遍企业级后台管理系统布局搭建的全流程。我们不仅会实现基础的左右分栏、菜单导航、面包屑,还会深入探讨如何用 Pinia 优雅地管理全局状态(比如菜单的折叠状态、主题模式),并集成全屏切换、路由过渡动画这些提升用户体验的细节。我会尽量避开那些官方文档里就有的基础内容,把重点放在实际开发中你会遇到的真实问题和更优的实践上。

1. 项目初始化与核心依赖选型

在动手写布局代码之前,搭建一个稳健的项目基础环境至关重要。这就像盖房子前先打好地基,选好建材。我们跳过 vue create 这种传统方式,直接使用目前社区最活跃、体验也最好的构建工具------Vite。

首先,打开你的终端,执行以下命令来创建一个基于 Vite 的 Vue3 + TypeScript 项目:

bash 复制代码
npm create vue@latest vue3-admin-layout

创建过程中,命令行会交互式地询问你是否需要添加一些可选功能。这里是我的选择,你可以参考:

复制代码
✔ Project name: ... vue3-admin-layout
✔ Add TypeScript? ... Yes
✔ Add JSX Support? ... No
✔ Add Vue Router for Single Page Application development? ... Yes
✔ Add Pinia for state management? ... Yes
✔ Add Vitest for Unit Testing? ... No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? ... Yes
✔ Add Prettier for code formatting? ... Yes

提示 :强烈建议同时选择 PiniaVue Router。Pinia 是 Vue 官方推荐的状态管理库,相比 Vuex,它的 API 更简洁,对 TypeScript 的支持也更好,我们后续管理布局状态(如菜单折叠、主题)会用到它。Vue Router 则是构建单页面应用(SPA)的基石。

项目创建完成后,进入目录并安装 Element Plus 及其图标库:

bash 复制代码
cd vue3-admin-layout
npm install element-plus @element-plus/icons-vue

接下来,我们需要在项目中引入 Element Plus。一种常见做法是在 main.ts 中进行全局注册。但为了更好的性能和按需引入,我推荐使用 自动导入 插件。首先安装必要的插件:

bash 复制代码
npm install -D unplugin-vue-components unplugin-auto-import

然后,修改 vite.config.ts 配置文件:

typescript 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    // 自动导入 API,如 ref, reactive, onMounted 等
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts', // 生成类型声明文件
    }),
    // 自动导入组件
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/components.d.ts', // 生成组件类型声明文件
    }),
  ],
})

这样配置后,你就可以在项目的任何 .vue 文件中直接使用 Element Plus 的组件(如 <el-button>)和 Vue/Vue Router/Pinia 的 Composition API(如 ref, useRouter),而无需手动 import。工具会自动为你处理导入,并生成对应的 TypeScript 类型声明文件,完美支持智能提示。

最后,别忘了引入 Element Plus 的样式文件。在 src/main.ts 中:

typescript 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'

// 引入 Element Plus 样式
import 'element-plus/dist/index.css'
// 引入项目全局样式
import './styles/index.scss'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

至此,一个集成了 Vue3、TypeScript、Vite、Vue Router、Pinia 和 Element Plus(支持自动导入)的现代化前端项目骨架就搭建完成了。接下来,我们就可以专注于布局本身的实现了。

2. 核心布局结构设计与组件拆分

后台管理系统的界面虽然看起来千篇一律,但一个清晰、合理的组件结构划分,是后续功能扩展和维护性的关键。我习惯采用"容器-组件"的思维来构建布局。

布局的视觉结构通常分为三个主要区域:

  1. 左侧边栏 (Aside):承载主导航菜单,通常包含 Logo 和系统名称。
  2. 顶部栏 (Header):位于右侧区域顶部,包含面包屑导航、用户信息、全屏/主题切换等功能入口。
  3. 主内容区 (Main):位于右侧区域,是页面核心内容的渲染区域,通过 Vue Router 动态切换。

在代码层面,我建议在 src/layout 目录下组织这些组件,这样职责更清晰:

复制代码
src/
├── layout/
│   ├── index.vue              # 布局根组件,组装所有布局部件
│   ├── components/            # 布局专用的子组件
│   │   ├── AppAside.vue       # 左侧边栏组件
│   │   ├── AppHeader.vue      # 顶部栏组件
│   │   └── AppMain.vue        # 主内容区组件
│   └── hooks/                 # 布局相关的自定义组合式函数
├── views/                     # 页面级组件(由路由渲染在 AppMain 中)
├── router/
└── stores/                    # Pinia 状态管理

现在,我们来创建最核心的布局容器 src/layout/index.vue。这里我们将使用 Element Plus 的 Container 布局容器 组件。它的好处是提供了语义化的标签(el-container, el-aside, el-header, el-main)和开箱即用的弹性布局,让我们能快速搭建出结构。

vue 复制代码
<!-- src/layout/index.vue -->
<template>
  <el-container class="layout-container">
    <!-- 左侧边栏区域 -->
    <app-aside />
    <!-- 右侧垂直排列的容器:Header + Main -->
    <el-container direction="vertical" class="right-container">
      <!-- 顶部导航栏 -->
      <app-header />
      <!-- 主内容区域 -->
      <app-main />
    </el-container>
  </el-container>
</template>

<script setup lang="ts">
// 使用 defineAsyncComponent 实现组件的异步加载(按需加载)
// 这对于大型应用优化首屏加载速度很有帮助
import { defineAsyncComponent } from 'vue'

const AppAside = defineAsyncComponent(() => import('./components/AppAside.vue'))
const AppHeader = defineAsyncComponent(() => import('./components/AppHeader.vue'))
const AppMain = defineAsyncComponent(() => import('./components/AppMain.vue'))
</script>

<style lang="scss" scoped>
.layout-container {
  width: 100vw;
  height: 100vh;
  overflow: hidden; // 防止整体出现滚动条

  .right-container {
    // 右侧容器采用 flex 布局,让 Header 和 Main 垂直排列
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0; // 解决 flex 布局下内容溢出问题
  }
}
</style>

这里有几个设计点值得注意:

  • defineAsyncComponent:用于异步加载布局子组件。虽然对于布局组件来说,同步加载也无妨,但养成这个习惯对后续拆分业务模块、优化首屏加载有益。
  • direction="vertical" :这是 el-container 的一个属性,当子元素包含 el-headerel-footer 时,默认会垂直排列。我们显式声明,让代码意图更清晰。
  • CSS 细节height: 100vh 让布局占满整个视口;overflow: hidden 防止最外层出现滚动条;min-width: 0 是处理 Flex 项目内容溢出的一个经典技巧。

接下来,我们需要在路由中应用这个布局。修改 src/router/index.ts

typescript 复制代码
// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

// 定义路由元信息类型,方便后续扩展(如页面标题、图标、缓存等)
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    icon?: string
    requiresAuth?: boolean // 是否需要登录认证
    keepAlive?: boolean    // 是否缓存组件
    hiddenInMenu?: boolean // 是否在菜单中隐藏
  }
}

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Layout',
    component: () => import('@/layout/index.vue'), // 布局组件作为父路由
    redirect: '/dashboard', // 默认重定向到仪表盘
    children: [
      // 具体的页面路由将作为子路由在这里定义
      // 例如:
      // {
      //   path: 'dashboard',
      //   name: 'Dashboard',
      //   component: () => import('@/views/dashboard/index.vue'),
      //   meta: { title: '仪表盘', icon: 'Odometer' }
      // },
    ]
  },
  // 可以在这里添加登录页、404页等非布局路由
  // {
  //   path: '/login',
  //   name: 'Login',
  //   component: () => import('@/views/login/index.vue')
  // },
  // {
  //   path: '/:pathMatch(.*)*',
  //   name: 'NotFound',
  //   component: () => import('@/views/error/404.vue')
  // }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

这样,访问根路径 / 时,就会加载我们的布局框架,具体的页面内容将在 <app-main> 中根据子路由动态渲染。App.vue 只需要保留最简单的路由出口:

vue 复制代码
<!-- src/App.vue -->
<template>
  <router-view />
</template>

<style>
/* 全局样式重置,确保布局能正常占满屏幕 */
html, body, #app {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
}
</style>

基础框架搭好了,接下来我们逐一实现三个核心区域组件。

3. 左侧菜单与路由的动态集成

左侧菜单是后台系统的导航中枢,其核心需求是:与路由配置同步,并能响应式折叠。我们先从静态菜单开始,再升级为动态生成。

首先创建 src/layout/components/AppAside.vue

vue 复制代码
<!-- src/layout/components/AppAside.vue -->
<template>
  <el-aside :width="asideWidth" class="app-aside">
    <!-- Logo 区域 -->
    <div class="aside-logo">
      <img src="@/assets/logo.svg" alt="Logo">
      <span v-show="!isCollapse">Vue3 Admin</span>
    </div>
    <!-- 菜单区域 -->
    <el-menu
      :default-active="activeMenu"
      :collapse="isCollapse"
      :collapse-transition="false"
      router
      class="aside-menu"
    >
      <!-- 菜单项将通过函数动态生成 -->
      <menu-item v-for="route in menuRoutes" :key="route.path" :item="route" />
    </el-menu>
  </el-aside>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/stores/app' // 假设我们有一个管理应用状态(如折叠)的 store
import MenuItem from './MenuItem.vue' // 递归菜单项组件

const route = useRoute()
const appStore = useAppStore()
const { isCollapse } = storeToRefs(appStore)

// 根据折叠状态计算侧边栏宽度
const asideWidth = computed(() => isCollapse.value ? '64px' : '200px')

// 当前激活的菜单路径(用于高亮)
const activeMenu = computed(() => route.path)

// 模拟从路由中过滤出需要在菜单中显示的路由
// 实际项目中,这部分数据可能来自后端API或根据路由meta动态生成
import { routes } from '@/router'
const menuRoutes = computed(() => {
  // 这里是一个简单的过滤逻辑,实际应根据路由的 meta.hiddenInMenu 等字段判断
  return routes.find(r => r.name === 'Layout')?.children?.filter(r => !r.meta?.hiddenInMenu) || []
})
</script>

<style lang="scss" scoped>
.app-aside {
  height: 100vh;
  background-color: #001529; // 经典的深色侧边栏背景
  transition: width 0.3s ease; // 平滑的折叠动画
  border-right: 1px solid #e6e6e6;

  .aside-logo {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 60px;
    color: #fff;
    font-size: 18px;
    font-weight: bold;
    overflow: hidden;
    white-space: nowrap;

    img {
      width: 32px;
      height: 32px;
      margin-right: 8px;
    }
  }

  .aside-menu {
    border-right: none; // 去除 el-menu 默认的右边框
    background-color: transparent; // 背景透明,继承父级
    :deep(.el-menu-item),
    :deep(.el-sub-menu__title) {
      color: rgba(255, 255, 255, 0.65);
      &:hover {
        color: #fff;
        background-color: rgba(255, 255, 255, 0.1);
      }
      &.is-active {
        color: #fff;
        background-color: #1890ff; // 激活项高亮色
      }
    }
  }
}
</style>

这里我们引入了 Pinia store (useAppStore) 来管理菜单的折叠状态 isCollapse。我们稍后会创建这个 store。菜单项组件 MenuItem 需要处理嵌套路由(多级菜单)的情况,这是一个递归组件:

vue 复制代码
<!-- src/layout/components/MenuItem.vue -->
<template>
  <!-- 没有子路由或子路由不显示在菜单 -> 渲染为 el-menu-item -->
  <el-menu-item
    v-if="!hasChildren || !showChildren"
    :index="resolvePath(item.path)"
    :route="{ path: resolvePath(item.path) }"
  >
    <el-icon v-if="item.meta?.icon">
      <component :is="item.meta.icon" />
    </el-icon>
    <template #title>{{ item.meta?.title || item.name }}</template>
  </el-menu-item>
  <!-- 有子路由且需要显示 -> 渲染为 el-sub-menu -->
  <el-sub-menu v-else :index="resolvePath(item.path)">
    <template #title>
      <el-icon v-if="item.meta?.icon">
        <component :is="item.meta.icon" />
      </el-icon>
      <span>{{ item.meta?.title || item.name }}</span>
    </template>
    <!-- 递归渲染子菜单 -->
    <menu-item
      v-for="child in item.children"
      :key="child.path"
      :item="child"
      :base-path="resolvePath(item.path)"
    />
  </el-sub-menu>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { RouteRecordRaw } from 'vue-router'

interface Props {
  item: RouteRecordRaw
  basePath?: string
}
const props = withDefaults(defineProps<Props>(), {
  basePath: ''
})

// 解析完整路径(处理嵌套路由)
const resolvePath = (routePath: string) => {
  // 如果已经是绝对路径,直接返回
  if (routePath.startsWith('/')) {
    return routePath
  }
  // 否则,拼接基础路径
  const base = props.basePath.endsWith('/') ? props.basePath.slice(0, -1) : props.basePath
  return `${base}/${routePath}`
}

// 判断当前路由项是否有需要显示的子菜单
const hasChildren = computed(() => {
  return props.item.children && props.item.children.length > 0
})

const showChildren = computed(() => {
  if (!hasChildren.value) return false
  // 这里可以添加更复杂的逻辑,例如根据 meta 判断子路由是否显示在菜单
  // 简单起见,假设所有子路由都显示
  return true
})
</script>

现在,我们需要创建 Pinia store 来集中管理布局的全局状态。创建 src/stores/app.ts

typescript 复制代码
// src/stores/app.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAppStore = defineStore('app', () => {
  // 状态
  const sidebar = ref({
    opened: true, // 侧边栏是否展开
    withoutAnimation: false // 切换时是否禁用动画
  })
  const device = ref<'desktop' | 'mobile'>('desktop') // 设备类型,用于响应式
  const size = ref<'default' | 'large' | 'small'>('default') // 全局组件尺寸

  // Getter (计算属性)
  const isCollapse = computed(() => !sidebar.value.opened)

  // Action (方法)
  function toggleSidebar(withoutAnimation = false) {
    sidebar.value.opened = !sidebar.value.opened
    sidebar.value.withoutAnimation = withoutAnimation
  }
  function closeSidebar(withoutAnimation = false) {
    sidebar.value.opened = false
    sidebar.value.withoutAnimation = withoutAnimation
  }
  function openSidebar(withoutAnimation = false) {
    sidebar.value.opened = true
    sidebar.value.withoutAnimation = withoutAnimation
  }
  function toggleDevice(dev: 'desktop' | 'mobile') {
    device.value = dev
  }
  function setSize(s: 'default' | 'large' | 'small') {
    size.value = s
  }

  return {
    sidebar,
    device,
    size,
    isCollapse,
    toggleSidebar,
    closeSidebar,
    openSidebar,
    toggleDevice,
    setSize
  }
})

为了让状态在页面刷新后不丢失,我们还需要实现 状态持久化 。一个简单的方法是使用 pinia-plugin-persistedstate 插件:

bash 复制代码
npm install pinia-plugin-persistedstate

然后在 main.ts 中配置:

typescript 复制代码
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 使用持久化插件

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

最后,在 store 定义中标记需要持久化的状态:

typescript 复制代码
// src/stores/app.ts (修改后)
export const useAppStore = defineStore('app', () => {
  // ... 状态和逻辑保持不变 ...
}, {
  persist: {
    key: 'vue3-admin-app', // 存储的 key
    storage: localStorage, // 默认是 localStorage
    paths: ['sidebar.opened', 'size', 'device'] // 指定要持久化的状态路径
  }
})

现在,菜单的折叠状态、组件尺寸偏好等都会被自动保存到浏览器的 localStorage 中,即使用户刷新页面,体验也能保持一致。

4. 顶部导航栏:面包屑、全屏与主题切换

顶部导航栏 (AppHeader) 是用户交互的另一个核心区域。它通常包含:

  1. 菜单折叠/展开触发器
  2. 面包屑导航,显示当前页面位置
  3. 功能图标区:全屏切换、主题切换、消息通知、用户头像下拉菜单

我们先创建基础结构 src/layout/components/AppHeader.vue

vue 复制代码
<!-- src/layout/components/AppHeader.vue -->
<template>
  <el-header class="app-header">
    <div class="header-left">
      <!-- 折叠触发器 -->
      <div class="collapse-trigger" @click="toggleCollapse">
        <el-icon :size="20">
          <component :is="isCollapse ? 'Expand' : 'Fold'" />
        </el-icon>
      </div>
      <!-- 面包屑导航 -->
      <breadcrumb />
    </div>
    <div class="header-right">
      <!-- 全屏切换 -->
      <screenfull class="header-action" />
      <!-- 主题切换 -->
      <theme-switch class="header-action" />
      <!-- 用户头像与下拉菜单 -->
      <user-dropdown class="header-action" />
    </div>
  </el-header>
</template>

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/stores/app'
import Breadcrumb from './Breadcrumb.vue'
import Screenfull from './Screenfull.vue'
import ThemeSwitch from './ThemeSwitch.vue'
import UserDropdown from './UserDropdown.vue'

const appStore = useAppStore()
const { isCollapse } = storeToRefs(appStore)

const toggleCollapse = () => {
  appStore.toggleSidebar()
}
</script>

<style lang="scss" scoped>
.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  height: 60px;
  line-height: 60px;
  background-color: #fff;
  border-bottom: 1px solid #e6e6e6;
  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

  .header-left {
    display: flex;
    align-items: center;
    .collapse-trigger {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 40px;
      height: 40px;
      margin-right: 16px;
      cursor: pointer;
      border-radius: 4px;
      transition: background-color 0.3s;
      &:hover {
        background-color: rgba(0, 0, 0, 0.025);
      }
    }
  }

  .header-right {
    display: flex;
    align-items: center;
    .header-action {
      margin-left: 16px;
      cursor: pointer;
    }
  }
}
</style>

接下来,我们逐一实现这几个功能组件。

4.1 智能面包屑导航

面包屑应该能根据当前路由自动生成。我们可以利用 Vue Router 的 route.matched 属性,它返回当前路由的所有嵌套路径片段的路由记录数组。

vue 复制代码
<!-- src/layout/components/Breadcrumb.vue -->
<template>
  <el-breadcrumb separator="/" class="breadcrumb">
    <transition-group name="breadcrumb">
      <el-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
        <!-- 最后一项不可点击 -->
        <span v-if="index === breadcrumbList.length - 1" class="no-redirect">
          <el-icon v-if="item.meta?.icon" class="breadcrumb-icon">
            <component :is="item.meta.icon" />
          </el-icon>
          {{ item.meta?.title || item.name }}
        </span>
        <!-- 非最后一项可点击跳转 -->
        <a v-else class="redirect" @click.prevent="handleLink(item)">
          <el-icon v-if="item.meta?.icon" class="breadcrumb-icon">
            <component :is="item.meta.icon" />
          </el-icon>
          {{ item.meta?.title || item.name }}
        </a>
      </el-breadcrumb-item>
    </transition-group>
  </el-breadcrumb>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter, type RouteLocationMatched } from 'vue-router'

const route = useRoute()
const router = useRouter()
const breadcrumbList = ref<RouteLocationMatched[]>([])

// 过滤生成面包屑列表的函数
const getBreadcrumb = () => {
  // 只显示有 meta.title 且 hiddenInBreadcrumb 不为 true 的路由
  const matched = route.matched.filter(item => item.meta && item.meta.title && item.meta.hiddenInBreadcrumb !== true)
  breadcrumbList.value = matched
}

// 监听路由变化
watch(() => route.path, getBreadcrumb, { immediate: true })

// 点击面包屑项跳转
const handleLink = (item: RouteLocationMatched) => {
  const { redirect, path } = item
  if (redirect) {
    router.push(redirect as string)
  } else {
    router.push(path)
  }
}
</script>

<style lang="scss" scoped>
.breadcrumb {
  .breadcrumb-icon {
    margin-right: 4px;
    vertical-align: middle;
  }
  .no-redirect {
    color: var(--el-text-color-placeholder);
    cursor: text;
  }
  .redirect {
    color: var(--el-text-color-regular);
    font-weight: normal;
    cursor: pointer;
    &:hover {
      color: var(--el-color-primary);
    }
  }
}

// 面包屑切换动画
.breadcrumb-enter-active,
.breadcrumb-leave-active {
  transition: all 0.5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
  opacity: 0;
  transform: translateX(20px);
}
.breadcrumb-leave-active {
  position: absolute;
}
</style>

4.2 全屏切换功能

实现全屏切换,我推荐使用 VueUse 库中的 useFullscreen 组合式函数。它封装了原生的 Fullscreen API,使用起来非常简单。

bash 复制代码
npm install @vueuse/core
vue 复制代码
<!-- src/layout/components/Screenfull.vue -->
<template>
  <div class="screenfull" @click="toggle">
    <el-tooltip :content="isFullscreen ? '退出全屏' : '全屏'" placement="bottom">
      <el-icon :size="20">
        <component :is="isFullscreen ? 'FullScreen' : 'CropFree'" />
      </el-icon>
    </el-tooltip>
  </div>
</template>

<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'

const { isFullscreen, toggle } = useFullscreen()
</script>

<style scoped>
.screenfull {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 4px;
  transition: background-color 0.3s;
  &:hover {
    background-color: rgba(0, 0, 0, 0.025);
  }
}
</style>

4.3 暗黑/明亮主题切换

Element Plus 内置支持暗黑模式。切换原理是通过在 html 标签上添加或移除 dark 类名。我们同样可以利用 VueUse 的 useDarkuseToggle 来优雅地实现。

首先,确保在 main.ts 或你的全局样式入口文件中引入了暗黑主题的 CSS 变量文件:

typescript 复制代码
// src/styles/index.scss
// ... 其他样式导入
// 引入 Element Plus 的暗黑模式 CSS 变量
@use 'element-plus/theme-chalk/dark/css-vars.css' as *;

然后创建主题切换组件:

vue 复制代码
<!-- src/layout/components/ThemeSwitch.vue -->
<template>
  <div class="theme-switch" @click="toggleDark">
    <el-tooltip :content="isDark ? '切换明亮主题' : '切换暗黑主题'" placement="bottom">
      <el-icon :size="20">
        <component :is="isDark ? 'Sunny' : 'Moon'" />
      </el-icon>
    </el-tooltip>
  </div>
</template>

<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'

// useDark 会自动处理 class 的添加/移除,并将偏好存储到 localStorage
const isDark = useDark({
  selector: 'html', // 作用于 html 标签
  attribute: 'class', // 通过 class 属性切换
  valueDark: 'dark', // 暗黑模式下的 class 名
  valueLight: '' // 明亮模式下的 class 名
})
const toggleDark = useToggle(isDark)
</script>

<style scoped>
.theme-switch {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 4px;
  transition: background-color 0.3s;
  &:hover {
    background-color: rgba(0, 0, 0, 0.025);
  }
}
</style>

为了让自定义组件也能响应主题变化,你可以在项目全局样式中定义一套 CSS 变量(CSS Custom Properties),并根据 html.dark 类名来切换这些变量的值。

scss 复制代码
// src/styles/variables.scss
:root {
  // 明亮主题变量
  --bg-color: #ffffff;
  --text-color: #303133;
  --border-color: #dcdfe6;
  // ... 其他变量
}

html.dark {
  // 暗黑主题变量
  --bg-color: #141414;
  --text-color: #e5eaf3;
  --border-color: #424242;
  // ... 其他变量
}

// 然后在组件样式中使用这些变量
.app-header {
  background-color: var(--bg-color);
  color: var(--text-color);
  border-bottom: 1px solid var(--border-color);
}

4.4 用户下拉菜单

用户下拉菜单通常包含个人信息、设置、退出登录等选项。这里我们用 Element Plus 的 el-dropdown 组件实现一个简单的版本。

vue 复制代码
<!-- src/layout/components/UserDropdown.vue -->
<template>
  <el-dropdown @command="handleCommand" class="user-dropdown">
    <span class="user-info">
      <el-avatar :size="32" :src="userAvatar" class="avatar" />
      <span class="username">{{ userName }}</span>
      <el-icon class="el-icon--right"><ArrowDown /></el-icon>
    </span>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item command="profile">
          <el-icon><User /></el-icon>个人中心
        </el-dropdown-item>
        <el-dropdown-item command="settings">
          <el-icon><Setting /></el-icon>个人设置
        </el-dropdown-item>
        <el-dropdown-item divided command="logout">
          <el-icon><SwitchButton /></el-icon>退出登录
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'

const router = useRouter()

// 模拟用户数据,实际应从 store 或 API 获取
const userName = '管理员'
const userAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'

const handleCommand = async (command: string) => {
  switch (command) {
    case 'profile':
      router.push('/profile')
      break
    case 'settings':
      router.push('/settings')
      break
    case 'logout':
      try {
        await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        })
        // 实际业务中,这里应调用退出登录的 API,并清除 token 等
        // 然后跳转到登录页
        router.push('/login')
      } catch {
        // 用户点击了取消
      }
      break
  }
}
</script>

<style lang="scss" scoped>
.user-dropdown {
  cursor: pointer;
  .user-info {
    display: inline-flex;
    align-items: center;
    padding: 5px;
    border-radius: 4px;
    transition: background-color 0.3s;
    &:hover {
      background-color: rgba(0, 0, 0, 0.025);
    }
    .avatar {
      margin-right: 8px;
    }
    .username {
      margin-right: 4px;
      font-size: 14px;
    }
  }
}
</style>

5. 主内容区优化:路由过渡、页面缓存与响应式

主内容区 (AppMain) 是承载业务页面的地方。除了基本的 router-view,我们还可以为其添加一些提升用户体验的特性。

5.1 路由过渡动画与页面缓存

Vue Router 提供了 <router-view>v-slot API,可以让我们方便地包裹过渡动画和 keep-alive

vue 复制代码
<!-- src/layout/components/AppMain.vue -->
<template>
  <el-main class="app-main">
    <router-view v-slot="{ Component, route }">
      <transition name="fade-transform" mode="out-in">
        <keep-alive :include="cachedViews">
          <component :is="Component" :key="route.fullPath" />
        </keep-alive>
      </transition>
    </router-view>
  </el-main>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useTagsViewStore } from '@/stores/tags-view' // 假设有一个管理标签页的 store

// 从 store 中获取需要缓存的组件 name 数组
const tagsViewStore = useTagsViewStore()
const cachedViews = computed(() => tagsViewStore.cachedViews)
</script>

<style lang="scss" scoped>
.app-main {
  padding: 20px;
  background-color: #f0f2f5;
  // 确保内容区域可以滚动,而不是整个页面滚动
  overflow: auto;
}

// 路由切换动画
.fade-transform-enter-active,
.fade-transform-leave-active {
  transition: all 0.3s ease;
}
.fade-transform-enter-from {
  opacity: 0;
  transform: translateX(-20px);
}
.fade-transform-leave-to {
  opacity: 0;
  transform: translateX(20px);
}
</style>

这里的关键点:

  • <keep-alive> :通过 :include 属性指定哪些组件需要被缓存。include 的值应该是组件的 name 选项。我们需要一个机制来动态管理这个列表,通常与标签页(TagsView)功能结合。
  • <transition> :为路由切换添加淡入淡出和轻微平移的动画效果。mode="out-in" 确保当前元素先离开,新元素再进入。
  • :key="route.fullPath" :这是保证同一路由不同参数页面能被正确区分的常见做法。例如 /user/1/user/2 会被视为不同的组件实例。

5.2 响应式布局适配

在移动设备上,我们的左右布局通常需要调整为上下布局,或者隐藏侧边栏。我们可以通过监听窗口大小变化,在 Pinia store 中更新设备类型,并据此调整布局。

首先,在 app store 中我们已经有了 device 状态。我们需要一个组合式函数来监听窗口变化:

typescript 复制代码
// src/composables/useResponsive.ts
import { onMounted, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'

const WIDTH = 768 // 移动端断点,通常设为 768px (iPad 竖屏)

export function useResponsive() {
  const appStore = useAppStore()

  const isMobile = () => {
    const rect = document.body.getBoundingClientRect()
    return rect.width - 1 < WIDTH
  }

  const resizeHandler = () => {
    if (!document.hidden) {
      const mobile = isMobile()
      appStore.toggleDevice(mobile ? 'mobile' : 'desktop')
      // 如果是移动端,自动关闭侧边栏
      if (mobile) {
        appStore.closeSidebar(true) // 关闭动画
      }
    }
  }

  onMounted(() => {
    window.addEventListener('resize', resizeHandler)
    // 初始化时判断一次
    resizeHandler()
  })

  onUnmounted(() => {
    window.removeEventListener('resize', resizeHandler)
  })
}

然后在布局根组件 src/layout/index.vue 中引入并使用这个 hook:

vue 复制代码
<!-- src/layout/index.vue (补充 script 部分) -->
<script setup lang="ts">
import { defineAsyncComponent, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/stores/app'
import { useResponsive } from '@/layout/hooks/useResponsive'

// ... 原有的异步组件导入 ...

const appStore = useAppStore()
const { device } = storeToRefs(appStore)

// 启用响应式监听
useResponsive()

// 监听设备变化,动态调整布局类名(可选)
watch(device, (newVal) => {
  const body = document.body
  if (newVal === 'mobile') {
    body.classList.add('mobile')
  } else {
    body.classList.remove('mobile')
  }
}, { immediate: true })
</script>

<style lang="scss" scoped>
// 添加移动端样式
.layout-container {
  // ... 原有样式 ...
}

// 移动端样式覆盖
body.mobile {
  .app-aside {
    position: fixed !important;
    top: 0;
    left: 0;
    z-index: 1001; // 确保在 Header 之上
    // 移动端可能希望侧边栏覆盖全屏,或者使用抽屉式
    transform: translateX(-100%);
    transition: transform 0.3s;
    &.opened {
      transform: translateX(0);
    }
  }
  .app-header {
    .collapse-trigger {
      // 在移动端,折叠按钮可能用于打开侧边栏抽屉
    }
  }
  // 当侧边栏打开时,给主内容区添加一个遮罩层
  .layout-mask {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 1000;
  }
}
</style>

5.3 标签页导航 (TagsView) 思路延伸

虽然不在基础布局的强制要求内,但标签页导航是许多中后台系统的标配功能。它的核心是维护一个访问过的页面路由数组,并以标签的形式展示在 AppHeader 下方或 AppMain 上方。结合 keep-aliveinclude,可以实现页面的缓存与快速切换。

实现标签页涉及更复杂的状态管理(当前激活标签、可关闭标签、缓存列表等)和与路由的联动(路由跳转时添加标签、后退时移除标签等)。如果你需要这个功能,可以基于 Pinia 创建一个 tagsView store 来集中管理,并在 AppHeader 或一个新的 TagsView 组件中渲染。这可以作为你完成基础布局后的一个进阶挑战。

6. 样式架构与全局主题定制

一个大型项目的样式管理需要良好的架构。我推荐采用 SCSSCSS 自定义属性(CSS Variables) 结合的方式。

目录结构建议:

复制代码
src/styles/
├── index.scss          # 全局样式入口
├── variables.scss      # 全局 CSS 变量定义(颜色、尺寸等)
├── mixins.scss         # SCSS 混入
├── transition.scss     # 动画相关样式(如之前的面包屑、路由过渡)
├── dark.scss           # 暗黑模式覆盖样式
└── common/             # 公共工具类
    ├── _flex.scss
    ├── _margin-padding.scss
    └── _text.scss

variables.scss 中定义主题变量:

scss 复制代码
// src/styles/variables.scss
:root {
  // 主题色
  --el-color-primary: #409eff;
  // 成功、警告、危险色等
  --el-color-success: #67c23a;
  --el-color-warning: #e6a23c;
  --el-color-danger: #f56c6c;
  --el-color-info: #909399;

  // 背景色
  --bg-color: #ffffff;
  --bg-color-page: #f2f3f5;
  --bg-color-overlay: #ffffff;

  // 文字色
  --text-color-primary: #303133;
  --text-color-regular: #606266;
  --text-color-secondary: #909399;
  --text-color-placeholder: #a8abb2;
  --text-color-disabled: #c0c4cc;

  // 边框色
  --border-color: #dcdfe6;
  --border-color-light: #e4e7ed;
  --border-color-lighter: #ebeef5;
  --border-color-extra-light: #f2f6fc;

  // 填充色
  --fill-color: #f0f2f5;
  --fill-color-light: #f5f7fa;
  --fill-color-lighter: #fafafa;
  --fill-color-extra-light: #fafcff;
  --fill-color-dark: #ebedf0;
  --fill-color-darker: #e6e8eb;
  --fill-color-darkest: #d4d7de;

  // 侧边栏
  --sidebar-bg-color: #001529;
  --sidebar-text-color: rgba(255, 255, 255, 0.65);
  --sidebar-active-text-color: #ffffff;
  --sidebar-active-bg-color: #1890ff;

  // 头部
  --header-bg-color: #ffffff;
  --header-height: 60px;
  --header-border-color: #e6e6e6;

  // 主内容区
  --main-bg-color: #f0f2f5;

  // 阴影
  --box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
  --box-shadow-light: 0 2px 8px rgba(0, 0, 0, 0.15);

  // 圆角
  --border-radius-base: 4px;
  --border-radius-small: 2px;
  --border-radius-round: 20px;
  --border-radius-circle: 50%;

  // 过渡
  --transition-duration: 0.3s;
  --transition-function: ease;
}

// 暗黑模式变量覆盖
html.dark {
  --bg-color: #141414;
  --bg-color-page: #0a0a0a;
  --bg-color-overlay: #1d1e1f;

  --text-color-primary: #e5eaf3;
  --text-color-regular: #cfd3dc;
  --text-color-secondary: #a3a6ad;
  --text-color-placeholder: #8d9095;
  --text-color-disabled: #6c6e72;

  --border-color: #4c4d4f;
  --border-color-light: #414243;
  --border-color-lighter: #363637;
  --border-color-extra-light: #2b2b2c;

  --fill-color: #262727;
  --fill-color-light: #2b2b2c;
  --fill-color-lighter: #303030;
  --fill-color-extra-light: #353536;
  --fill-color-dark: #3a3a3b;
  --fill-color-darker: #404040;
  --fill-color-darkest: #49494b;

  --sidebar-bg-color: #001529; // 可以保持深色
  --header-bg-color: #141414;
  --header-border-color: #303030;
  --main-bg-color: #0a0a0a;

  --box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
  --box-shadow-light: 0 2px 8px rgba(0, 0, 0, 0.8);
}

然后在组件样式中,使用这些变量:

scss 复制代码
// 在组件中
.app-header {
  height: var(--header-height);
  background-color: var(--header-bg-color);
  border-bottom: 1px solid var(--header-border-color);
  box-shadow: var(--box-shadow);
  color: var(--text-color-primary);
}

这种方式的好处是:

  1. 主题切换无缝 :只需切换 html 上的 dark 类,所有使用变量的地方都会自动更新。
  2. 维护方便:颜色、尺寸等设计令牌集中管理,易于统一修改。
  3. 与 Element Plus 集成:Element Plus 本身也使用 CSS 变量,我们的变量命名可以与其保持一致或覆盖它。

最后,在 main.ts 中引入全局样式入口:

typescript 复制代码
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import 'element-plus/dist/index.css'
import '@/styles/index.scss' // 引入全局样式

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

7. 性能优化与部署考量

当布局功能基本完成后,我们还需要关注一些性能和工程化方面的优化点,确保项目在生产环境有良好的表现。

7.1 组件与路由的懒加载

我们已经对布局组件使用了 defineAsyncComponent。对于路由组件,Vue Router 的动态 import 语法本身就支持懒加载。确保你的路由配置都像下面这样:

typescript 复制代码
{
  path: '/dashboard',
  name: 'Dashboard',
  component: () => import('@/views/dashboard/index.vue') // 懒加载
}

Vite 和 Webpack 会将这些动态导入的组件打包成独立的 chunk,只在访问对应路由时才加载,有效减少首屏资源体积。

7.2 图标按需引入方案

如果项目中使用了大量的图标(比如 Element Plus 图标或自定义 SVG 图标),全量引入会显著增加包体积。推荐以下两种方案:

方案一:使用 unplugin-icons 自动按需引入 这是一个非常强大的图标解决方案,可以集成多种图标集(Element Plus Icons, Iconify, 自定义 SVG 等)。

bash 复制代码
npm i -D unplugin-icons @iconify/json

vite.config.ts 中配置:

typescript 复制代码
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [
        // 自动导入 Element Plus 组件
        ElementPlusResolver(),
        // 自动导入图标组件
        IconsResolver({
          prefix: 'Icon', // 组件前缀,默认为 'i',这里设为 'Icon'
        })
      ],
    }),
    Icons({
      autoInstall: true, // 自动安装图标集
      compiler: 'vue3',
    }),
  ],
})

然后在组件中可以直接使用 <IconEpMenu />Ep 是 Element Plus 图标集的前缀)。

方案二:手动管理 SVG 图标 如果图标数量可控,也可以将 SVG 文件放在 src/assets/icons/ 目录下,使用一个全局的 SvgIcon 组件来渲染。

7.3 生产环境构建优化

Vite 提供了开箱即用的优化,但你还可以通过以下配置进一步提升:

typescript 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer' // 分析包体积

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    // 只在分析模式启用
    mode === 'analyze' && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
    })
  ].filter(Boolean),
  build: {
    rollupOptions: {
      output: {
        // 对 chunk 文件进行命名,避免哈希值变化导致缓存失效问题
        chunkFileNames: 'static/js/[name]-[hash].js',
        entryFileNames: 'static/js/[name]-[hash].js',
        assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
        // 手动拆包策略,将大的依赖或不太变动的代码单独打包
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('element-plus')) {
              return 'chunk-element-plus'
            }
            if (id.includes('vue')) {
              return 'chunk-vue'
            }
            return 'chunk-vendors' // 其他依赖
          }
        }
      }
    },
    // 启用 terser 压缩
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true, // 生产环境移除 console
        drop_debugger: true,
      },
    },
  },
}))

可以创建一个 package.json 脚本用于分析构建结果:

json 复制代码
{
  "scripts": {
    "build": "vite build",
    "build:analyze": "vite build --mode analyze"
  }
}

运行 npm run build:analyze 后会生成一个 dist/stats.html 文件,在浏览器中打开可以直观地看到各个模块的体积占比,便于进行针对性的优化。

7.4 部署注意事项

  • 路由 History 模式 :如果你使用了 createWebHistory()(即去掉了 URL 中的 #),在部署到非根路径或使用 Nginx 等服务器时,需要配置 回退路由 ,将所有前端路由请求重定向到 index.html

    nginx 复制代码
    # Nginx 配置示例
    location / {
      try_files $uri $uri/ /index.html;
    }
  • 环境变量 :使用 Vite 的环境变量 (import.meta.env) 来管理不同环境(开发、测试、生产)的 API 地址等配置。在项目根目录创建 .env.development, .env.production 等文件。

  • CDN 引入 :对于 Element Plus 这类较大的库,可以考虑通过 CDN 引入,以减小应用主包的体积。Vite 支持通过 build.rollupOptions.external 配置外部化依赖。

8. 常见问题排查与调试技巧

在开发过程中,你可能会遇到一些典型问题。这里列举几个我常遇到的及其解决方法:

1. 菜单激活状态不准确?

  • 检查点 :确保 el-menudefault-active 绑定的是正确的路径。使用 Vue Router 的 useRoute() 获取的 route.path 通常是完整的路径。如果路由有嵌套,可能需要使用 route.matched 中的最后一条记录。
  • 注意el-menurouter 属性为 true 时,index 属性值应与路由的 path 严格匹配。

2. Pinia 状态在页面刷新后丢失?

  • 检查点 :确保已正确配置 pinia-plugin-persistedstate 插件,并且在 store 定义中设置了 persist 选项。
  • 调试 :打开浏览器开发者工具的 Application -> Local Storage,查看对应的 key 下是否有数据。确保没有在 onMounted 等生命周期中意外重置了状态。

3. 主题切换后,自定义样式没变化?

  • 检查点 :确认暗黑模式的 CSS 变量文件已正确引入。检查你的自定义样式是否使用了 CSS 变量,并且这些变量在 :roothtml.dark 下有不同的值。
  • 技巧 :在浏览器开发者工具的 Elements 面板中,检查 html 标签上是否有 dark 类名。检查计算后的样式,看你的 CSS 变量是否被成功应用。

4. <keep-alive> 缓存不生效?

  • 检查点<keep-alive>include/exclude 属性依赖组件的 name 选项。确保你缓存的组件都显式定义了 name
  • 注意 :使用 <script setup> 语法时,Vue 会根据文件名自动推断 name,但如果你使用了类似 vite-plugin-vue-setup-extend 的插件在 <script setup> 上写 name,或者使用了两个 <script> 标签(一个带 setup,一个不带),需要确保 name 被正确设置。最稳妥的方式是使用 Options API 的 name 选项,或者使用 defineOptions 宏(Vue 3.3+)。

5. 响应式布局在移动端异常?

  • 检查点 :检查 CSS 中媒体查询的断点是否与 JavaScript 中判断 isMobile 的断点 (WIDTH) 一致。检查移动端下是否有定位 (position: fixed) 元素的 z-index 冲突。
  • 调试 :使用浏览器开发者工具的 Device Mode 模拟不同设备尺寸,观察布局变化和 console 中 device 状态的值。

6. 图标不显示?

  • 检查点 :如果使用自动导入,确认图标组件名称正确(如 <IconEpMenu />)。如果使用 SVG 组件,确认 SVG 文件路径正确,且 SvgIcon 组件能正常解析。
  • 网络问题:如果使用 Iconify 等在线图标集,检查网络连接。考虑在本地部署图标集或使用离线方案。

开发过程中,善用 Vue Devtools 和浏览器开发者工具是最高效的调试手段。Vue Devtools 可以直观地查看组件树、状态(Pinia store)、路由和事件。对于样式问题,浏览器的 Elements 和 Styles 面板是必不可少的。

搭建一个健壮、可扩展的后台管理系统布局,远不止是把几个 div 摆到正确的位置。它涉及路由架构、状态管理、组件通信、样式方案、性能优化和用户体验等多个方面。希望这篇从零开始的实战指南,不仅能帮你搭建出一个可用的布局,更能让你理解其背后的设计思路和最佳实践。在实际项目中,你可以根据业务需求,在这个基础上继续扩展,比如加入权限控制(动态路由和菜单)、多标签页、全局搜索、页面水印等功能。最重要的是,保持代码的清晰、可维护和可测试性,这样无论项目如何增长,你都能从容应对。

相关推荐
雨季mo浅忆2 小时前
记录Vue3项目中的各类问题
前端·bug·vue3
八目蛛3 天前
八目蛛网络(免费工具网站导航)
css·vue.js·开源·vue3·html5·ai编程
颂love3 天前
Vue3基础入门
前端·学习·vue3
海市公约4 天前
Vue3组合式API中watch传值生命周期与自定义Hook实战
vue3·生命周期·watch·props·组件通信·defineexpose·自定义hook
海市公约5 天前
Vue3组合式API与响应式系统核心机制详解
vue3·computed·reactive·ref·响应式系统·composition api·script setup
小茴香3536 天前
Vue3路由权限动态管理
前端·前端框架·vue3
暗冰ཏོ10 天前
《2026 Vue2 + Vue3 完整学习指南:基础语法、路由缓存、登录拦截、项目实战与面试题》
前端·vue.js·vue·vue3·vue2
曲幽11 天前
写页面时别再把 Element Plus 整个搬进来啦!Vue3按需加载的坑我帮你踩平了
vue3·web·vite·icon·element plus·vs code·import·unplugin
小云小白12 天前
若依-vue3 把深色版本改成天蓝色-含登录页
vue3·若依·天蓝色
曲幽13 天前
FastApiAdmin 后端接口开发好了,前端管理界面怎么调用与显示?
python·vue3·api·fastapi·web·ant design·view·menu·frontend