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

相关推荐
梵得儿SHI4 小时前
Vue3 生态工具实战进阶:API 请求封装 + 样式解决方案全攻略(Axios/Sass/CSS Modules)
前端·css·vue3·sass·api请求·样式解决方案·组合式api管理
行者-全栈开发1 天前
43 篇系统实战:uni-app 从入门到架构师成长之路
前端·typescript·uni-app·vue3·最佳实践·企业级架构
沙振宇1 天前
【Web】使用Vue3+PlayCanvas开发3D游戏(二)3D 地图自由巡视闯关游戏
游戏·3d·vue3·playcanvas
沙振宇2 天前
【Web】使用Vue3+PlayCanvas开发3D游戏(一)3D 立方体交互式游戏
游戏·3d·vue·vue3·playcanvas
小圣贤君2 天前
从「选中一段」到「整章润色」:编辑器里的 AI 润色是怎么做出来的
人工智能·electron·编辑器·vue3·ai写作·deepseek·写小说
之歆16 天前
Vue3 + Vite2.0 全栈开发实践:从零到一构建通用后台管理系统-上
vue3·vite2.0
之歆16 天前
Vue3 + Vite2.0 全栈开发实践:从零到一构建通用后台管理系统-下
javascript·vue.js·vue3
麦麦大数据18 天前
M004_基于Langchain+RAG的银行智能客服系统设计与开发
typescript·langchain·flask·vue3·faiss·rag
哆啦A梦158820 天前
Vue3魔法手册 作者 张天禹 012_路由_(一)
前端·typescript·vue3