CMS-vue3-内容管理平台

834 阅读13分钟

CMS后台管理项目笔记

一、创建项目搭建

1、创建项目指令(使用vite创建)

pnpm create vue@latest

2、插件安装、完成相关配置。

editorconfig配置,确保不同ide上的编码风格一致。

安装插件editorconfig for vscode,安装完进行配置

# http://editorconfig.org

root = true

[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格(tab | space)
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行尾的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行

[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off
trim_trailing_whitespace = false

prettierrc插件以及相关的配置

插件安装好后要进行相关的配置,本项目配置如下。注意需要对vscode进行一些配置

{
  "$schema": "https://json.schemastore.org/prettierrc",
  "useTabs": false,
  "tabWidth": 2,
  "printWidth": 80,
  "singleQuote": true,
  "trailingComma": "none",
  "semi": false
}

ESLint配置---eslint.config.js
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import { rules } from 'eslint-plugin-vue'

// 配置 ESLint(一个代码检查工具)的规则和设置。帮助开发者识别和修复代码中的问题,确保代码的一致性和可维护性
export default [
  {
    name: 'app/files-to-lint',
    files: ['**/*.vue', '**/*.ts', '**/*.mts', '**/*.tsx'] // 允许所有 Vue 和 TypeScript 文件
  },
  {
    name: 'app/files-to-ignore',
    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'] // 确保未忽略任何单词命名的文件
  },
  ...pluginVue.configs['flat/essential'],
  ...vueTsEslintConfig(),
  skipFormatting,
  rules['vue/no-multiple-template-root']
]
tsconfig.json文件的配置
  • 作用:这是 TypeScript 项目的基本配置文件。它定义了 TypeScript 编译器的选项,以及要编译的文件和排除的文件。通常包括项目的根级配置,适用于整个项目。
  • 常见配置选项
    • compilerOptions:编译器选项,如 target(编译的 JavaScript 版本),module(模块系统),strict(严格模式),outDir(输出目录)等。
    • include:指定要包含的文件或目录,通常为所有 TypeScript 文件。
    • exclude:指定要排除的文件或目录,避免编译某些特定文件。
// tsconfig.json
{
  "files": [],
  "references": [
    {
      "path": "./tsconfig.node.json"
    },
    {
      "path": "./tsconfig.app.json"
    }
  ]
}
// tsconfig.node.json
{
  "extends": "@tsconfig/node22/tsconfig.json",
  "include": [
    "vite.config.*",
    "vitest.config.*",
    "cypress.config.*",
    "nightwatch.conf.*",
    "playwright.config.*"
  ],
  "compilerOptions": {
    "noEmit": true,
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",

    "module": "ESNext",
    "moduleResolution": "Bundler",
    "types": ["node"]
  }
}
// tsconfig.app.json
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": [
    "env.d.ts",
    "src/**/*",
    "src/**/*.vue",
    "auto-imports.d.ts",
    "components.d.ts"
  ],
  "exclude": ["src/**/__tests__/*"],
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

tsconfig.app.json 和 tsconfig.node.json主要都是从tsconfig.json中继承过来的。通常用于配置应用程序特定的 TypeScript 编译选项。它可能用于前端应用(如 Angular、React 等),专注于编译应用程序代码,而不包括测试或构建工具相关的代码。

vite.config.ts

vite.config.ts 文件用于配置 Vite 的行为。你可以在这个文件中设置各种选项,比如:

  • 服务器配置
  • 构建选项
  • 插件
  • 路径别名
  • 环境变量

主要的配置项

plugins

  • 作用:用于扩展 Vite 的功能,通常可以通过插件来支持特定的框架或功能。
  • 示例:在上面的例子中,使用了 @vitejs/plugin-vue 插件来支持 Vue.js。

server

  • 作用:配置开发服务器的行为。
  • 常见选项
    • port:指定开发服务器的端口号,默认为 3000。
    • open:设置为 true 时,启动服务器后自动打开默认浏览器。
    • proxy:配置代理,以解决跨域问题。

build

  • 作用:配置生产构建的行为。
  • 常见选项
    • outDir:指定构建输出的目录,默认为 dist
    • sourcemap:是否生成 sourcemap 文件,方便调试。
    • minify:是否启用代码压缩,通常设置为 esbuildterser

resolve

  • 作用:配置模块解析的行为。

  • 常见选项

    • alias:定义路径别名,便于在项目中引用模块。
    resolve: {
      alias: {
        '@': '/src', // 将 '@' 替换为 '/src'
      },
    },
    

使用环境变量

vite.config.ts 中,你可以访问环境变量,使用 import.meta.env 来获取环境变量的值。你可以在 .env 文件中定义环境变量,例如:

ENV
VITE_API_URL=https://api.example.com

然后在 vite.config.ts 中使用:

console.log(import.meta.env.VITE_API_URL);

3、 要了解目录上每个文件的作用。重点掌握这些配置文件

3、CSS样式重置

找现成的copy一下

4、路由配置

5、状态管理

6、网络请求封装axios

7、区分development和production环境的区别

三种方式一共

1、手动

2、Vite 在一个特殊的 import.meta.env 对象上暴露环境变量。这里有一些在所有情况下都可以使用的内建变量:

import.meta.env.MODE: {string} 应用运行的模式。

import.meta.env.PROD: {boolean} 应用是否运行在生产环境。

import.meta.env.DEV: {boolean} 应用是否运行在开发环境 (永远与 import.meta.env.PROD相反)。

import.meta.env.SSR: {boolean} 应用是否运行在 server 上。

3、Vite 使用 dotenv 从你的 环境目录 中的下列文件加载额外的环境变量:

8、element-plus的配置

直接参考官网。采用按需导入,安装两个插件实现自动导入(需要注意的是只有包含在template中的内容能够实现按需导入,其他则不行)。还要注意安装了新的插件会给我们配置文件的列表添加两个文件,我们需要把这两个.d.ts文件放到我们的tsconfig.json文件中的include配置项下,确定能够给他正常的编译。

二、登录界面的实现

登录功能实现遇到的跨域问题

父组件子组件之间的消息传递

触发父组件的某个点击事件然后去调用子组件的方法来完成消息的传递,实现某些行为。

关于pinia在登录功能的应用

集中存储用户信息

  • 用户的 namepasswordtoken 等信息应存储到 Pinia 中,这样可以集中管理,方便后续调用,避免数据分散导致的不便。
  • 我们需要把IAccount抽取出去成一个接口,这样是为了

网络请求与存储

  • 获取用户信息的网络请求应直接在 Pinia 的 action 中发送。
  • 请求成功后,将获取到的用户信息存储到 Pinia 的 state 中,便于全局共享和组件间数据同步。

组件中调用 Pinia

  • 在需要使用用户信息的组件(如 paneAccount)中,不再单独发送请求,而是直接调用 Pinia 中的 action 或读取状态。
  • 这样可以通过调用 Pinia 提供的函数完成用户信息的获取和更新,简化组件逻辑。

缓存工具的封装--catch类

有时候需要缓存一些复杂的对象,例如用户信息对象、设置配置等。直接使用原生浏览器缓存API存储这些对象时需要手动进行JSON序列化和反序列化操作,过程相对繁琐。为了方便管理和操作浏览器的本地缓存和会话缓存,所以封装一个通用的工具类来处理这些操作。

// 但有时候需要缓存一些复杂的对象,例如用户信息对象、设置配置等。直接使用原生浏览器缓存API存储这些对象时需要手动进行JSON序列化和反序列化操作,过程相对繁琐
// 为了方便管理和操作浏览器的本地缓存和会话缓存,所以封装一个通用的工具类来处理这些操作
enum CacheType {
  Local,
  Session
}

class Cache {
  storage: Storage

  constructor(type: CacheType) {
    this.storage = type === CacheType.Local ? localStorage : sessionStorage
  }

  setCache(key: string, value: any) {
    if (value) {
      // JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串
      this.storage.setItem(key, JSON.stringify(value))
    }
  }

  getCache(key: string) {
    const value = this.storage.getItem(key)
    if (value) {
      // JSON.parse() 方法用于将一个 JSON 字符串转换为对象
      return JSON.parse(value)
    }
  }

  removeCache(key: string) {
    this.storage.removeItem(key)
  }

  clear() {
    this.storage.clear()
  }
}

const localCache = new Cache(CacheType.Local)
const sessionCache = new Cache(CacheType.Session)

export { localCache, sessionCache }

路由导航守卫

导航守卫:因为我们的默认路径是重定向到/main的,但重定向的前提是我们的用户是已经登录成功了的。如果我们的用户没有登录(没有找到存储的token),就需要写个导航守卫进行一个拦截,让他去跳转到login页面。

// 导航守卫逻辑
router.beforeEach((to) => {
  const token = localCache.getCache('login/Token')
  if (to.path === '/main' && !token) return '/login'
})

退出登录

这个功能实现比较简单,主要是两步,第一步删除token,第二步就是router.push('/login')退回到登陆界面。

记住密码

主要是通过本地存储的方式实现,根据我们的变量isRememberPwd是true还是false来决定是否需要把我们的账号和密码放进本地存储中去。同时可以利用监听器watch通过对isRememberPwd的监听(随着isRememberPwd的变化来动态放入本地存储,需要注意的是这里需要修改我们的cache方法中的setcache,取消if(value)这个判断),以此来实现一些小细节的东西。

手机号登录、扫码登录的实现

回头模拟一遍

三、主页面搭建(main)

权限控制(RBAC)

chatgpt总结问题。

动态渲染menu菜单的展示(基于啥?)

首先我们要是事先把用户的个人信息也缓存起来,要不然一刷新就丢掉了都。

用动态组件进行渲染<component :is="isFold ? 'Fold' : 'Expand'" />

组件通信的几种方式
方式适用场景
Props父组件向子组件传递数据
自定义事件 (emit)子组件向父组件传递数据
v-model父子组件双向绑定数据
provide/inject祖先组件向深层组件提供数据
事件总线(Mitt)兄弟组件通信
Pinia(全局状态管理)适用于大型应用的共享状态

关于VUE的动态路由(两种方法)

基于role的

垃圾还麻烦,不便于更新

基于menus的(后台返回来的数据)

主要是利用router.addRoute('main', route)把我们获取到的route 追加到 name 为 'main' 的路由对象的 children 中。

关于如何实现跳转?

// // 2.监听item点击
const router = useRouter()
function handleItemClick(item: any) {
  router.push(item.url)
}
设置一个监听事件,如果触发了就直接push当前的item的url,因为这个url已经注册过了在我们的router的children中。
menus方案的实现过程

在我们的项目中,菜单是基于用户权限动态生成的。不同用户登录后,能访问的页面不同,因此不能在 router/index.ts提前写死所有路由
我们需要根据后端返回的用户菜单数据动态加载对应的路由,保证用户只能访问有权限的页面。

1.预加载所有可能的路由

我们在 router/main/ 目录下存放了所有可能用到的子路由,每个功能模块的路由都单独存放。

但这些路由不会默认注册,我们会在用户登录后,根据菜单数据动态匹配并添加到 Vue Router(对象)中。

加载所有路由

function loadLocalRoutes() {
  // 1. 通过 import.meta.glob() 批量导入 main 目录下的所有路由
  const modules: Record<string, any> = import.meta.glob(
    '../router/main/**/*.ts',
    { eager: true } // 立即加载,而不是懒加载
  )

  // 2. 遍历所有导入的模块,并提取 default 导出的路由对象
  const routes: RouteRecordRaw[] = []
  for (const key in modules) {
    const route = modules[key].default //获取 default 导出的路由对象
    routes.push(route)
  }
  return routes
}

📌 作用:

  • 通过 import.meta.glob() 一次性加载所有可能的子路由

  • 遍历这些文件,并将 default 导出的路由对象存入 routes 数组,方便后续匹配。

    注:routes 是一个存放路由对象的数组,类型是 RouteRecordRaw[],表示它是Vue Router 路由记录的数组routes 数组是一个临时存储所有可能的路由的地方,它本身不会直接被 Vue Router 使用,而是先存起来,等用户登录后,根据权限筛选并动态注册。这样可以:

    • 减少路由冗余,只注册需要的路由。
    • 提高安全性,避免无权限用户访问某些页面。
    • 支持动态菜单,后端可以自由配置菜单,前端无需修改代码。

2. 根据用户菜单匹配对应的路由

登录后,我们会从后端获取用户可访问的 menu 列表,我们需要从 localRoutes 里筛选出匹配的路由

export function mapMenuToRoutes(menus: any[]) {
  const localRoutes = loadLocalRoutes() // 加载所有的路由对象
  const finalRoutes: RouteRecordRaw[] = [] // 存放最终匹配到的路由

  function _recurseGetRoute(menus: any[]) {
    for (const menu of menus) {
      if (menu.type === 2) { // type: 2 代表具体的路由页面
        const route = localRoutes.find((item) => item.path === menu.url)
        if (route) finalRoutes.push(route)
        if (!firstRoute && route) firstRoute = route // 记录第一个可访问的路由
      } else { 
        if (menu.type === 1 && menu.children.length) {
          finalRoutes.push({ path: menu.url, redirect: menu.children[0].url }) // 目录类菜单设置 redirect
        }
        _recurseGetRoute(menu.children ?? []) // 递归处理多级菜单
      }
    }
  }

  _recurseGetRoute(menus)
  return finalRoutes
}

📌 作用:

  • 递归遍历菜单,查找 type === 2(具体页面)的路由,并匹配 localRoutes 里对应的 path
  • 如果是 type === 1(目录类菜单),则添加重定向,确保点击目录后进入它的第一个子页面。
  • 支持多级菜单结构,无论菜单层级多少,都能正确匹配。

3. 动态注册匹配到的路由

router/index.ts 里,我们会:

  • 获取用户菜单 userMenus
  • 调用 mapMenuToRoutes(userMenus) 获取用户可访问的路由
  • 遍历 routes,将其动态添加到 main 路由的 children 里
const userMenus = localCache.getCache('userMenus')
if (userMenus) {
  const routes = mapMenuToRoutes(userMenus)
  routes.forEach((route) => {
    router.addRoute('main', route) // 动态添加到 main 下面,route是一个对象
  })
}

📌 作用:

  • 通过 router.addRoute('main', route) 动态注册子路由,确保 main 组件加载后,它的 children 里有正确的子路由。说白了就是router.addRoute('main', route) 会把 route 直接添加到 name: 'main' 的路由对象的 children 数组里,相当于动态扩展 main 路由的子路由

4. 解决的问题

根据用户权限动态加载路由,避免硬编码所有路由,提高安全性和可维护性。 ✅ 支持多级菜单递归匹配,无论菜单层级多少,都能正确映射到路由。 ✅ 优化性能,只加载当前用户需要的路由,而不是整个 router/main/ 目录。 ✅ 保证路由刷新后不会丢失,通过 beforeEach 重新加载动态路由,防止 Vue Router 初始化时丢失动态添加的部分。

注意:beforeEach 是 Vue Router 的全局前置守卫,它在每次路由跳转前都会执行

可能的面试追问

🧐 如果用户刷新页面,动态路由会丢失吗?怎么解决?(其实说白了就是动态路由丢失的解决方案,一共两种,一个是在前置路由守卫中加一个flag控制--下边的答案。另外一个就是我现在的方案,我直接把他放到我的store的action中,只要触发action中的函数,自动执行我的动态路由处理。)

📌注:正常刷新页面后loginAccountAction函数不会再执行一遍,因为他只有登录的时候才会执行一次,又因为我们的动态路由初始化的逻辑在我们的loginAccountAction函数体内,所以要处理一下刷新后动态路由丢失的问题。

方法一(最佳方案)--回答:

// 在我们的loginStore的action中定义一个方法
loadLocalDataAction() {
      this.token = localCache.getCache('token')
      this.userInfo = localCache.getCache('userInfo')
      this.userMenus = localCache.getCache('userMenus')
      addRoutesWithMenu(this.userMenus)
    }
// 每次刷新页面都要调用这个方法---在main.ts中调用这个方法,重新初始化并注册我们丢失掉的动态路由。
// 需要注意的是:在我们的main.ts中调用这个方法的时候一定要在app.use(router)的前边去调用他,在他的后边调用相当于白整。

方法二--回答:

是的,Vue Router 不会持久化动态添加的路由,刷新后会丢失。因此,我们在 beforeEach检查并重新添加动态路由

router.beforeEach((to, from, next) => {
  if (!router.hasDynamicRoutes) {
    const userMenus = localCache.getCache('userMenus')
    if (userMenus) {
      const routes = mapMenuToRoutes(userMenus)
      routes.forEach((route) => {
        router.addRoute('main', route)
      })
    }
    router.hasDynamicRoutes = true // 避免重复添加
    return next(to.fullPath) // 重新导航,确保生效
  }
  next()
})
router.hasDynamicRoutes 不是 Vue Router 内置的属性,而是你们项目里可能自定义的一个变量。
在动态路由的实现中,通常会用一个布尔值(比如 hasDynamicRoutes)来标记是否已经动态添加过路由,避免重复添加。
🔹 作用:
防止路由重复添加(如果已经添加过,hasDynamicRoutes 为 true,就不会重复执行 addRoute)。
提升性能(如果不加这个判断,每次切换页面可能都会重复执行 mapMenuToRoutes,浪费性能)。

🧐 Vue Router 什么时候初始化动态路由?

回答:

在 Pinia 的 login 模块中调用 addRoutesWithMenu 进行动态路由注册

  • 登录成功后,从后端获取 userMenus 并存入 localStorage
  • 调用 addRoutesWithMenu(userMenus) 将动态路由注册到 router 中。

页面刷新后,store 重新加载本地缓存,并再次动态注册路由

  • store 初始化时执行 loadLocalDataAction(),从 localStorage 读取 userMenus 并调用 addRoutesWithMenu,确保路由不会丢失。

🧐 为什么不用 Vuex 或 Pinia 存路由,而是用 router.addRoute?

回答:

Vuex/Pinia 适合存储状态,但不会真正影响 Vue Router 的解析。即使我们把动态路由数据存到 Pinia/Vuex,Vue Router 仍然无法识别这些路由,只有通过 router.addRoute 真正注册,Vue 才能解析并正确跳转。

在我的项目中,动态路由数据是 在执行 Pinia 的 action 方法时生成的,此时就可以直接 router.addRoute没有必要额外存入 Pinia/Vuex,因为:

  1. 存到 Vuex/Pinia 并不会自动让 Vue Router 解析,还需要额外调用 router.addRoute
  2. 路由信息本身不会频繁变动,动态注册后,Vue Router 就能正确解析,无需反复存取状态管理。
  3. 页面刷新时,store 重新执行 action 方法(如 loadLocalDataAction),会再次 router.addRoute,保证路由不会丢失

在项目中,我们的动态路由实现

  • 预加载所有可能的路由
  • 根据用户菜单匹配可访问的路由
  • 动态注册到 main 组件的 children
  • 防止刷新后丢失动态路由

这种方式可以提高安全性,避免不必要的路由暴露,同时提升维护性,当后台新增菜单时,不需要改前端代码,只需要更新后端返回的数据即可!

实现login成功默认显示第一个页面以及刷新后菜单默认值的设置
面包屑组件的实现

四、User页面的实现(main-system-user)

user列表的搭建(作用域插槽)

当我们再用到一些高级组件的时候,一定会涉及到作用于插槽的使用。插槽 (slot) 允许父组件向子组件传递 HTML 结构,并在子组件内部的特定位置渲染。说白了作用域插槽**(Scoped Slot)就是 子组件可以把自己的数据暴露给父组件**,然后父组件使用这些数据进行操作,最终决定如何渲染内容。

具名插槽概念补充

具名插槽允许我们在子组件的多个位置插入不同内容,并且可以指定名称,父组件可以有选择地填充这些插槽。

子组件:
<template>
  <div class="card">
    <header>
      <slot name="header">默认标题</slot>
    </header>
    <main>
      <slot>默认内容</slot>
    </main>
    <footer>
      <slot name="footer">默认页脚</slot>
    </footer>
  </div>
</template>
父组件:
<Child>
  <template #header>
    <h1>我是自定义的标题</h1>
  </template>
  
  <template #default>
    <p>我是自定义的主要内容</p>
  </template>

  <template #footer>
    <p>我是自定义的页脚</p>
  </template>
</Child>

作用域插槽scoped slot)允许子组件向父组件暴露数据,并由父组件决定如何渲染内容。

<el-table-column align="center" prop="enable" label="状态" width="120">
  <template #default="scope">
    <el-button
      :type="scope.row.enable ? 'success' : 'danger'"
      plain
      size="small"
    >
      {{ scope.row.enable ? '启用' : '禁用' }}
    </el-button>
  </template>
</el-table-column>

上述代码解读

  • prop="enable" 表示这一列显示 row.enable 的值(也就是 true/false)。

  • <template #default="scope"> 这里的 scope子组件传递给插槽的参数,通常包含:

    scope.row:当前行的数据

    scope.column:当前列的信息

    scope.$index:当前行的索引(行号)

分页(动态传参)

核心逻辑是当页码发生改变的时候重新调用请求函数,重新去请求数据。通过一系列的双向绑定获取到我们需要的参数,把我们需要的参数打包成一个对象传递过去即可。

async function fetchUserListData(queryInfo: any = {}) {
  // 1. 获取 store 的响应式引用
  // 注意顺序,先获取响应式引用,再计算分页参数,如果不先获取响应式引用,userList.value 会是 undefined
  // 2. 计算分页参数
  const size = pageSize.value
  const offset = (currentPage.value - 1) * size
  // 我们获取用户列表是一个post请求,发送post请求需要我们带上分页数据size和offset
  const info = { size, offset }
  // 3. 触发异步请求
  await systemStore.postUsersListAction(info)
  // 4. 现在 userList.value 已经有数据
  console.log(userList.value) // ✅ 这里不会是 undefined
}
fetchUserListData()

// 2.展示数据
// const { usersList, usersTotalCount } = storeToRefs(systemStore)

// 3.绑定分页数据
function handleCurrentChange() {
    // 当前页码发生改变
  fetchUserListData()
}
function handleSizeChange() {
    // 改变size
  fetchUserListData()
}

查询用户功能实现(组件通信-兄弟)

pagecontent和pagesearch是兄弟组件,我们实现查询功能(兄弟组件之间的通信)有两种解决方案,一种是通过事件总线(但是这个项目还没有复杂到那种程度,所以不建议)。另外一种方案是通过defineEmits实现,因为content和search都有一个共同的父组件user,所以我们可以把子组件search的数据通过emits传递给user组件,然后user直接通过<page-content ref="contentRef" />获取到content组件,代码如下:

const contentRef = ref<InstanceType<typeof PageContent>>()
function handleQueryClick(searchInfo: any) {
  contentRef.value?.fetchUserListData(searchInfo)
}
function handleResetClick() {
  contentRef.value?.handleResetClick()
}

根据我们获取到的contentref去调用content组件中的方法,把从search中拿到的数据给content传递过去发送网络请求获取数据。

删除用户功能实现

删除功能需要注意的是需要用到作用域插槽,因为我们需要获取到用户的id,通过用户的id来实现数据的删除。

<el-table-column align="center" label="操作" width="180">
          <template #default="scope">
            <el-button
              type="primary"
              size="small"
              icon="EditPen"
              link
              @click="handleEditClick(scope.row.id)"
              >编辑</el-button
            >
            <el-button
              type="danger"
              size="small"
              icon="Delete"
              link
              @click="handleDeleteClick(scope.row.id)"
              >删除</el-button
            >
          </template>
</el-table-column>

涉及到作用于插槽scope.row.id的使用,我们需要通过获取到的id信息来实现删除操作,把获取到的id传递给函数发送请求。

###新增用户功能实现(组件通信-兄弟)

🔥 emit vs expose:父组件修改子组件状态的选择

方式优点缺点适用场景
emit (事件传递)✅ 符合 Vue 单向数据流结构清晰,更符合 Vue 组件通信规范。 ✅ 使子组件保持独立,降低耦合度,子组件不依赖父组件的具体逻辑。❌ 需要 额外定义 props 和 emit,代码相对复杂。 ❌ 父组件必须监听 事件并手动更新数据,否则不会生效。**当子组件的状态是由父组件管理时,应该使用 emit。**比如:子组件状态需要同步到全局状态,或者多个组件共享状态
expose (暴露方法)✅ 允许 父组件直接调用子组件方法,代码直观,修改状态更方便。 ✅ 适用于 复杂组件,如表单重置、刷新等功能。自由度更高,可以在修改之前做一些判断进行拦截。破坏 Vue 的单向数据流强耦合,父组件必须了解子组件内部实现。 ❌ 可能导致 组件复用性降低,因为父组件直接控制子组件行为,子组件在不同场景下可能难以复用。**当父组件需要主动控制子组件的状态,而子组件本身不需要通知父组件时,可以使用 expose。**比如:表单组件需要让父组件调用 resetForm() 来清空内容

📌 Vue3 + Pinia 下拉选择数据绑定 & 问题排查

关于Pinia storeToRefs 及响应式数组的使用:

1. storeToRefs 的作用

  • 将 Pinia store 的 state 属性转换为 ref,保持响应性。
  • 直接解构 const { entireDepartments, entireRoles } = mainStore 会丢失响应性,所以要用 storeToRefs(mainStore) 处理。

2. 为什么 v-for 能自动更新(使用storeToRefs解构出来的数据可以使v-for自动更新)?

  • storeToRefs 使 entireDepartmentsentireRoles 仍然是 响应式的 ref
  • Vue 的 v-for 会自动跟踪数组的增减,当数组发生变化时,DOM 也会随之更新

3. 数组增删时的自动更新

  • 增加元素:使用 push() 方法,Vue 会自动更新列表。
  • 删除元素:使用 filter() 重新赋值,Vue 也能检测到变化。

(1)Pinia state 数据结构与初始化错误

问题

  • entireDepartmentsentireRoles 本质上是一个对象 { list: [], totalCount: 0 },但在 state 初始化时没有正确指定结构,导致 list 可能为 undefined
  • Vue 组件使用 storeToRefs(mainStore) 解构时,直接访问 entireDepartments 其实是一个 Proxy 对象,内部数据结构与预期不符,我们只是要这个 Proxy 对象中的list数组。

解决方案:一定要确认好请求过来的东西的数据类型再进行渲染。方法一,在pinia中只获取list,别的不要。方法二,直接在templat中调用这个对象的list,我用的方法二

(2)Pinia 数据更新异步问题

问题fetchEntireDataAction() 确实执行了,但 Vue 组件可能在 Pinia 数据未更新完成 时就尝试渲染 ElSelect,导致 entireDepartments.list 仍然为空数组 [],从而下拉框无数据。

数据获取是异步的,组件渲染时可能数据尚未准备好,需 watchawait 处理

Vue 可能会在数据未加载完成时就渲染 setup(),导致 ElSelect 为空。

  • 如果数据在组件初始化时必须存在:使用 awaitfetchEntireDataAction() 完成后再渲染。
  • 如果数据可能会在生命周期中变化:使用 watch() 监听数据变化,确保组件能够动态更新。
  • 如果想确保 Vue 绑定的数据总是有效的:使用 computed(),保证 list 始终为数组,不会出现 undefined

方式 1:使用 watch 监听数据变化

watch(() => entireDepartments.list, (newVal) => {
  console.log('部门数据已更新:', newVal);
});

修改用户信息功能实现

大体上同新增,只不过是patch请求。

五、Department页面的实现(高阶组件封装)

本部分主要对超级抽取封装高阶组件的实现思路进行整理。我们的目的不仅仅是简单的实现组件的高阶抽取,更要实现对组件的定制化,定制出我们任何想要的效果。拥有超强复用性。

request部分的抽取

// 针对页面数据的增删改擦请求接口(抽取版本)
export function postPageListData(pagename: string, Info: any) {
  return service({
    url: `/${pagename}/list`,
    method: 'post',
    data: Info
  })
}

export function deletePageById(pagename: string, id: number) {
  return service({
    url: `/${pagename}/${id}`,
    method: 'delete'
  })
}

export function editPageData(pagename: string, id: number, Info: any) {
  return service({
    url: `/${pagename}/${id}`,
    method: 'patch',
    data: Info
  })
}

export function newPageData(pagename: string, Info: any) {
  return service({
    url: `${pagename}`,
    method: 'post',
    data: Info
  })

Pinia部分的抽取

const useSystemStore = defineStore('system', {
  state: (): ISystemState => ({
    pageList: [],
    pageTotalCount: 0
  }),
  actions: {
    // 针对页面数据的增删改擦请求接口(抽取版本)
    async getPageListDataAction(pagename: string, Info: any) {
      const result = await postPageListData(pagename, Info)
      const { totalCount, list } = result.data.data
      this.pageList = list
      this.pageTotalCount = totalCount
    },
    async deletePageDataAction(pagename: string, id: number) {
      await deletePageById(pagename, id)
      this.getPageListDataAction(pagename, { page: 1, limit: 10 })
    },
    async editPageDataAction(pagename: string, id: number, Info: any) {
      await editPageData(pagename, id, Info)
      this.getPageListDataAction(pagename, { page: 1, limit: 10 })
    },
    async newPageDataAction(pagename: string, Info: any) {
      await newPageData(pagename, Info)
      this.getPageListDataAction(pagename, { page: 1, limit: 10 })
    }
  }
})

几个基础高阶组件的抽取封装(普通抽取)

整个组件的封装流程:

1、1️⃣ 定义 props 和 interface(保持灵活性) 你的 IProps 目前的设计是可以的,但建议:

  • 避免 any[],尽量用具体的类型
  • 考虑是否需要可选属性,比如 formItems?
  • 增加默认值处理,防止 undefined
interface IFormItem {
  prop: string
  label: string
  type: 'input' | 'select' | 'datePicker' | 'switch'
  options?: { label: string; value: any }[] // 仅 select 需要
  placeholder?: string
  rules?: any[] // 校验规则
}

interface IModalConfig {
  pageName: string
  title: string
  formItems: IFormItem[]
}

interface IProps {
  modalConfig: IModalConfig
  otherInfo?: Record<string, any> // 适用于扩展数据
}

2️⃣ 定义内部的属性和数据绑定

  • 管理内部 formData
  • 利用 computed() 处理动态数据
  • 避免直接修改 props,用 ref() or reactive() 代替

3️⃣ 写页面结构(保持 Slot 灵活性)

  • 支持 Slot,比如自定义底部按钮
  • 表单 v-model 绑定 formData

4️⃣ 写数据获取逻辑

  • API 请求写在 Store 中(避免组件内部管理复杂逻辑)
  • 使用 computed() 让组件自动获取数据

5️⃣ 封装 config.ts(管理组件配置)

  • 提取 formItems
  • 保证结构清晰,便于扩展
modal组件的封装

主要难点是options的获取,具体实现:

使用computed,computed依赖store,当store中的数据发生变化的时候,computed会重新执行生成一个新的modelconfigref对象,代码如下:

我们使用map把mainStore.entireDepartments.list数组中的对象转换成一个{ label: item.name, value: item.id }形式的原因是我们在封装组件库,要有通用性,别的页面还用的,这就是我们定义的一个标准!选择框中必须接受一个{ label: item.name, value: item.id }类型的对象,其实我们在定义类型的时候应该拿ts给他强化一下。

<page-modal :modal-config="modalConfigRef" ref="modalRef" />

// 通过pinia获取options选项中填充的数据
const mainStore = useMainStore()
//computed() 会自动追踪 你在计算过程中用到的响应式变量,一旦这些变量发生变化,computed 计算的值就会自动更新。
const modalConfigRef = computed(() => {
// mainStore.entireDepartments.list是一个响应式的数据,因为因为 Pinia 是 Vue 的状态管理库,它默认使用 Vue 的响应式系统(reactive()),所以 mainStore.entireDepartments.list 本质上是一个响应式对象。因为 Pinia 默认用 reactive() 处理 state,Vue 会递归让所有嵌套对象(包括 list)都变成响应式的。    
  const department = mainStore.entireDepartments.list.map((item) => {
    return { label: item.name, value: item.id }
  })
  modalConfig.formItems.forEach((item) => {
    if (item.prop === 'parentId') {
      // 把获取到的数组赋值给optionsitem.options = department // 直接赋值,避免 push() 类型错误
      item.options = department as any
    }
  })
  return modalConfig
})

<template v-if="item.type === 'select'">
    <el-select
    v-model="formData.parentId"
    :placeholder="item.placeholder"
    style="width: 100%"
    >
    <template v-for="value in item.options" :key="value.value">
        <el-option :value="value.value" :label="value.label" />
    </template>
    </el-select>
</template>

进一步强化-实现定制化

核心思路:具名插槽和作用域插槽的结合使用(我项目中的实现主要是在el-table的基础上再添加作用域插槽和具名插槽实现高阶的抽取)

🔹 具名插槽:给组件预留定制化位置

🔹 作用域插槽:让父组件能访问子组件的数据

具名插槽获取精准位置,作用域插槽获取数据来实现对内容的定制化

<template>
  <el-table :data="tableData" border>
    <el-table-column v-for="item in columns" :key="item.prop" :prop="item.prop" :label="item.label">
      <template #default="scope">
        <!-- 允许调用方传入自定义内容 -->
        <slot :name="item.prop" v-bind="scope"></slot>
      </template>
    </el-table-column>
  </el-table>
  <slot name="extra-footer"></slot> <!-- 额外的具名插槽 -->
</template>

<script setup>
defineProps({
  tableData: Array,
  columns: Array
})
</script>

业务要求:在实现某一个页面的时候要对某两行实现一个特殊的定制,业务组件中使用定制化插槽,代码如下

<DepartmentTable :table-data="departmentList" :columns="columns">
  <!-- 自定义渲染 "部门名称" 列,直接解构scope.row -->
  <template #name="{ row }">
    <span style="color: red; font-weight: bold">{{ row.name }}</span>
  </template>

  <!-- 在表格底部插入额外内容 -->
  <template #extra-footer>
    <el-button type="primary" @click="handleExport">导出数据</el-button>
  </template>
</DepartmentTable>

hooks的理解,使用hooks对页面逻辑实现抽取

🔹 为什么使用 Hooks

在 Vue 3 中,我们可以用 hooks(组合式 API 函数)抽取可复用的业务逻辑,让 setup() 更简洁,避免代码重复。在多个页面中都有 分页 + 数据获取 的逻辑,因此可以封装一个 useTable Hook。

import { ref, onMounted } from 'vue'
import { fetchDepartmentList } from '@/api/department' // 假设这是 API

export function useTable() {
  const tableData = ref([])
  const totalCount = ref(0)
  const loading = ref(false)

  const fetchData = async () => {
    loading.value = true
    try {
      const res = await fetchDepartmentList()
      tableData.value = res.data.list
      totalCount.value = res.data.total
    } finally {
      loading.value = false
    }
  }

  onMounted(fetchData)

  return { tableData, totalCount, loading, fetchData }
}

📌 总结

方法适用场景优势
具名插槽需要对组件的特定部分进行自定义灵活,可扩展多个插槽
作用域插槽让父组件访问子组件数据并决定如何渲染更细粒度的控制
Hooks(逻辑抽取)复用 数据获取、分页、搜索 等逻辑提高可维护性,减少代码重复

页面实现(利用我们自己封装的高阶组件)

<template>
  <div>
    <page-content :content-config="contentConfig" />
  </div>
</template>

<script setup lang="ts" name="menu">
import pageContent from '@/components/page-content/page-content.vue'
import contentConfig from './config/content.config'
</script>

<style scoped></style>

六、按钮的权限控制、数据统计

nexttick

复习完微任务再来回顾,原理其实就是promise.then

Vue 的 nextTick 是一个异步任务调度器nextTick 常用于 等待 DOM 更新完成后再执行某些操作。简单来说,就是把某个操作推迟到下一次更新后执行,确保拿到的是最新的 DOM 或数据状态。

vue的任务调度机制:

Vue 通过 queueJob() 收集所有需要执行的任务(如 nextTick() 里的回调),然后在合适的时机统一执行

const queue: Function[] = []
let isFlushing = false

function queueJob(job: Function) {
  if (!queue.includes(job)) {
    queue.push(job)
    if (!isFlushing) {
      isFlushing = true
      nextTick(flushJobs)
    }
  }
}

function flushJobs() {
  isFlushing = false
  queue.sort((a, b) => getJobPriority(a) - getJobPriority(b))
  for (let i = 0; i < queue.length; i++) {
    queue[i]()
  }
  queue.length = 0
}

🔹 queueJob() 把任务放到队列里,等 Vue 触发 nextTick(flushJobs)。 🔹 flushJobs() 统一执行所有任务,保证任务不会重复添加,同时按照优先级执行。 🔹 这让 Vue 能够批量更新 DOM,避免多次不必要的重绘,提升性能。

按钮的权限控制

思路:

在项目中,我们通过后端接口 /menu/list 获取当前登录用户的全部菜单信息及权限信息。返回的数据是一个三级嵌套的结构,其中 type 字段的值可能为 123,但我们只处理 type=3 的数据。

对于 type=3 的数据,每个项都包含一个 permission 字段,例如 "permission": "system:users:delete",我们需要提取 permission 字段的最后一部分(如 "delete")并存入一个数组中。为此,我们编写了一个 递归的 map 函数,遍历返回的 menu 数据,筛选出所有 type=3permission 并进行处理。

在 Vue 组件中,我们使用一个 hooks 方法来进行权限判断。该方法接收 当前页面的 pageName 和操作名称 作为参数,并将它们拼接成一个完整的权限标识字符串(例如 "system:users:delete")。然后,我们在 permissions 数组中使用 find 方法查找是否存在该权限标识,并返回一个布尔值。最终,我们将该布尔值用于 v-if,来控制按钮等组件的显示与否,实现 基于权限的按钮控制

// 递归方式map到数组,这个数组存到pinia中,本地缓存起来
export function mapPathToPermissions(menus: any[]) {
  const permissions: string[] = []
  const reverseMap = function (menuList: any[]) {
    for (const menu of menuList) {
      if (menu.type === 1 || menu.type === 2) {
        reverseMap(menu.children ?? [])
      } else {
        permissions.push(menu.permission)
      }
    }
  }
// hooks 通过permissions.find得到权限布尔值,
import useLoginStore from '@/store/login/login'

function usePermission(pageName: string, handleName: string) {
  // 拼接字符串
  const queryPermission = `${pageName}:${handleName}`
  const permissions = useLoginStore().userPermissions
  console.log(queryPermission, permissions)
  // 返回一个布尔值
  return !!permissions.find((item) => item.includes(queryPermission))
}

export default usePermission

// hooks在vue文件中的具体调用
const isCreate = usePermission(props.contentConfig.pageName, 'create')
const isDelete = usePermission(props.contentConfig.pageName, 'delete')
const isUpdate = usePermission(props.contentConfig.pageName, 'update')
const isQuery = usePermission(props.contentConfig.pageName, 'query')