Skip to content

Vue3+TS-项目:mr_vue3_ts_cms

[TOC]

接口文档

时间:2023-6-2

接口文档v1版本(失效)

https://documenter.getpostman.com/view/12387168/TzsfmQvw

baseURL的值:

http://152.136.185.210:5000

设置全局token的方法:

js
const res = pm.response.json();
pm.globals.set("token", res.data.token);

接口文档v2版本:(有部分更新)

https://documenter.getpostman.com/view/12387168/TzzDKb12

baseURL的值:

sh
http://codercba.com:5000

技术栈

  • Vue3:vue@3.3.2
  • TS5:
  • Vite4:vite@4.3.5。使用create-vue@3.6.4创建项目
  • Pinia2:pinia@2.1.3
  • VueRouter4: vue-router@4.2.2
  • Node16:node@16.19.0

创建项目

创建项目

使用create-vue 工具创建mr-vue3-ts-cms项目。create-vue 是基于vite的脚手架工具

sh
$ pnpm create vue@latest

创建选项

image-20230602133349666

安装VsCode插件

  • Vue Language Features (Volar)(暂时停用)
  • TypeScript Vue Plugin (Volar)(被Vue - Official替代)
  • Vue - Officialv1.8.27(2.x版本有问题)

问题: 在启用 TypeScript Vue Plugin (Volar) 的情况下,vscode不能识别vue文件的组件返回类型

解决: (暂时)停用TypeScript Vue Plugin (Volar) 插件

目录结构

sh
  .eslintrc.cjs # eslint检测配置
  .gitignore # git忽略配置
  .prettierrc.json # prettier格式化配置
  env.d.ts	# ts声明全局变量的类型定义文件
  index.html # 模板文件
  package-lock.json # 包管理
  package.json # 包管理
  README.md # 项目文档
  tsconfig.app.json
  tsconfig.json # ts编译器的配置文件
  tsconfig.node.json
  vite.config.ts # vite配置文件
  
├─.vscode
      extensions.json # vscode推荐插件
      
├─node_modules
          
├─public
      favicon.ico
      
└─src
  App.vue
  main.ts
  
    ├─assets
  ├─css
  └─img
    ├─base-ui
    ├─components
    ├─hooks
    ├─router
    ├─service
    ├─store
    ├─utils
    └─views

3个tsconfig文件之间的关系

image-20230602142445302

配置vue文件类型声明

问题: 项目本身的vue模块声明并不能识别出App是一个组件

js
import App from './App.vue'

解决: 重新声明vue模块,使得ts可以识别出vue是一个组件

ts
// env.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const src: DefineComponent
  export default src
}

配置icon,标题

直接复制自己的icon到public中

配置标题

html
<!-- index.html -->
<title>木头人 - 后台管理</title>

重置CSS样式

normalize.css

第三方包:normalize.css

1、安装:normalize.css

sh
npm i normalize.css

2、在main.ts 中导入

ts
import 'normalize.css'

reset.less

自定义重置:reset.less

common.less

公共样式:common.less

问题: vite默认不能识别less文件,需要安装less

sh
npm i less -D

代码规范

集成editorconfig配置

.editorconfig 有助于为不同 IDE 编辑器上处理同一项目的多个开发人员维护一致的编码风格

yaml
# 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

VSCode需要安装一个插件:EditorConfig for VS Code

使用prettier工具

Prettier 是一款强大的代码格式化工具,支持 JavaScript、TypeScript、CSS、SCSS、Less、JSX、Angular、Vue、GraphQL、JSON、Markdown 等语言,基本上前端能用到的文件格式它都可以搞定,是当下最流行的代码格式化工具。

1、安装prettier

shell
npm install prettier -D

2、配置.prettierrc或者.prettierrc.json文件:

  • useTabs:使用tab缩进还是空格缩进,选择false;
  • tabWidth:tab是空格的情况下,是几个空格,选择2个;
  • printWidth:当行字符的长度,推荐80,也有人喜欢100或者120;
  • singleQuote:使用单引号还是双引号,选择true,使用单引号;
  • trailingComma:在多行输入的尾逗号是否添加,设置为 none 表示不加;
  • semi:语句末尾是否要加分号,默认值true,选择false表示不加;
json
{
  "useTabs": false,
  "tabWidth": 2,
  "printWidth": 100,
  "singleQuote": true,
  "trailingComma": "none",
  "semi": false
}

3、创建.prettierignore忽略文件

/dist/*
.local
.output.js
/node_modules/**

**/*.svg
**/*.sh

/public/*

4、VSCode需要安装prettier的插件:Prettier - Code formatter

5、测试prettier是否生效

  • 测试一:在代码中保存代码;

    可以通过插件Prettier - Code formatter实现

  • 测试二:配置一次性修改的命令;

    在package.json中配置一个scripts:

    sh
    "prettier": "prettier --write ."

让prettier在保存时自动格式化

  • 1、在vscode中安装 Prettier 扩展
  • 2、在设置中搜索format on save ,选中Editor: Format On Save
  • 3、在设置中搜索default format,设置Editor: Default FormatterPrettier - Code formatter
  • 4、配置.prettierrc
  • 5、实现保存代码时自动格式化

使用ESLint检测

1、在前面创建项目的时候,我们就选择了ESLint,所以Vue会默认帮助我们配置需要的ESLint环境。

2、VSCode需要安装ESLint插件:ESLint

3、解决eslint和prettier冲突的问题:

安装插件:(vue在创建项目时,如果选择prettier,那么这两个插件会自动安装)

  • eslint-plugin-prettier(主要)
  • eslint-config-prettier
shell
npm i eslint-plugin-prettier eslint-config-prettier -D

添加prettier插件:

json
  extends: [
    "plugin:vue/vue3-essential",
    "eslint:recommended",
    "@vue/typescript/recommended",
    "@vue/prettier",
    "@vue/prettier/@typescript-eslint",
      
+    // "@vue/eslint-config-prettier/skip-formatting" // 该规范导致eslint没有提示
+    '@vue/eslint-config-prettier',
+    "plugin:prettier/recommended"
  ],

4、手动修改eslint检测规则

  • 需要修改的报错:

    • @typescript-eslint/no-unused-vars:未使用的变量名
    • vue/multi-word-component-names:检测当前的组件名称是否使用驼峰或多单词命名
  • 在出现提示的位置,复制出现的错误:@typescript-eslint/no-unused-vars

    image-20240304095040226

  • 在出现提示的位置,复制出现的错误:vue/multi-word-component-names

    image-20240304094642473

  • .eslintrc.cjs 中添加如下代码:

    js
    module.exports = {
    +  rules: {
    +    '@typescript-eslint/no-unused-vars': 'off',
    +    'vue/multi-word-component-names': 'off'
    +  }
    }

git-Husky和eslint

虽然我们已经要求项目使用eslint了,但是不能保证组员提交代码之前都将eslint中的问题解决掉了:

  • 也就是我们希望保证代码仓库中的代码都是符合eslint规范的;

  • 那么我们需要在组员执行 git commit 命令的时候对其进行校验,如果不符合eslint规范,那么自动通过规范进行修复;

那么如何做到这一点呢?可以通过Husky工具:

  • husky是一个git hook工具,可以帮助我们触发git提交的各个阶段:pre-commitcommit-msgpre-push

如何使用husky呢?

这里我们可以使用自动配置命令

shell
# npm
npx husky-init && npm install

# pnpm
pnpx husky-init && pnpm install

# 推荐
pnpm dlx husky-init && pnpm install

注意: 在windows的powershell中需要给&&添加引号

sh
# npm
npx husky-init '&&' npm install

# pnpm
pnpx husky-init '&&' pnpm install

这里会做三件事:

1.安装husky相关的依赖:

image-20230615162857405

2.在项目目录下创建 .husky 文件夹:

npx huksy install

image-20230615162908690

3.在package.json中添加一个脚本:

image-20230615162943102

接下来,我们需要去完成一个操作:在进行commit时,执行lint脚本:

image-20230615163757848

这个时候我们执行git commit的时候会自动对代码进行lint校验。

暂存区 eslint 校验

由于使用pnpm lint校验时,会对所有文件都进行校验,耗时久。

为解决以上问题,就出现了 lint-staged 插件,它可以只对有改动的文件进行校验

依赖包: lint-staged

安装: pnpm i lint-staged -D

使用:

1、在package.json中配置lint-staged命令

image-20240528100558788

2、配置lint-staged

  • 方法一:在.lintstagedrc文件中配置

    json
    {
      "*.{js,ts,vue}": "eslint"
    }
  • 方法二:在package.json中配置

    json
      "scripts": {
          ...
    +    "lint-staged": "lint-staged"
      },
    +  "lint-staged": {
    +    "*.{js,ts,vue}": [
    +      "prettier --write",
    +      "eslint"
    +    ]
      },

3、修改.husky/pre-commit文件

image-20240528100644889

4、通过git commit -m "xxx"提交git时会使用lint-staged检测

git-commit规范

代码提交风格

通常我们的git commit会按照统一的风格来提交,这样可以快速定位每次提交的内容,方便之后对版本进行控制。

image-20230615164118256

但是如果每次手动来编写这些是比较麻烦的事情,我们可以使用一个工具:commitizen

  • commitizen 是一个帮助我们编写规范 commit message 的工具;

1.安装commitizen

shell
#npm 
npm install commitizen -D

#pnpm 
pnpm install commitizen -D

2.安装cz-conventional-changelog,并且初始化cz-conventional-changelog:

shell
# npm
pnpx commitizen init cz-conventional-changelog --save-dev --save-exact

# pnpm
pnpx commitizen init cz-conventional-changelog --save-dev --save-exact --pnpm

这个命令会帮助我们安装cz-conventional-changelog:

image-20230615164205198

并且在package.json中进行配置:

image-20230615164358905

3、将commitizen的配置单独写入.czrc配置文件中

js
{
  "path": "./node_modules/cz-conventional-changelog"
}

4、这个时候我们提交代码需要使用:

sh
#npm
npx cz

#pnpm
# 1. 在`package.json`中添加scripts: `"commit": "cz"`
# 2. 在命令行运行:`pnpm run commit`
  • 第一步是选择type,本次更新的类型
Type作用
feat新增特性 (feature)
fix修复 Bug(bug fix)
docs修改文档 (documentation)
style代码格式修改(white-space, formatting, missing semi colons, etc)
refactor代码重构(refactor)
perf改善性能(A code change that improves performance)
test测试(when adding missing tests)
build变更项目构建或外部依赖(例如 scopes: webpack、gulp、npm 等)
ci更改持续集成软件的配置文件和 package 中的 scripts 命令,例如 scopes: Travis, Circle 等
chore变更构建流程或辅助工具(比如更改测试环境)
revert代码回退
release发布新版本
  • 第二步选择本次修改的范围(作用域)
sh
? What is the scope of this change (e.g. component or file name): (press enter to skip) git
  • 第三步选择提交的信息
sh
? Write a short, imperative tense description of the change (max 89 chars): 安装了husky
  • 第四步提交详细的描述信息
sh
? Provide a longer description of the change: (press enter to skip)
  • 第五步是否是一次重大的更改
sh
? Are there any breaking changes? (y/N) n
  • 第六步是否影响某个open issue
sh
? Does this change affect any open issues? (y/N) n

我们也可以在scripts中构建一个命令来执行 cz:

image-20230615165454050

代码提交验证(无效)

如果我们按照cz来规范了提交风格,但是依然有同事通过 git commit 按照不规范的格式提交应该怎么办呢?

  • 我们可以通过 commitlint 来限制提交;

1.安装 @commitlint/config-conventional@commitlint/cli

shell
# npm
npm i @commitlint/config-conventional @commitlint/cli -D

# pnpm
pnpm add @commitlint/config-conventional @commitlint/cli -D

2.在根目录创建commitlint.config.js文件,配置commitlint

js
module.exports = {
  extends: ['@commitlint/config-conventional']
}

问题: 报错如下:

image-20240304112335565

解决:

  • 方法一:通过快速修复,暂时屏蔽eslint检测,因为这个是误报

    效果如下:

    js
    // eslint-disable-next-line no-undef
    module.exports = {
      extends: ['@commitlint/config-conventional']
    }
  • 方法二:修改commitlint.config.js文件后缀为.cjs,此时就可以解析commonJS代码了

3.使用husky生成commit-msg文件,验证提交信息:

shell
# npm
npx husky add .husky/commit-msg "npx --no-install commitlint --edit $1"

# pnpm (无效)
pnpx husky add .husky/commit-msg "pnpx --no-install commitlint --edit $1"

第三方库集成

vue.config.js配置

vue.config.js有三种配置方式:

  • 方式一:直接通过CLI提供给我们的选项来配置:
    • 比如publicPath:配置应用程序部署的子目录(默认是 /,相当于部署在 https://www.my-app.com/);
    • 比如outputDir:修改输出的文件夹;
  • 方式二:通过configureWebpack修改webpack的配置:
    • 可以是一个对象,直接会被合并;
    • 可以是一个函数,会接收一个config,可以通过config来修改配置;
  • 方式三:通过chainWebpack修改webpack的配置:
    • 是一个函数,会接收一个基于 webpack-chain 的config对象,可以对配置进行修改;
js
const path = require('path')

module.exports = {
  outputDir: './build',
  // configureWebpack: {
  //   resolve: {
  //     alias: {
  //       views: '@/views'
  //     }
  //   }
  // }
  // configureWebpack: (config) => {
  //   config.resolve.alias = {
  //     '@': path.resolve(__dirname, 'src'),
  //     views: '@/views'
  //   }
  // },
  chainWebpack: (config) => {
    config.resolve.alias.set('@', path.resolve(__dirname, 'src')).set('views', '@/views')
  }
}

vue-router集成

1、安装vue-router的最新版本:

shell
npm install vue-router@next

2、创建router对象:

ts
import { createRouter, createWebHashHistory } from 'vue-router'
import { RouteRecordRaw } from 'vue-router'

// 映射关系
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/main'
  },
  {
    path: '/main',
    component: () => import('../views/main/main.vue')
  },
  {
    path: '/login',
    component: () => import('../views/login/login.vue')
  }
]

const router = createRouter({
  routes,
  history: createWebHashHistory()
})

export default router

3、安装router:

ts
import router from './router'

createApp(App).use(router).mount('#app')

4、在App.vue中配置跳转:

html
<template>
  <div id="app">
    <router-link to="/login">登录</router-link>
    <router-link to="/main">首页</router-link>
    <router-view></router-view>
  </div>
</template>

pinia集成

1、安装pinia

sh
npm i pinia

2、创建pinia对象

ts
import { createPinia } from 'pinia'

const pinia = createPinia()

export default pinia

3、挂载pinia

ts
+ import pinia from './store'

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

4、创建store

ts
import { defineStore } from 'pinia'

const useCounterStore = defineStore('counter', {
  state: () => ({
    counter: 10
  }),
  getters: {
    doubleCounter(state) {
      return state.counter * 2
    }
  },
  actions: {
    changeCounterAction(payload: number) {
      this.counter = payload
    }
  }
})

export default useCounterStore

5、使用store

获取counter

html
<template>
  <div class="test">
+    <div>计数: {{ counterStore.counter }} - {{ counterStore.doubleCounter }}</div>
  </div>
</template>
<script setup lang="ts">
+ import useCounterStore from '@/store/counter'

+ const counterStore = useCounterStore()
</script>

修改counter

html
<template>
  <div class="test">
+    <button @click="setCounter">修改counter</button>
  </div>
</template>
<script setup lang="ts">
import useCounterStore from '@/store/counter'

const counterStore = useCounterStore()

// 修改store
+ function setCounter() {
+   counterStore.changeCounterAction(900)
+ }
</script>

vuex集成

1、安装vuex:

shell
npm install vuex@next

2、创建store对象:

ts
import { createStore } from 'vuex'

const store = createStore({
  state() {
    return {
      name: 'coderwhy'
    }
  }
})

export default store

3、安装store:

ts
createApp(App).use(router).use(store).mount('#app')

4、在App.vue中使用:

html
<h2>{{ $store.state.name }}</h2>

element-plus集成

Element Plus,一套为开发者、设计师和产品经理准备的基于 Vue 3.0 的桌面端组件库:

  • 相信很多同学在Vue2中都使用过element-ui,而element-plus正是element-ui针对于vue3开发的一个UI组件库;
  • 它的使用方式和很多其他的组件库是一样的,所以学会element-plus,其他类似于ant-design-vue、NaiveUI、VantUI都是差不多的;

安装element-plus

shell
npm install element-plus

完整引入

一种引入element-plus的方式是全局引入,代表的含义是所有的组件和插件都会被自动注册:

js
import { createApp } from 'vue'
+ import ElementPlus from 'element-plus'
+ import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)

+ app.use(ElementPlus)
app.mount('#app')

volar支持

如果您使用 Volar,请在 tsconfig.json 中通过 compilerOptions.type 指定全局组件类型。

json
// tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": ["element-plus/global"]
  }
}

按需引入

也就是在开发中用到某个组件对某个组件进行引入:

vue
<template>
  <div id="app">
    <router-link to="/login">登录</router-link>
    <router-link to="/main">首页</router-link>
    <router-view></router-view>

    <h2>{{ $store.state.name }}</h2>

    <el-button>默认按钮</el-button>
+    <el-button type="primary">主要按钮</el-button>
+    <el-button type="success">成功按钮</el-button>
+    <el-button type="info">信息按钮</el-button>
+    <el-button type="warning">警告按钮</el-button>
+    <el-button type="danger">危险按钮</el-button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

+ import { ElButton } from 'element-plus'

export default defineComponent({
  name: 'App',
+  components: {
+    ElButton
+  }
})
</script>

<style lang="less">
</style>

但是我们会发现是没有对应的样式的,引入样式有两种方式:

  • 全局引用样式(像之前做的那样);
  • 局部引用样式(通过babel的插件);

1.安装babel的插件:

shell
npm install babel-plugin-import -D

2.配置babel.config.js

js
module.exports = {
  plugins: [
    [
      'import',
      {
        libraryName: 'element-plus',
        customStyleName: (name) => {
          return `element-plus/lib/theme-chalk/${name}.css`
        }
      }
    ]
  ],
  presets: ['@vue/cli-plugin-babel/preset']
}

但是这里依然有个弊端:

  • 这些组件我们在多个页面或者组件中使用的时候,都需要导入并且在components中进行注册;
  • 所以我们可以将它们在全局注册一次;
ts
import {
  ElButton,
  ElTable,
  ElAlert,
  ElAside,
  ElAutocomplete,
  ElAvatar,
  ElBacktop,
  ElBadge,
} from 'element-plus'

+ const app = createApp(App)

const components = [
  ElButton,
  ElTable,
  ElAlert,
  ElAside,
  ElAutocomplete,
  ElAvatar,
  ElBacktop,
  ElBadge
]

+ for (const cpn of components) {
+  app.component(cpn.name, cpn)
+ }

自动按需引入(推荐)

首先你需要安装unplugin-vue-componentsunplugin-auto-import这两款插件

sh
npm install unplugin-vue-components unplugin-auto-import -D

然后把下列代码插入到你的 ViteWebpack 的配置文件中

Vite

1、设置vite.config.ts,添加插件ComponentsComponents

ts
// vite.config.ts
import { defineConfig } from 'vite'
+ import AutoImport from 'unplugin-auto-import/vite'
+ import Components from 'unplugin-vue-components/vite'
+ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  // ...
+  plugins: [
    // ...
+    AutoImport({
+      resolvers: [ElementPlusResolver()],
++      dts: 'auto-imports.d.ts' // 重点
+    }),
+    Components({
+      resolvers: [ElementPlusResolver()],
++      dts: 'components.d.ts' // 重点
+    }),
  ],
})

2、修改tsconfig.app.json,添加"auto-imports.d.ts", "components.d.ts"include

json
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "auto-imports.d.ts", "components.d.ts"], // 重点
  "exclude": ["src/**/__tests__/*", "commitlint.config.js"],
  "compilerOptions": {
    "composite": true,
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",

    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Webpack

ts
// webpack.config.js
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers')

module.exports = {
  // ...
  plugins: [
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
}

类型提示设置

tsconfig.json 中将安装的2个插件对应的类型是声明文件添加到include

image-20230603210617236

axios集成

1、安装axios

shell
npm install axios

2、封装axios

ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { Result } from './types'
import { useUserStore } from '/@/store/modules/user'

class HYRequest {
  private instance: AxiosInstance

  private readonly options: AxiosRequestConfig

  constructor(options: AxiosRequestConfig) {
    this.options = options
    this.instance = axios.create(options)

    this.instance.interceptors.request.use(
      (config) => {
        const token = useUserStore().getToken
        if (token) {
          config.headers.Authorization = `Bearer ${token}`
        }
        return config
      },
      (err) => {
        return err
      }
    )

    this.instance.interceptors.response.use(
      (res) => {
        // 拦截响应的数据
        if (res.data.code === 0) {
          return res.data.data
        }
        return res.data
      },
      (err) => {
        return err
      }
    )
  }

  request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return new Promise((resolve, reject) => {
      this.instance
        .request<any, AxiosResponse<Result<T>>>(config)
        .then((res) => {
          resolve((res as unknown) as Promise<T>)
        })
        .catch((err) => {
          reject(err)
        })
    })
  }

  get<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'GET' })
  }

  post<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'POST' })
  }

  patch<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'PATCH' })
  }

  delete<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'DELETE' })
  }
}

export default HYRequest

VSCode配置

json
{
  "workbench.iconTheme": "vscode-great-icons",
  "editor.fontSize": 17,
  "eslint.migration.2_x": "off",
  "[javascript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "files.autoSave": "afterDelay",
  "editor.tabSize": 2,
  "terminal.integrated.fontSize": 16,
  "editor.renderWhitespace": "all",
  "editor.quickSuggestions": {
    "strings": true
  },
  "debug.console.fontSize": 15,
  "window.zoomLevel": 1,
  "emmet.includeLanguages": {
    "javascript": "javascriptreact"
  },
  "explorer.confirmDragAndDrop": false,
  "workbench.tree.indent": 16,
  "javascript.updateImportsOnFileMove.enabled": "always",
  "editor.wordWrap": "on",
  "path-intellisense.mappings": {
    "@": "${workspaceRoot}/src"
  },
  "hediet.vscode-drawio.local-storage": "eyIuZHJhd2lvLWNvbmZpZyI6IntcImxhbmd1YWdlXCI6XCJcIixcImN1c3RvbUZvbnRzXCI6W10sXCJsaWJyYXJpZXNcIjpcImdlbmVyYWw7YmFzaWM7YXJyb3dzMjtmbG93Y2hhcnQ7ZXI7c2l0ZW1hcDt1bWw7YnBtbjt3ZWJpY29uc1wiLFwiY3VzdG9tTGlicmFyaWVzXCI6W1wiTC5zY3JhdGNocGFkXCJdLFwicGx1Z2luc1wiOltdLFwicmVjZW50Q29sb3JzXCI6W1wiRkYwMDAwXCIsXCIwMENDNjZcIixcIm5vbmVcIixcIkNDRTVGRlwiLFwiNTI1MjUyXCIsXCJGRjMzMzNcIixcIjMzMzMzM1wiLFwiMzMwMDAwXCIsXCIwMENDQ0NcIixcIkZGNjZCM1wiLFwiRkZGRkZGMDBcIl0sXCJmb3JtYXRXaWR0aFwiOjI0MCxcImNyZWF0ZVRhcmdldFwiOmZhbHNlLFwicGFnZUZvcm1hdFwiOntcInhcIjowLFwieVwiOjAsXCJ3aWR0aFwiOjExNjksXCJoZWlnaHRcIjoxNjU0fSxcInNlYXJjaFwiOnRydWUsXCJzaG93U3RhcnRTY3JlZW5cIjp0cnVlLFwiZ3JpZENvbG9yXCI6XCIjZDBkMGQwXCIsXCJkYXJrR3JpZENvbG9yXCI6XCIjNmU2ZTZlXCIsXCJhdXRvc2F2ZVwiOnRydWUsXCJyZXNpemVJbWFnZXNcIjpudWxsLFwib3BlbkNvdW50ZXJcIjowLFwidmVyc2lvblwiOjE4LFwidW5pdFwiOjEsXCJpc1J1bGVyT25cIjpmYWxzZSxcInVpXCI6XCJcIn0ifQ==",
  "hediet.vscode-drawio.theme": "Kennedy",
  "editor.fontFamily": "Source Code Pro, 'Courier New', monospace",
  "editor.smoothScrolling": true,
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "workbench.colorTheme": "Atom One Dark",
  "vetur.completion.autoImport": false,
  "security.workspace.trust.untrustedFiles": "open",
  "eslint.lintTask.enable": true,
  "eslint.alwaysShowStatus": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

区分开发、生产环境

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

  • import.meta.env.MODE: {string} 应用运行的模式。(development | production)
  • import.meta.env.PROD: {boolean} 应用是否运行在生产环境。
  • import.meta.env.DEV: {boolean} 应用是否运行在开发环境 (永远与 import.meta.env.PROD相反)。
  • import.meta.env.BASE_URL: {string} 部署应用时的基本 URL。他由base 配置项决定。
  • import.meta.env.SSR: {boolean} 应用是否运行在 server 上。

~~方法1:~~手动决定使用哪个BASE_URL

~~方法2:~~根据 import.meta.env.MODE 判断处于哪个环境,使用不同的BASE_URL

image-20230603173919328

方法3: 自定义环境常量

image-20230603175200246

1、创建.env.development.env.production文件

2、分别在文件中定义不同的常量(注意:常量名必须以VITE_开头)

image-20230603180018457

3、通过import.meta.env.VITE_XXX 获取定义的常量

Login

占满屏幕

css
.app {
+  width: 100vw;
+  height: 100vh;
  background-col2or: #999;
}

组件:LoginPanel

image-20240719171059328

使用组件

html
<template>
  <div class="login">
+    <LoginPannel />
  </div>
</template>

<script setup lang="ts">
+ import LoginPannel from './cpns/LoginPanel/LoginPanel.vue'
</script>

页面布局

1、封装组件-基础

html
<template>
  <div class="login-panel">
    <!-- 标题 -->
    <h3 class="title">木人-后台管理系统</h3>

    <!-- 登录表单 -->
+    <div class="tabs">tabs</div>

    <!-- 密码管理 -->
    <div class="pwd-control">
      <el-checkbox v-model="isRemPwd" label="记住密码" />
      <el-link type="primary">忘记密码</el-link>
    </div>

    <!-- 登录提交 -->
    <el-button class="login-btn" size="large" type="primary">立即登录</el-button>
  </div>
</template>
css
.login-panel {
  width: 330px;
}

2、封装组件-密码管理

html
    <!-- 密码管理 -->
    <div class="pwd-control">
+      <el-checkbox v-model="isRemPwd" label="记住密码" />
      <el-link type="primary">忘记密码</el-link>
    </div>
ts
import { ref } from 'vue'
const isRemPwd = ref(true)

4、封装组件-立即登录

html
    <!-- 登录提交 -->
    <el-button class="login-btn" size="large" type="primary">立即登录</el-button>

5、封装组件-tabs

html
    <!-- 登录表单 -->
    <el-tabs v-model="activeName" class="tabs" type="border-card" stretch>
      <el-tab-pane label="帐号登录" name="account">
	 	...
      </el-tab-pane>
      <el-tab-pane label="手机登录" name="phonse">
		...
      </el-tab-pane>
    </el-tabs>

导入Icon

1、安装图标:npm install @element-plus/icons-vue

2、全局注册图标:

ts
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

3、对上面的方法进行封装:

使用

ts
import registerIcons from './global/registerIcons'
const app = createApp(App)
+ app.use(registerIcons)

封装

ts
// 注册element-plus图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

function registerIcons(app: App<Element>) {
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
  }
}

export default registerIcons

4、在label插槽中添加图标

html
    <!-- 登录表单 -->
    <el-tabs v-model="activeName" class="tabs" type="border-card" stretch>
      <el-tab-pane label="帐号登录" name="account">
+        <template #label>
          <div class="label">
+            <el-icon><UserFilled /></el-icon>
+            <span class="text">帐号登录</span>
          </div>
        </template>
      </el-tab-pane>
      <el-tab-pane label="手机登录" name="phonse">
+        <template #label>
          <div class="label">
+            <el-icon><Iphone /></el-icon>
+            <span class="text">手机登录</span>
          </div>
        </template>
      </el-tab-pane>
    </el-tabs>

获取tabs当前标签项

  • v-model: 绑定值,选中选项卡的 name
html
+	<!-- @click="loginClickHdl" -->
+    <el-button class="login-btn" size="large" type="primary" @click="loginClickHdl">
      立即登录
    </el-button>
html
+ 	 <!-- v-model="activeName" -->
+    <el-tabs v-model="activeName" class="tabs" type="border-card" stretch> 
+      <el-tab-pane label="帐号登录" name="account"> <!-- name="account" -->
          ...
      </el-tab-pane>
+      <el-tab-pane label="手机登录" name="phone"> <!-- name="phone" -->
          ...
      </el-tab-pane>
    </el-tabs>
...
<script setup lang="ts">
+	const activeName = ref('account')
</script>
ts
function loginClickHdl() {
  if (activeName.value === 'account') {
    console.log('通过帐号登录')
  } else {
    console.log('通过手机登录')
  }
}

组件:paneAccount

使用组件

html
      <el-tab-pane label="帐号登录" name="account">
        <template #label>
            ...
        </template>
+        <PaneAccount />
      </el-tab-pane>
ts
import PaneAccount from '../PaneAccount/PaneAccount.vue'

页面布局

封装组件-基础

html
  <div class="pane-account">
    <el-form label-width="60px" size="large" status-icon>
      <el-form-item label="帐号">
        <el-input />
      </el-form-item>
      <el-form-item label="密码">
        <el-input show-password />
      </el-form-item>
    </el-form>
  </div>

绑定表单数据

html
  <div class="pane-account">
+    <el-form label-width="60px" :model="account" size="large" status-icon> <!-- :model="account" -->
      <el-form-item label="帐号">
+        <el-input v-model="account.name" /> <!-- v-model="account.name" -->
      </el-form-item>
      <el-form-item label="密码">
+        <el-input v-model="account.password" show-password /> <!-- v-model="account.password" -->
      </el-form-item>
    </el-form>
  </div>
ts
import { reactive } from 'vue'

const account = reactive({
  name: '',
  password: ''
})

校验规则

html
+	 <!-- :rules="accountRules" -->
+    <el-form label-width="60px" :model="account" :rules="accountRules" size="large" status-icon>
+      <el-form-item label="帐号" prop="name"> <!-- prop="name" -->
        <el-input v-model="account.name" />
      </el-form-item>
+      <el-form-item label="密码" prop="password"> <!-- prop="password" -->
        <el-input v-model="account.password" show-password />
      </el-form-item>
    </el-form>
ts
// 验证规则
const accountRules: FormRules = {
  name: [
    { required: true, message: '帐号不能为空', trigger: 'blur' },
    { pattern: /^[0-9a-zA-Z]{6,20}$/, message: '帐号必须是6~20个字符或数字', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '密码不能为空', trigger: 'blur' },
    { pattern: /^[0-9a-zA-Z]{3,}$/, message: '密码必须是3位以上的字符或数字', trigger: 'blur' }
  ]
}

点击登录

思路: 由于点击操作是在父组件中进行的,而帐号信息是在子组件中。可以考虑在父组件中调用子组件的方法

父组件

1、为el-button添加点击事件

html
+    <!-- @click="loginClickHdl" -->
+    <el-button class="login-btn" size="large" type="primary" @click="loginClickHdl">
      立即登录
    </el-button>

2、获取组件PaneAccount的ref

InstanceType<typeof PaneAccount> 返回的是PaneAccount组件的 实例对象的类型

html
<PaneAccount ref="accountRef" />
ts
const accountRef = ref<InstanceType<typeof PaneAccount>>()

3、调用组件PaneAccount 中的方法

ts
/* 点击登录 */
function loginClickHdl() {
  if (activeName.value === 'account') {
    // 调用PaneAccount组件内部方法
+    accountRef.value?.loginAction()

    console.log('通过帐号登录')
  } else {
    console.log('通过手机登录')
  }
}

子组件

注意: 父组价想访问到子组件的方法,一定要先通过defineExpose 将方法暴露出去

ts
/* 实现登录 */
function loginAction() {
  console.log('loginAction')
}

+ defineExpose({
+   loginAction
+ })

1、实现登录功能-登录前校验

html
    <el-form
      label-width="60px"
      :model="account"
      :rules="accountRules"
+      ref="accountRef"
      size="large"
      status-icon
    >
        ...
    </el-form>
ts
import type { FormInstance } from 'element-plus/lib/components/index.js'

+ const accountRef = ref<FormInstance | undefined>()
/* 实现登录 */
function loginAction() {
  // 登录前校验
+  accountRef.value?.validate((valid) => {
    if (valid) {
      console.log('校验成功')
    } else {
      console.log('校验失败')
    }
  })
}

2、实现登录功能-登录前校验-验证失败提示

ts
import { ElMessage } from 'element-plus'

function loginAction() {
  // 登录前校验
  accountRef.value?.validate((valid) => {
    if (valid) {
      console.log('校验成功')
    } else {
+      ElMessage.error('呜~, 校验失败,请输入正确的账号密码格式~')
    }
  })
}

3、实现登录功能-登录前校验-验证成功实现登录

image-20230604211002932

service/login/login.ts

ts
export function loginRequest(account: IAccount) {
  return mrRequest.post({
    url: '/login',
    data: account
  })
}

4、优化:在pinia中发送网络请求

组件中

ts
+ const loginStore = useLoginStore()
function loginHdl() {
  // 登录前校验
  accountRef.value?.validate((valid) => {
    if (valid) {
+      loginStore.loginAction({ name: account.name, password: account.password })
    } else {
      ElMessage.error('呜~, 校验失败,请输入正确的账号密码格式~')
    }
  })
}

pinia中

ts
const useLoginStore = defineStore('login', {
  state: () => ({
    token: localCache.getCache('token') ?? ''
  }),
  actions: {
+    async loginAction(account: IAccount) {
+      // 发送网络请求
+      const loginRes = await loginRequest(account)
+      console.log(loginRes)
+      // 保存请求数据到store
+      this.token = loginRes.data.token
+      // 保存请求数据到本地
+      localCache.setCache('token', this.token)
+    }
  }
})

IAccount对象类型定义

关于类型文件存放的位置:

思路一: 在每个页面组件里建一个types文件夹,保存定义的类型

ts
export interface IAccount {
  name: string
  password: string
}

使用类型

ts
import type { IAccount } from '@/types/login'

思路二: 如果定义的类型,在views、store、service等多个地方都会用到,就放入src/types

src/types/login.d.ts

ts
export interface IAccount {
  name: string
  password: string
}

定义统一的出口文件 src/types/index.d.ts

ts
export * from './login'

使用类型

ts
+ import type { IAccount } from '@/types'

const useLoginStore = defineStore('login', {
  state: () => ({
      ...
  }),
  actions: {
+    async loginAction(account: IAccount) {
      // 发送网络请求
      // 保存请求数据到store
      // 保存请求数据到本地
    }
  }

登录-本地缓存登录信息

1、本地缓存-基本使用

ts
const useLoginStore = defineStore('login', {
  state: () => ({
+    token: localStorage.getItem('token') ?? ''
  }),
  actions: {
    async loginAction(account: IAccount) {
      // 发送网络请求
      const loginRes = await loginRequest(account)
      // 保存请求数据到store
      this.token = loginRes.data.token
      // 保存请求数据到本地
+      localStorage.setItem('token', this.token)
    }
  }
})

2、本地缓存-封装

utils/cache/index.ts 中封装操作LocalStorage的类Cache

ts
class Cache {
  setCache(key: string, value: any) {
    if (value) {
      localStorage.setItem(key, JSON.stringify(value))
    }
  }

  getCache(key: string) {
    const value = localStorage.getItem(key)
    if (value) {
      return JSON.parse(value)
    }
  }

  removeCache(key: string) {
    localStorage.removeItem(key)
  }

  clear() {
    localStorage.clear()
  }
}

export default new Cache()

3、本地缓存-封装-兼容localStorage、sessionStorage

ts
enum TCache {
  Local,
  Session
}
class Cache {
  storage: Storage
  constructor(type: TCache) {
    this.storage = type === TCache.Local ? localStorage : sessionStorage
  }

  setCache(key: string, value: any) {
    if (value) {
      this.storage.setItem(key, JSON.stringify(value))
    }
  }

  getCache(key: string) {
    const value = this.storage.getItem(key)
    if (value) {
      return JSON.parse(value)
    }
  }

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

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

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

export { localCache, sessionCache }

4、本地缓存-封装-使用

ts
const useLoginStore = defineStore('login', {
  state: () => ({
+    token: localCache.getCache('token') ?? ''
  }),
  actions: {
    async loginAction(account: IAccount) {
      // 发送网络请求
      const loginRes = await loginRequest(account)
      // 保存请求数据到store
      this.token = loginRes.data.token
      // 保存请求数据到本地
+      localCache.setCache('token', this.token)
    }
  }
})

登录成功跳转

ts
    async loginAction(account: IAccount) {
      // 发送网络请求
      const loginRes = await loginRequest(account)
      console.log(loginRes)
      // 保存请求数据到store
      this.token = loginRes.data.token
      // 保存请求数据到本地
      localCache.setCache('token', this.token)
      // 登录成功跳转
+      router.push('/main')
    }

路由导航守卫

1、在router/index.ts中添加导航守卫

ts
/* 路由导航守卫 */
router.beforeEach((to, from) => {
  const token = localCache.getCache('token')
  if (to.path === '/main' && !token) {
    return '/login'
  }
})

退出登录

html
  <div class="main">
    <div>main</div>
+      <!-- @click="logoutClickHdl" -->
+    <el-button type="primary" @click="logoutClickHdl">退出登录</el-button> 
  </div>
ts
/* 点击退出登录 */
function logoutClickHdl() {
  // 清除storage
  localCache.removeCache('token')
  // 跳转login
  router.push('/login')
}

记住密码

思路: 记住密码状态isRemPwd在父组件中,而帐号和密码在子组件中,可以将isRemPwd通过loginAction(isRemPwd)传递给子组件

1、传递isRemPwd到子组件

ts
function loginClickHdl() {
  if (activeName.value === 'account') {
    // 调用PaneAccount组件内部方法
+    accountRef.value?.loginHdl(isRemPwd.value) // isRemPwd.value
  } else {
    console.log('通过手机登录')
  }
}

2、登录成功后缓存帐号、密码

ts
function loginHdl(isRemPwd: boolean) {
  // 登录前校验
  accountRef.value?.validate((valid) => {
    if (valid) {
      loginStore.loginAction({ name: account.name, password: account.password }).then(() => {
        // 登录成功,记住密码
        if (isRemPwd) {
+         localCache.setCache('name', account.name)
+         localCache.setCache('password', account.password)
        }
      })
    } else {
      ElMessage.error('呜~, 校验失败,请输入正确的账号密码格式~')
    }
  })
}

3、修改初始化帐号、密码

ts
const account = reactive({
+  name: localCache.getCache('name') ?? '',
+  password: localCache.getCache('password') ?? ''
})

4、未勾选记住密码时,移除缓存

ts
        // 登录成功,记住密码
        if (isRemPwd) {
          localCache.setCache('name', account.name)
          localCache.setCache('password', account.password)
        } else {
+          localCache.removeCache('name')
+          localCache.removeCache('password')
        }

5、缓存记住密码状态

在父组件中watch监听isRemPwd的值

ts
/* 缓存记住密码状态 */
const isRemPwd = ref<boolean>(localCache.getCache('isRemPwd') ?? false)
watch(isRemPwd, (newValue) => {
  localCache.setCache('isRemPwd', newValue)
})

获取用户详细信息

1、发送网络请求

ts
/* 获取用户详细信息 */
export function getUserInfo(id: number) {
  return mrRequest.get({
    url: `/users/${id}`
  })
}

2、登录成功后获取用户详细信息

ts
const useLoginStore = defineStore('login', {
  state: (): ILoginState => ({
    token: localCache.getCache('token') ?? '',
+    userInfo: {}
  }),
  actions: {
    /* 用户登录 */
    async loginAction(account: IAccount) {
        ...
      // 获取用户详细信息
+      this.fetchUserInfo(loginRes.data.id)
    },

    /* 获取用户详细信息 */
+    async fetchUserInfo(id: number) {
+      const userRes = await getUserInfo(id)
+      this.userInfo = userRes.data
+    },
})

3、保存userInfo到pinia中

ts
    async fetchUserInfo(id: number) {
      const userRes = await getUserInfo(id)
+      this.userInfo = userRes.data
    },

携带token

1、*方法一:*在每次请求中添加headers.Authorization

ts
/* 获取用户详细信息 */
export function getUserInfo(id: number) {
  return mrRequest.get({
    url: `/users/${id}`
+    headers: {
+      Authorization: 'Bearer ' + localCache.getCache('token')
+    }
  })
}

2、方法二: 在拦截器中添加headers.Authorization

ts
const mrRequest = new MrRequest({
  baseURL: BASE_URL,
  timeout: TIME_OUT,

  interceptors: {
    requestSuccessFn: (config) => {
      // 携带token
+      if (config.headers) {
+        config.headers.Authorization = 'Bearer ' + localCache.getCache('token')
+      }
      return config as InternalAxiosRequestConfig
    },
    ...
  }
})

定义state中的类型

ts
+  interface ILoginState {
+    token: string
+    userInfo: any
+    roleMenuTreeInfo: any
+  }
  const useLoginStore = defineStore('login', {
+    state: (): ILoginState => ({
      token: localCache.getCache('token') ?? '',
      userInfo: {},
      roleMenuTreeInfo: {}
    }),
  })

获取用户角色权限菜单树

1、发送网络请求

ts
/* 获取角色菜单树 */
export function getRoleMenuTreeInfo(id: number) {
  return mrRequest.get({
    url: `/role/${id}/menu`
  })
}

2、在store中获取数据

ts
  actions: {
    /* 用户登录 */
    async loginAction(account: IAccount) {
        ...
      // 获取角色菜单树
+      this.fetchRoleMenuTreeInfo(loginRes.data.id)
    },

    /* 获取角色菜单树 */
+    async fetchRoleMenuTreeInfo(id: number) {
+      const roleRes = await getRoleMenuTreeInfo(id)
+      this.roleMenuTreeInfo = roleRes.data
+    }
  }

组件:PanePhone

使用组件

页面布局

html
<template>
  <div class="pane-phone">
    <el-form label-width="60px" size="large" :model="phone">
      <el-form-item label="手机号">
        <el-input v-model="phone.phone" />
      </el-form-item>
      <el-form-item label="验证码">
        <div class="code">
          <el-input v-model="phone.code" />
          <el-button class="get-code" type="primary">获取验证码</el-button>
        </div>
      </el-form-item>
    </el-form>
  </div>
</template>

手机验证码【】

校验规则选项

  • type:指定输入数据的类型

  • required:表示是否必填,值为 true 或 false。

  • message:表示校验不通过时的提示消息。

  • trigger:表示触发校验的事件类型,可以是 blur、change 等。

  • min 和 max:分别表示输入值的最小值和最大值。例如 min:3 表示输入的值必须大于等于 3。

  • pattern:表示输入值的正则表达式,例如 pattern:/^[a-z]+$/i 表示输入的值必须由字母构成。

  • len:表示输入值的长度,例如 len:6 表示输入的值必须恰好为 6 个字符。

权限管理系统-RBAC

RBAC (Role-Based Access Control) 即基于角色的访问控制,是一种常见的访问控制机制,用于管理用户和资源之间的访问权限。RBAC 基于角色对用户进行授权,而不是直接授予用户特定的权限集合。通过这种方式,RBAC 可以使权限管理更易于管理和扩展。

在 RBAC 中,有三个核心概念:用户、角色和权限。其中:

  • 用户:系统中的每个用户都有唯一的标识符,可以被分配到一个或多个角色。
  • 角色:每个角色代表了一组权限集合,包含了访问系统中的某些资源所需的权限。角色可以被分配给一个或多个用户。
  • 权限:权限是指访问系统中某些资源所需的能力,例如读取、写入或执行某些操作等。

通过将用户分配到角色,并为每个角色分配适当的权限,RBAC 可以简化权限管理过程,减少管理员管理的工作量,同时也可以帮助保证系统安全性。

Main

页面布局

html
<template>
  <div class="main">
    <el-container class="main-content">
      <el-aside class="aside" width="210px">
          Aside
      </el-aside>
      <el-container>
        <el-header>
            Header
        </el-header>
        <el-main>Main</el-main>
      </el-container>
    </el-container>
  </div>
</template>

占满屏幕

css
  .main {
+    height: 100%;
    .main-content {
+      height: 100%;
      background-color: #ccc;
    }
  }
  .aside {
    &::-webkit-scrollbar {
      display: none;
    }
  }

组件:MainMenu

image-20230606142342401

使用组件

ts
import MainMenu from '@/components/MainMenu/MainMenu.vue'
import MainHeader from '@/components/MainHeader/MainHeader.vue'
html
  <div class="main">
    <el-container class="main-content">
      <el-aside class="aside" width="210px">
+        <MainMenu />
      </el-aside>
      <el-container>
        <el-header>
+          <MainHeader />
        </el-header>
        <el-main>Main</el-main>
      </el-container>
    </el-container>
  </div>

页面布局

1、element-plus布局

html
<template>
  <div class="main-menu">
    <!-- logo -->
    <div class="logo">
      <span>
        <img src="@/assets/img/logo.svg" alt="" />
      </span>
      <h2 class="title">木人后台管理</h2>
    </div>
    <!-- menu -->
    <div class="menu">
+      <el-menu
        default-active="2"
        class="el-menu-vertical-demo"
        background-color="#02142f"
        active-text-color="--active-color"
      >
+        <el-sub-menu>
+          <template #title>
            <el-icon><Monitor /></el-icon>
            <span>系统总览</span>
+          </template>
+          <el-menu-item index="1">核心技术</el-menu-item>
+          <el-menu-item index="1">商品统计</el-menu-item>
+        </el-sub-menu>
+        <el-sub-menu>
          <template #title>
            <el-icon><Monitor /></el-icon>
            <span>系统管理</span>
          </template>
          <el-menu-item index="1">用户管理</el-menu-item>
          <el-menu-item index="1">部门管理</el-menu-item>
          <el-menu-item index="1">菜单</el-menu-item>
          <el-menu-item index="1">角色统计</el-menu-item>
+        </el-sub-menu>
+        <el-sub-menu>
          <template #title>
            <el-icon><Monitor /></el-icon>
            <span>商品中心</span>
          </template>
          <el-menu-item index="1">商品类别</el-menu-item>
          <el-menu-item index="1">商品信息</el-menu-item>
+        </el-sub-menu>
+        <el-sub-menu>
          <template #title>
            <el-icon><Monitor /></el-icon>
            <span>随便聊聊</span>
          </template>
          <el-menu-item index="1">意见中心</el-menu-item>
          <el-menu-item index="1">信息分享</el-menu-item>
+        </el-sub-menu>
+      </el-menu>
    </div>
  </div>
</template>

2、分析:

  • ElMenu:整个菜单
  • ElSubMenu:可以有子菜单,并且可以展开
  • ElMenuItemGroup:对子菜单进行分组,但是不能展开。目的时给子菜单加上组名,但是不能交互
  • ElMenuItem:可以点击的每一个item

3、样式颜色

html
<el-menu default-active="2" background-color="#02142f">
less
.menu {
  .el-menu {
    border-right: none;
  }
  .el-sub-menu {
    background-color: #0c1f36;
    .el-sub-menu__title span,
    .el-sub-menu__title .el-icon {
      color: #b0bccf !important;
    }
    :deep(.el-sub-menu__icon-arrow) {
      color: #b0bccf;
    }
  }
  .el-menu-item {
    background-color: #0c1f36;
    color: #b0bccf;
    &.is-active {
      background-color: #0b5dbe;
      color: #fff;
    }
  }
}

缓存用户信息等

缓存登录时请求的用户详细信息、角色权限菜单树

ts
  state: (): ILoginState => ({
    token: localCache.getCache('token') ?? '',
+    userInfo: localCache.getCache('userInfo') ?? {},
+    roleMenuTreeInfo: localCache.getCache('roleMenuTreeInfo') ?? []
  }),

    /* 获取用户详细信息 */
    async fetchUserInfo(id: number) {
      const userRes = await getUserInfo(id)
      this.userInfo = userRes.data
      // 缓存用户详细信息
+      localCache.setCache('userInfo', userRes.data)
    },

    /* 获取角色菜单树 */
    async fetchRoleMenuTreeInfo(id: number) {
      const roleRes = await getRoleMenuTreeInfo(id)
      this.roleMenuTreeInfo = roleRes.data
      // 缓存角色菜单数
+      localCache.setCache('roleMenuTreeInfo', roleRes.data)
    }

动态展示菜单

1、基本展示

html
  <el-menu default-active="39" background-color="#02142f">
+    <template v-for="item in menus" :key="item.id">
      <el-sub-menu :index="item.id + ''">
        <template #title>
          <el-icon>
+            <component :is="item.icon.split('-icon-')[1]" />
          </el-icon>
          <span>{{ item.name }}</span>
        </template>
++        <template v-for="subitem in item.children" :key="subitem.id">
          <el-menu-item :index="subitem.id + ''">{{ subitem.name }}</el-menu-item>
++        </template>
      </el-sub-menu>
+    </template>
  </el-menu>
ts
import useLoginStore from '@/store/login'
/* 动态展示菜单 */
const loginStore = useLoginStore()
const menus = loginStore.roleMenuTreeInfo

2、给每个item添加唯一标识index

html
  <el-menu default-active="39" background-color="#02142f">
    <template v-for="item in menus" :key="item.id">
+      <el-sub-menu :index="item.id + ''">
        <template #title>
          <el-icon>
            <component :is="item.icon.split('-icon-')[1]" />
          </el-icon>
          <span>{{ item.name }}</span>
        </template>
        <template v-for="subitem in item.children" :key="subitem.id">
+          <el-menu-item :index="subitem.id + ''">{{ subitem.name }}</el-menu-item>
        </template>
      </el-sub-menu>
    </template>
  </el-menu>

3、初始打开第一个item

html
<!-- default-active="39" -->
<el-menu default-active="39" background-color="#02142f">

保持一个子菜单展开

  • Menu[ unique-opened ] : boolean,是否只保持一个子菜单的展开,默认false

动态展示图标

思路: 利用动态组件<component :is="xxx"> 通过字符串生成组件

html
  <el-icon>
+    <component :is="item.icon.split('-icon-')[1]" />
  </el-icon>

组件:MainHeader

image-20230606163139137

使用组件

html
    <el-container class="main-content">
      <el-aside class="aside" width="210px">
        <MainMenu />
      </el-aside>
      <el-container>
        <el-header>
+          <MainHeader />
        </el-header>
        <el-main>Main</el-main>
      </el-container>
    </el-container>
<script setup lang="ts">
+   import MainHeader from '@/components/MainHeader/MainHeader.vue'
</script>

页面布局

html
  <div class="main-header">
    <!-- 折叠图标 -->
    <div class="menu-icon" @click="flodMenuHdl">
      <el-icon>
        <component :is="isFold ? 'Expand' : 'Fold'" />
      </el-icon>
    </div>
    <div class="content">
      <!-- 面包屑 -->
      <div class="breadcrumb">
        <el-breadcrumb :separator-icon="ArrowRight">
          <el-breadcrumb-item :to="{ path: '/' }">系统总览</el-breadcrumb-item>
          <el-breadcrumb-item>核心技术</el-breadcrumb-item>
        </el-breadcrumb>
      </div>
      <!-- 用户信息 -->
      <div class="info">
        <!-- 消息互动 -->
        <div class="interactive">
          <span>
            <el-icon><Bell /></el-icon>
          </span>
          <span>
            <el-icon><ChatDotRound /></el-icon>
          </span>
          <span>
            <el-icon><ChatLineSquare /></el-icon>
          </span>
        </div>
        <!-- 用户中心 -->
        <div class="user">
			用户中心
        </div>
      </div>
    </div>
  </div>

点击折叠菜单

1、监听图标点击事件

html
    <!-- 折叠图标 -->
+    <div class="menu-icon" @click="flodMenuHdl">
      <el-icon>
        <Fold />
      </el-icon>
    </div>

2、切换图标

修改父组件中的isFold

  • 向父组件发送事件changeFold
ts
const emits = defineEmits(['changeFold'])
/* 点击折叠/展开菜单 */
function flodMenuHdl() {
  emits('changeFold')
}
  • 在父组件中修改isFold
html
<MainHeader @change-fold="changeFoldHdl" />
ts
/* 修改isFold */
function changeFoldHdl() {
  isFold.value = !isFold.value
}

获取父组件的isFold

  • 父组件传递isFold到子组件
html
<MainHeader :is-fold="isFold" />
  • 在子组件接收isFold,并根据isFold切换图标
ts
defineProps({
  isFold: {
    type: Boolean,
    default: false
  }
})
html
    <!-- 折叠图标 -->
    <div class="menu-icon" @click="flodMenuHdl">
      <el-icon>
+        <component :is="isFold ? 'Expand' : 'Fold'" />
      </el-icon>
    </div>

3、折叠菜单-修改aside宽度

在父组件定义isFold,根据isFlod切换宽度

ts
const isFold = ref<boolean>(false)
html
+      <el-aside class="aside" :width="isFold ? '60px' : '210px'">
        <MainMenu :is-fold="isFold" />
      </el-aside>

4、添加折叠时的动画

css
.aside {
+  transition: width 300ms ease;
  &::-webkit-scrollbar {
    display: none;
  }
}

5、折叠菜单-折叠el-menu

  • 在main中将isFold传递给main-menu
html
<MainMenu :is-fold="isFold" />
  • 在main-menu中接收isFold
ts
defineProps({
  isFold: {
    type: Boolean,
    default: false
  }
})
  • 通过collapse控制菜单折叠
html
<el-menu default-active="39" :collapse="isFold" background-color="#02142f">

6、根据isFold显示、隐藏title

html
<h2 class="title" v-show="!isFold">木人后台管理</h2>

组件:HeaderInfo

使用组件

html
    <div class="content">
      <!-- 面包屑 -->
      <div class="breadcrumb">
        <el-breadcrumb :separator-icon="ArrowRight">
          <el-breadcrumb-item :to="{ path: '/' }">系统总览</el-breadcrumb-item>
          <el-breadcrumb-item>核心技术</el-breadcrumb-item>
        </el-breadcrumb>
      </div>
      <!-- 用户信息 -->
+      <HeaderInfo />
    </div>
<script setup lang="ts">
+   import HeaderInfo from './cpns/HeaderInfo.vue'
</script>

页面布局

html
  <div class="header-info">
    <!-- 消息互动 -->
    <div class="interactive">
      <span>
        <el-icon><Bell /></el-icon>
      </span>
      <span>
        <el-icon><ChatDotRound /></el-icon>
      </span>
      <span>
        <el-icon><ChatLineSquare /></el-icon>
      </span>
    </div>
    <!-- 用户中心 -->
    <div class="user">
      <div class="avatar">
        <el-avatar
          :size="30"
          src="https://foruda.gitee.com/avatar/1672726238822211763/5636878_meray_1672726238.png"
        />
      </div>
+      <el-dropdown>
        <span class="name">
          {{ userInfo.name }}
          <el-icon><ArrowDown /></el-icon>
        </span>
        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item @click="logoutClickHdl">
              <el-icon><Close /></el-icon>
              <span>退出系统</span>
            </el-dropdown-item>
            <el-dropdown-item divided>
              <el-icon><InfoFilled /></el-icon>
              <span>个人信息</span>
            </el-dropdown-item>
            <el-dropdown-item>
              <el-icon><Lock /></el-icon>
              <span>修改密码</span>
            </el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </div>
  </div>

重置弹框的样式

注意: 此处由于弹框的根元素在#app元素外面,所以:deep() 不起效果,必须使用:global

image-20230606173115733

退出登录

html
+            <el-dropdown-item @click="logoutClickHdl">
              <el-icon><Close /></el-icon>
              <span>退出系统</span>
            </el-dropdown-item>
ts
import router from '@/router'
import { localCache } from '@/utils/cache'
/* 点击退出登录 */
function logoutClickHdl() {
  // 清除storage
  localCache.removeCache('token')
  // 跳转login
  router.push('/login')
}

动态路由

添加路由占位

html
  <el-main>
+    <RouterView />
  </el-main>

注册所有路由

注意: 该方法有通过路由泄露权限的风险,用户可以通过url访问自己不具备的权限页面

image-20230606180216046

点击菜单跳转路由

image-20230606180341430

image-20230606180516359

动态路由分析

image-20230607130030861

动态路由存放位置

方式一: 所有的路由放在统一的地方

image-20240322114316268

image-20230607132945722

方式二: 每个路由建一个单独的文件夹保存,这些路由和views页面目录一一对应,可以通过自动化工具创建路由和组件

1、定义单个路由

image-20230607142102958

2、安装自动化工具

sh
npm i coderwhy -g

3、工具命令

sh
# 查看版本
coderwhy --version

# 添加页面
coderwhy addpage <pagename> -d <path> # 添加vue2页面
coderwhy add3page <pagename> -d <path> # 添加vue3页面,如:export default {}
coderwhy add3page_setup <pagename> -d <path> # 添加vue3的setup页面,如:<script setup>

# <pagename>: 页面名称
# <path>: 添加到的位置路径,如 src/views/main/system/dashboard
# 示例: coderwhy add3page_setup menu -d src/views/main/system/menu

动态加载路由对象

ts
/* 动态加载路由 */
const localRoutes: RouteRecordRaw[] = []
// 1. 获取所有的路由文件
const files: Record<string, any> = import.meta.glob('./main/**/*.ts', { eager: true })
for (const key in files) {
  localRoutes.push(files[key].default)
}

根据菜单映射路由

ts
const loginStore = useLoginStore(pinia)
const roleMenuTreeInfo = loginStore.roleMenuTreeInfo
// 2. 遍历localRoutes,并和menus匹配
for (const menu of roleMenuTreeInfo) {
  for (const submenu of menu.children) {
+    const route = localRoutes.find((route) => route.path === submenu.url)
+    if (route) router.addRoute('main', route)
  }
}

封装映射路由

ts
// src\utils\mapMenus\index.ts

import type { RouteRecordRaw } from 'vue-router'

/**
 * 根据菜单动态加载路由
 * @param roleMenuTreeInfo 菜单树
 */
export function mapMenus(roleMenuTreeInfo: any[]) {
  const routes: RouteRecordRaw[] = []
  // 1. 获取所有的路由文件
  const localRoutes = loadLocalRoutes()

  // 2. 遍历localRoutes,并和menus匹配
  for (const menu of roleMenuTreeInfo) {
    for (const submenu of menu.children) {
      const route = localRoutes.find((route) => route.path === submenu.url)
      if (route) routes.push(route)
    }
  }
  return routes
}

function loadLocalRoutes() {
  const localRoutes: RouteRecordRaw[] = []
  const files: Record<string, any> = import.meta.glob('../../router/main/**/*.ts', { eager: true })
  for (const key in files) {
    localRoutes.push(files[key].default)
  }
  return localRoutes
}

使用

ts
// store/login/login.ts

/* 动态加载路由 */
const loginStore = useLoginStore(pinia)
const roleMenuTreeInfo = loginStore.roleMenuTreeInfo

const routes = mapMenus(roleMenuTreeInfo)
for (const route of routes) {
  router.addRoute('main', route)
}

页面刷新保留路由注册

1、在actions中动态加载路由

image-20230607150200557

2、在main中调用action

image-20230607150300290

3、*优化:*调用action

image-20230607150813846

image-20230607150817406

匹配main的第一个子页面

1、在匹配动态路由时,记录第一个被匹配到的菜单

ts
/* 导出第一个子目录 */
+ export let firstSubItem: any = null

/**
 * 根据菜单动态加载路由
 * @param roleMenuTreeInfo 菜单树
 */
export function mapMenus(roleMenuTreeInfo: any[]) {
  const routes: RouteRecordRaw[] = []
  // 1. 获取所有的路由文件
  const localRoutes = loadLocalRoutes()

  // 2. 遍历localRoutes,并和menus匹配
  for (const menu of roleMenuTreeInfo) {
    for (const submenu of menu.children) {
      const route = localRoutes.find((route) => route.path === submenu.url)
      if (route) routes.push(route)
+      if (!firstSubItem && route) firstSubItem = submenu
    }
  }
  return routes
}

2、在路由导航守卫中,重定向/main到第一个路由地址

ts
/* 路由导航守卫 */
router.beforeEach((to) => {
  const token = localCache.getCache('token')
  // 1. 如果跳转到main页面及其子页面,并且没有token的话,则跳转到login页面
  if (to.path.startsWith('/main') && !token) {
    return '/login'
  }
+  // 2. 如果跳转到/main页面,则重定向到/main下的第一个子页面
+  if (to.path === '/main' || to.path === '/main/') {
+    return firstSubItem?.url
+  }
})

根据路由匹配菜单

ts
/**
 * 根据当前路由匹配正确的菜单项
 * @param path 当前路由
 * @param menus 菜单列表
 */
export function mapPathToMenu(path: string, menus: any[]) {
  for (const menu of menus) {
    for (const submenu of menu.children) {
      if (submenu.url === path) {
        return submenu
      }
    }
  }
}
ts
/* 根据当前路由匹配正确的菜单项 */
const path = useRoute().path
const currMenu = mapPathToMenu(path, menus)
html
<el-menu :default-active="currMenu.id + ''" :collapse="isFold" background-color="#02142f">

组件:HeaderCrumb

使用组件

html
  <div class="main-header">
    <div class="content">
      <!-- 面包屑 -->
+      <HeaderCrumb />
      <!-- 用户信息 -->
      <HeaderInfo />
    </div>
  </div>
</template>

<script setup lang="ts">
import HeaderInfo from './cpns/HeaderInfo.vue'
+ import HeaderCrumb from './cpns/HeaderCrumb.vue'
</script>

页面布局

html
      <!-- 面包屑 -->
      <div class="breadcrumb">
        <el-breadcrumb :separator-icon="ArrowRight">
          <el-breadcrumb-item :to="{ path: '/' }">系统总览</el-breadcrumb-item>
          <el-breadcrumb-item>核心技术</el-breadcrumb-item>
        </el-breadcrumb>
      </div>

根据路由匹配菜单及父菜单

1、根据路由匹配菜单及父菜单

ts
/**
 * 根据路由匹配面包屑
 * @param path 当前路由
 * @param menus 菜单列表
 * @returns 面包屑菜单列表
 */
export function mapPathToCrumb(path: string, menus: any[]) {
  const crumbs: any[] = []
  for (const menu of menus) {
    for (const submenu of menu.children) {
      if (submenu.url === path) {
        crumbs.push({ path: menu.url, name: menu.name })
        crumbs.push({ path: submenu.url, name: submenu.name })
      }
    }
  }
  return crumbs
}

2、调用方法,获取面包屑数据

ts
/* 根据路由匹配面包屑 */
const path = route.path
const menus = loginStore.roleMenuTreeInfo
const crumbs = mapPathToCrumb(path, menus)

3、遍历面包屑

html
    <div class="breadcrumb">
      <el-breadcrumb :separator-icon="ArrowRight">
+        <template v-for="crumb in crumbs" :key="crumb.path">
+          <el-breadcrumb-item :to="crumb.path">{{ crumb.name }}</el-breadcrumb-item>
+        </template>
      </el-breadcrumb>
    </div>

4、监听路径的改变,实时更新面包屑

ts
  /* 根据路由匹配面包屑 */
  const menus = loginStore.roleMenuTreeInfo
+  const crumbs = computed(() => {
+    const path = route.path
+    return mapPathToCrumb(path, menus)
  })

点击一级面包屑跳转

1、添加父菜单到动态路由中

ts
export function mapMenus(roleMenuTreeInfo: any[]) {
  const routes: RouteRecordRaw[] = []
  // 1. 获取所有的路由文件
  const localRoutes = loadLocalRoutes()

  // 2. 遍历localRoutes,并和menus匹配
  for (const menu of roleMenuTreeInfo) {
    for (const submenu of menu.children) {
      const route = localRoutes.find((route) => route.path === submenu.url)
      if (route) {
        // 添加父菜单到动态路由中
+        if (!routes.find((item) => item.path === menu.url)) {
+          routes.push({ path: menu.url, redirect: route.path })
+        }
        // 添加子菜单到动态路由中
        routes.push(route)
      }
      if (!firstSubItem && route) firstSubItem = submenu
    }
  }
  console.log('routes: ', routes)
  return routes
}

2、监听路由变化

ts
/* 根据当前路由匹配正确的菜单项 */
+  const currMenu = computed(() => {
  const path = route.path
  return mapPathToMenu(path, menus)
})

User

页面布局

html
  <div class="user">
    <div class="user-search">
      <UserSearch />
    </div>
    <div class="content">
      <UserContent />
    </div>
  </div>

组件:UserSearch

image-20230608132919794

使用组件

html
<template>
  <div class="user">
    <div class="user-search">
+      <UserSearch />
    </div>
    <div class="content">
      <UserContent />
    </div>
  </div>
</template>

<script setup lang="ts">
+ import UserSearch from './cpns/UserSearch/UserSearch.vue'
import UserContent from './cpns/UserContent/UserContent.vue'
</script>

页面布局

注意: 在element-plus中允许将多行的el-col放到一个el-row中,配合:span属性,当span满24份时会自动换行

html
  <div class="user-search">
    <el-form label-width="80px">
      <el-row :gutter="120">
        <el-col :span="8">
          <el-form-item label="用户名">
            <el-input placeholder="请输入用户名" />
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="真实姓名">
            <el-input placeholder="请输入真实姓名" />
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="手机号码">
            <el-input placeholder="请输入手机号码" />
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="状态">
+            <el-select class="m-2" placeholder="Select" style="width: 100%">
              <el-option label="启用" :value="1" />
              <el-option label="禁用" :value="0" />
            </el-select>
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="创建时间">
+            <el-date-picker
              type="daterange"
              range-separator="-"
              start-placeholder="开始时间"
              end-placeholder="结束时间"
            />
          </el-form-item>
        </el-col>
      </el-row>
      <el-form-item class="btns">
        <el-button icon="Refresh">重置</el-button>
        <el-button icon="Search" type="primary">查询</el-button>
      </el-form-item>
    </el-form>
  </div>

element国际化

方法一: 全局引入

ts
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'

app.use(ElementPlus, {
  locale: zhCn,
})

方式二: 自动按需引入(推荐)

html
<template>
  <div class="app">
+    <el-config-provider :locale="zhCn">
      <RouterView />
    </el-config-provider>
  </div>
</template>

<script setup lang="ts">
+  import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>

添加.mjs文件声明

ts
declare module '*.mjs'

效果:

image-20240322161522498

重置、查询按钮

html
      <el-form-item class="btns">
+        <el-button icon="Refresh">重置</el-button>
+        <el-button icon="Search" type="primary">查询</el-button>
      </el-form-item>

重置功能

1、绑定表单数据

html
+    <el-form label-width="80px" :model="searchForm" ref="searchFormRef">
      <el-row :gutter="120">
        <el-col :span="8">
          <el-form-item label="用户名" prop="name">
+            <el-input v-model="searchForm.name" placeholder="请输入用户名" />
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="真实姓名" prop="realname">
+            <el-input v-model="searchForm.realname" placeholder="请输入真实姓名" />
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
ts
/* 表单数据 */
const searchForm = reactive({
  name: '',
  realname: '',
  cellphone: '',
  enable: 1,
  creatAt: []
})

2、重置表单

html
<el-button icon="Refresh" @click="hdlReset">重置</el-button>
html
<el-form label-width="80px" :model="searchForm" ref="searchFormRef">
ts
const searchFormRef = ref<InstanceType<typeof ElForm>>()
/* 重置搜索表单 */
function hdlReset() {
  searchFormRef.value?.resetFields()
}

3、注意: 如果想让resetFields起作用,需要添加prop属性

html
+ <el-form-item label="用户名" prop="name"> <!-- prop="name" -->
  <el-input v-model="searchForm.name" placeholder="请输入用户名" />
</el-form-item>

查询功能

html
<el-button icon="Search" type="primary" @click="hdlQuery">查询</el-button>
ts
/* 根据搜索项查询 */
function hdlQuery() {
  console.log('根据搜索项查询')
}

组件:UserContent

image-20230608132930629

使用组件

html
<template>
  <div class="user">
    <div class="user-search">
      <UserSearch />
    </div>
    <div class="content">
+      <UserContent />
    </div>
  </div>
</template>

<script setup lang="ts">
import UserSearch from './cpns/UserSearch/UserSearch.vue'
+  import UserContent from './cpns/UserContent/UserContent.vue'
</script>

页面布局

html
  <div class="user-content">
    <div class="header">header</div>
    <div class="form">form</div>
    <div class="navigation">navigation</div>
  </div>

表格-请求用户列表数据

1、在services中发送网络请求

ts
/* 请求用户列表数据 */
export function postUserList() {
  return mrRequest.post({
    url: '/users/list',
    data: {
      offset: 0,
      size: 10
    }
  })
}

2、在pinia中调用网络请求,并保存返回结果

ts
import { postUserList } from '@/service/main/system'
import { defineStore } from 'pinia'

interface ISystemState {
  userList: any[]
  totalCount: number
}
const useSystemStore = defineStore('system', {
+  state: (): ISystemState => ({
    userList: [],
    totalCount: 0
  }),
  actions: {
     /* 请求用户列表数据 */
+    async postUserListAction() {
      const res = await postUserList()
      this.userList = res.data.list
      this.totalCount = res.data.totalCount
    }
  }
})

export default useSystemStore

3、在UserContent组件中调用Action,

ts
import useSystemStore from '@/store/main/system'
const systemStore = useSystemStore()

systemStore.postUserListAction()

表格-展示用户列表

1、获取数据

ts
import useSystemStore from '@/store/main/system'
import { storeToRefs } from 'pinia'
const systemStore = useSystemStore()

systemStore.postUserListAction()

/* 获取用户列表 */
+  const { userList, totalCount } = storeToRefs(systemStore)

2、使用el-table展示数据

html
      <el-table :data="userList" border style="width: 100%">
        <el-table-column align="center" type="selection" />
        <el-table-column align="center" type="index" label="序号" width="60px" />
        <el-table-column align="center" prop="name" label="用户名" width="150px" />
        <el-table-column align="center" prop="realname" label="真实姓名" width="150px" />
        <el-table-column align="center" prop="cellphone" label="手机号码" width="150px" />
        <el-table-column align="center" prop="enable" label="状态" width="60px" />
        <el-table-column align="center" prop="createAt" label="创建时间" />
        <el-table-column align="center" prop="updateAt" label="更新时间" />
        <el-table-column align="center" label="操作" width="150px"></el-table-column>
      </el-table>

4、调整表格样式-宽度、居中、高度、按钮样式

表格样式-宽度、居中

html
        <el-table-column align="center" type="index" label="序号" width="60px" />
        <el-table-column align="center" prop="name" label="用户名" width="150px" />
        <el-table-column align="center" prop="realname" label="真实姓名" width="150px" />
        <el-table-column align="center" prop="cellphone" label="手机号码" width="150px" />
        <el-table-column align="center" prop="enable" label="状态" width="60px">

表格样式-高度

css
.el-table {
  :deep(.el-table__cell) {
    padding: 12px 0;
  }
}

表格样式-按钮样式、给按钮添加图标、样式

html
        <el-table-column align="center" label="操作" width="150px">
          <div class="btns">
+            <el-button type="primary" text>
              <el-icon><Edit /></el-icon>
              <span>编辑</span>
            </el-button>
+            <el-button type="danger" text>
              <el-icon><Delete /></el-icon>
              <span>删除</span>
            </el-button>
          </div>
        </el-table-column>

表格-展示启用

知识点: 作用域插槽

视频: [220715] D064-18 Vue3-(理解)组件插槽-作用域插槽的使用.mp4

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

表格-时间格式化

知识点: 插件:dayjs

1、安装

sh
npm i dayjs

2、封装dayjs

ts
import dayjs from 'dayjs'
+ import utc from 'dayjs/plugin/utc'

// 继承utc
+ dayjs.extend(utc)

export function formatUTC(utcString: string, format: string = 'YYYY-MM-DD HH:mm:ss') {
+  return dayjs.utc(utcString).format(format)
}

3、转成东八区时间

ts
export function formatUTC(utcString: string, format: string = 'YYYY-MM-DD HH:mm:ss') {
+  return dayjs.utc(utcString).utcOffset(8).format(format)
}

4、使用封装的方法进行格式化

html
<script setup lang="ts">
+ import { formatUTC } from '@/utils/format
</script>

<el-table-column align="center" prop="createAt" label="创建时间">
  <template #default="scope">{{ formatUTC(scope.row.createAt) }}</template>
</el-table-column>
<el-table-column align="center" prop="updateAt" label="更新时间">
  <template #default="scope">{{ formatUTC(scope.row.updateAt) }}</template>
</el-table-column>

分页-展示

html
    <div class="navigation">
+      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="[10, 20, 30]"
        small="small"
        layout="total, sizes, prev, pager, next, jumper"
        :total="totalCount"
        @size-change="hdlSizeChange"
        @current-change="hdlCurrentChange"
      />
    </div>
ts
/* 分页 */
const currentPage = ref(1)
const pageSize = ref(10)
function hdlSizeChange() {}
function hdlCurrentChange() {}

分页-切换页码重新请求数据

1、修改网络请求函数

ts
/* 请求用户列表数据 */
+ export function postUserList(query: any) {
  return mrRequest.post({
    url: '/users/list',
+    data: query
  })
}

2、在store中调用请求函数

ts
const useSystemStore = defineStore('system', {
  state: (): ISystemState => ({
    userList: [],
    totalCount: 0
  }),
  actions: {
+    async postUserListAction(query: any) {
      const res = await postUserList(query)
      this.userList = res.data.list
      this.totalCount = res.data.totalCount
    }
  }
})

3、在组件中封装调用请求的函数

ts
/* 发送网络请求 */
function fetchUserList(searchForm: ISearchForm = {}) {
  const offset = (currentPage.value - 1) * pageSize.value
  const size = pageSize.value
  const query = { offset, size }
+  const finalQuery = { ...query, ...searchForm }
  systemStore.postUserListAction(finalQuery)
}

4、页码、每页大小发生变化时,调用fetchUserListData

ts
/* 分页事件 */
function hdlSizeChange() {
  fetchUserList()
}
function hdlPageChange() {
  fetchUserList()
}

点击查询发送请求

难点: 查询和重置按钮属于UserSearch,而数据请求时发生在UserContent组件中,二者是兄弟组件

知识点:

  • defineEmits(string:[])返回:emit,定义向外发射的事件

1、发射事件queryClick到外部

ts
const emits = defineEmits(['search-form'])

/* 表单数据 */
const searchForm = reactive<ISearchForm>({
  name: '',
  realname: '',
  cellphone: '',
  enable: 1,
  createAt: ''
})

/* 根据搜索项查询 */
function hdlQuery() {
  emits('search-form', searchForm)
}

2、在外部监听事件,并接收数据

html
<UserSearch @search-form="hdlSearchForm" />

3、通过绑定ref调用另一个组件中的方法

html
<UserContent ref="contentRef" />
ts
/* 调用UserContent组件中的方法,查询数据 */
const contentRef = ref<InstanceType<typeof UserContent>>()
function hdlSearchForm(searchForm: ISearchForm) {
  if (contentRef.value) contentRef.value.fetchUserList(searchForm)
}

4、在另一个组件中暴露将被调用的方法,并根据传递的formData数据发送请求

ts
defineExpose({ fetchUserList })

/* 发送网络请求 */
function fetchUserList(searchForm: ISearchForm = {}) {
  const offset = (currentPage.value - 1) * pageSize.value
  const size = pageSize.value
  const query = { offset, size }
  const finalQuery = { ...query, ...searchForm }
  systemStore.postUserListAction(finalQuery)
}

点击重置发送请求

1、发射事件resetClick到外部

ts
/* 重置搜索表单 */
function hdlReset() {
+  emits('search-form', {})
  searchFormRef.value?.resetFields()
}

2、在外部监听事件,并接收数据,重新发送请求

html
<UserSearch @search-form="hdlSearchForm" />
ts
/* 调用UserContent组件中的方法,查询数据 */
const contentRef = ref<InstanceType<typeof UserContent>>()
function hdlSearchForm(searchForm: ISearchForm) {
  if (contentRef.value) contentRef.value.fetchUserList(searchForm)
}

删除-根据id删除数据

1、监听删除按钮点击事件

html
+  <el-button type="danger" text @click="() => hdlDeleteUser(scope.row.id)">
    <el-icon><Delete /></el-icon>
    <span>删除</span>
  </el-button>

2、添加作用域插槽,获取scope

html
  <el-table-column align="center" label="操作" width="140px">
+    <template #default="scope">
      <div class="btns">
        <el-button type="primary" text>
          <el-icon><Edit /></el-icon>
          <span>编辑</span>
        </el-button>
+        <el-button type="danger" text @click="() => hdlDeleteUser(scope.row.id)">
          <el-icon><Delete /></el-icon>
          <span>删除</span>
        </el-button>
      </div>
    </template>
  </el-table-column>

3、根据id发送请求删除数据

ts
/* 根据id删除用户 */
function hdlDeleteUser(id: number) {
+  systemStore.delUserByIdAction(id)
}

4、在store中发送调用请求函数

ts
    /* 根据id删除用户 */
    async delUserByIdAction(id: number) {
+      await delUserById(id)
      ElMessage.success('哈哈,删除成功~')
      this.postUserListAction({ offset: 0, size: 5 })
    },

5、在service中定义发送网络请求函数

ts
/* 根据id删除用户 */
export function delUserById(id: number) {
  return mrRequest.delete({
    url: `/users/${id}`
  })
}

6、删除成功后重新请求数据

ts
    /* 根据id删除用户 */
    async delUserByIdAction(id: number) {
      await delUserById(id)
      ElMessage.success('哈哈,删除成功~')
+      this.postUserListAction({ offset: 0, size: 5 })
    },

新增用户

1、监听新增按钮点击

html
<el-button class="btn" type="primary" @click="hdlAddUser">新增用户</el-button>

组件:UserModal

image-20230608181447831

使用组件

html
  <div class="user-content">
+    <UserModal />
  </div>
<script setup lang="ts">
+  import UserModal from '../UserModal/UserModal.vue'
</script>

页面布局

html
  <div class="user-modal">
    <el-dialog v-model="modalVisiable" title="新增用户" width="30%" center>
      <span>
        表单部分
      </span>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="modalVisiable = false">取消</el-button>
          <el-button type="primary" @click="modalVisiable = false">确定</el-button>
        </span>
      </template>
    </el-dialog>
  </div>

显示、隐藏对话框

1、组件UserContent中

ts
const emit = defineEmits(['change-visiable'])
/* 新增用户 */
function hdlAddUser() {
  emit('change-visiable')
}

2、组件User中

html
<UserContent ref="contentRef" @change-visiable="hdlChangeVisiable" />
html
<UserModal ref="modalRef" />
ts
/* 修改对话框是否显示 */
const modalRef = ref<InstanceType<typeof UserModal>>()
function hdlChangeVisiable() {
  if (modalRef.value) modalRef.value.changeModalVisiable()
}

3、组件UserModal中

ts
defineExpose({ changeModalVisiable })

const modalVisiable = ref(false)

/* 修改对话框是否显示 */
function changeModalVisiable() {
  modalVisiable.value = true
}

表单布局

html
      <div class="form">
        <el-form label-position="right" label-width="100px" size="large">
          <el-form-item label="用户名" prop="name">
            <el-input placeholder="请输入用户名" />
          </el-form-item>
          <el-form-item label="真实姓名" prop="realname">
            <el-input placeholder="请输入真实姓名" />
          </el-form-item>
          <el-form-item label="密码" prop="password" show-password>
            <el-input placeholder="请输入密码" />
          </el-form-item>
          <el-form-item label="电话号码" prop="cellphone">
            <el-input placeholder="请输入电话号码" />
          </el-form-item>
          <el-form-item label="选择角色" prop="role">
            <el-input placeholder="请选择角色" />
          </el-form-item>
          <el-form-item label="选择部门" prop="department">
            <el-input placeholder="请选择部门" />
          </el-form-item>
        </el-form>
      </div>
ts
/* 表单数据 */
const addUserForm = reactive({
  name: '',
  realname: '',
  password: '',
  cellphone: '',
  roleId: '',
  departmentId: ''
})

角色和部门数据

注意: 由于角色和部门数据可能会在其他许多页面都有使用,应该提取出来,放在main/main.ts

1、在service中发送网络请求

ts
/* 获取角色列表 */
export function postRoleLists() {
  return mrRequest.post({
    url: '/role/list'
  })
}

/* 获取部门列表 */
export function postDepartmentLists() {
  return mrRequest.post({
    url: '/department/list'
  })
}

2、在store中调用网络请求

ts
import { postDepartmentLists, postRoleLists } from '@/service/main/main'
import { defineStore } from 'pinia'

interface IMainState {
  roleLists: any[]
  departmentLists: any[]
}

const useMainStore = defineStore('main', {
+  state: (): IMainState => ({
    roleLists: [],
    departmentLists: []
  }),
  actions: {
+    async postRoleListsAction() {
+      const res = await postRoleLists()
      this.roleLists = res.data.list
    },
+    async postDepartmentListsAction() {
+      const res = await postDepartmentLists()
      this.departmentLists = res.data.list
    }
  }
})

export default useMainStore

3、在组件中发起action

ts
// src\views\Main\Main.vue

/* 发送网络请求 */
mainStore.postRoleListsAction()
mainStore.postDepartmentListsAction()

展示角色和部门

1、从store中获取数据

ts
/* 获取store中数据 */
const { roleLists, departmentLists } = storeToRefs(mainStore)

2、遍历展示数据

html
  <el-form-item label="选择角色" prop="roleId">
    <el-select v-model="addUserForm.roleId" class="m-2" placeholder="Select">
      <el-option
+        v-for="item in roleLists"
+        :key="item.id"
+        :label="item.name"
+        :value="item.id"
      />
    </el-select>
  </el-form-item>

  <el-form-item label="选择部门" prop="departmentId">
    <el-select v-model="addUserForm.departmentId" class="m-2" placeholder="Select">
      <el-option
+        v-for="item in departmentLists"
+        :key="item.id"
+        :label="item.name"
+        :value="item.id"
      />
    </el-select>
  </el-form-item>

点击确定添加用户

1、监听按钮点击

html
  <template #footer>
    <span class="dialog-footer">
      <el-button @click="modalVisiable = false">取消</el-button>
+      <el-button type="primary" @click="hdlAddUser">确定</el-button>
    </span>
  </template>

2、在service中发送添加用户的网络请求

ts
/* 新增用户 */
export function addUser(userInfo: any) {
  return mrRequest.post({
    url: '/users',
    data: userInfo
  })
}

3、在store中调用网络请求

ts
    /* 新增用户 */
    async addUserAction(userInfo: any) {
+      await addUser(userInfo)
    }

4、在组件中,调用action,创建新用户

ts
/* 添加用户 */
const formRef = ref<InstanceType<typeof ElForm>>()
function hdlAddUser() {
  modalVisiable.value = false
  // 验证表单
+  formRef.value?.validate((valid: any) => {
    if (valid) {
      // 验证成功
+      systemStore.addUserAction(addUserForm)
    } else {
      // 验证失败
      ElMessage.error('呜呼,验证失败,请重新来过~')
    }
  })
}

5、新增用户后,重新请求用户数据

ts
    /* 新增用户 */
    async addUserAction(userInfo: any) {
      await addUser(userInfo)
+      this.postUserListAction({ offset: 0, size: 5 })
    }

编辑用户

1、监听编辑按钮点击

html
+  <el-button type="primary" text @click="() => hdlEditUser(scope.row)">
    <el-icon><Edit /></el-icon>
    <span>编辑</span>
  </el-button>

2、向外暴露事件,并传递数据

ts
const emit = defineEmits(['change-visiable', 'edit-click'])
/* 编辑用户 */
function hdlEditUser(userItem: any) {
  emit('edit-click', userItem)
}

3、在User父组件中,监听edit-click事件,并调用弹出框组件中的方法

html
  <UserContent
    ref="contentRef"
    @change-visiable="hdlChangeVisiable"
+    @edit-click="hdlEditClick"
  />
ts
/* 调用模态组件内函数,修改用户 */
function hdlEditClick(userItem: any) {
  if (modalRef.value) modalRef.value.changeModalVisiable(userItem)
}

4、在UserModal组件中,回显当前编辑的用户

ts
/* 修改对话框是否显示 */
const isEdit = ref(false)
function changeModalVisiable(userItem: any = null) {
  modalVisiable.value = true
  userId.value = userItem?.id
  if (userItem) {
    // 编辑状态
    isEdit.value = true
     // 遍历回显
+    for (const key in userForm) {
+      userForm[key] = userItem[key]
+    }
  }
}

注意: formData需要定义为any类型

ts
/* 表单数据 */
+ const userForm = reactive<any>({ // reactive<any>
  name: '',
  realname: '',
  password: '',
  cellphone: '',
  roleId: '',
  departmentId: ''
})

5、不显示密码表单

  • 全局记录isNew的值
ts
/* 修改对话框是否显示 */
+ const isEdit = ref(false)
const userInfo = ref()
function changeModalVisiable(userItem: any = null) {
  modalVisiable.value = true
  userId.value = userItem?.id
  if (userItem) {
    // 编辑状态
+    isEdit.value = true
    for (const key in userForm) {
      userForm[key] = userItem[key]
    }
    userInfo.value = userForm
  } else {
    // 新增状态
+    isEdit.value = false
    for (const key in userForm) {
      userForm[key] = ''
    }
    userInfo.value = null
  }
}
  • 根据isNew的值,显示、隐藏密码表单
html
  <el-form-item v-if="!isEdit" label="密码" prop="password">
    <el-input v-model="userForm.password" placeholder="请输入密码" show-password />
  </el-form-item>

6、在新增用户的情况下,初始化清空所有表单

ts
function changeModalVisiable(userItem: any = null) {
  modalVisiable.value = true
  userId.value = userItem?.id
  if (userItem) {
    // 编辑状态
    isEdit.value = true
    for (const key in userForm) {
      userForm[key] = userItem[key]
    }
  } else {
    // 新增状态
    isEdit.value = false
+    for (const key in userForm) {
+      userForm[key] = ''
+    }
  }
}

点击确定编辑用户

1、service

ts
/* 编辑用户 */
export function editUser(id: number, userInfo: any) {
  return mrRequest.patch({
    url: `/users/${id}`,
    data: userInfo
  })
}

2、store

ts
    /* 修改用户 */
    async editUserAction(id: number, userInfo: any) {
      await editUser(id, userInfo)
        
      this.postUserListAction({ offset: 0, size: 5 })
    }

3、组件

  • 保存userInfo
ts
+ const userInfo = ref()
function changeModalVisiable(userItem: any = null) {
  modalVisiable.value = true
  userId.value = userItem?.id
  if (userItem) {
    // 编辑状态
    isEdit.value = true
    for (const key in userForm) {
      userForm[key] = userItem[key]
    }
+    userInfo.value = userForm
  } else {
    // 新增状态
    isEdit.value = false
    for (const key in userForm) {
      userForm[key] = ''
    }
+    userInfo.value = null
  }
}
  • 调用action,执行编辑操作
ts
function hdlSubmitUser() {
  modalVisiable.value = false
  // 验证表单
  formRef.value?.validate((valid: any) => {
    if (valid) {
      // 验证成功
      if (isEdit.value) {
+        systemStore.editUserAction(userId.value, userInfo)
+        ElMessage.success('哈哈,修改用户成功~')
      } else {
        systemStore.addUserAction(userInfo)
        ElMessage.success('哈哈,新增用户成功~')
      }
    } else {
      // 验证失败
      ElMessage.error('呜呼,验证失败,请重新来过~')
    }
  })
}

问题集

声明之前已使用的块范围变量“__VLS_13”

问题: 刚刚使用pnpm create vue@latest创建vue3项目,安装依赖包之后就出现以下报错:

image-20240304084429664

解决思路:

  • 怀疑是vscode插件引起,目前使用的插件:

    • Vue - Official@2.0.3
    • TypeScript Vue Plugin (Volar)@1.8.27
  • 尝试1:(失败),卸载Vue - Official@2.0.3,只用volar

    • 问题:会出现vue代码没有高亮提示
  • 尝试2:(失败),装回Vue - Official@2.0.3,此时提示:

    image-20240304085202620

    • 根据提示卸载TypeScript Vue Plugin (Volar)@1.8.27
  • 尝试3:(成功),怀疑是Vue - Official因为是2.x版本,比较新出现的BUG,安装Vue - Official@1.8.27版本看看

    • 问题解决
  • 目前(2024-5-23):更新到Vue - Officialv2.0.8版本,该问题已经解决

不识别vue文件类型

问题:

  • vscode不能识别vue文件。

    image-20240304091649500

  • 此时App是any类型

    image-20240304091726371

解决: 需要手动添加vue文件的类型声明

ts
declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
  const component: DefineComponent
  export default component
}

注意: import type xxx 需要写在 declare {} 内部

问题: 在安装了TypeScript Vue Plugin (Volar) 插件时,不能正确报错:不能识别vue文件的错误

  • 虽然没有报错,但是App不能识别正确的类型,此时App是any类型

    image-20240304092007580

解决: (暂时)停用TypeScript Vue Plugin (Volar) 插件

image-20240304092441485

打包出错

问题: 通过pnpm run build打包项目时,报以下错误:

sh
D:\2024\Work\Vue\mr-vue3-ts-cms-2403>pnpm run build

> mr-vue3-ts-cms-2403@0.0.0 build D:\2024\Work\Vue\mr-vue3-ts-cms-2403
> run-p type-check "build-only {@}" --

 ERROR  Unknown option: 'mr-vue3-ts-cms-2403:commitizen_path'
For help, run: pnpm help run
 ERROR  Unknown option: 'mr-vue3-ts-cms-2403:commitizen_path'
For help, run: pnpm help run
ERROR: "type-check" exited with 1.
 ELIFECYCLE  Command failed with exit code 1.

原因: 这是因为package.json配置中有以下配置,和打包冲突

js
{
+  "config": {
+    "commitizen": {
+      "path": "./node_modules/cz-conventional-changelog"
+    }
+  }
}

解决: 删除package.json中上面的"commitizen": {path}配置,将它移到新建的.czrc配置文件中

image-20240304135826463

git提交时换行符提示

问题: 在设置了.editconfig 文件的end_of_line = lf 后,每次git提交时都会提示:LF will be replaced by CRLF the next time Git touches it 报错

解决: (尝试)在终端设置如下命令:

sh
# 会重新配置 Git,将其默认的换行符格式设置为 lf
git config --global core.eol lf

其他关于换行符的命令:

sh
# 重新配置 Git,让其能够自动处理行尾符并转换为正确的格式。
git config --global core.autocrlf true

prettier保存时自动格式化

问题: 让prettier在保存时自动格式化

  • 1、在vscode中安装 Prettier 扩展
  • 2、在设置中搜索format on save ,选中Editor: Format On Save
  • 3、在设置中搜索default format,设置Editor: Default FormatterPrettier - Code formatter
  • 4、配置.prettierrc
  • 5、实现保存代码时自动格式化

element-plus 没有类型提示

问题: 使用自动导入之后,无法正确获取到element-plus的类型提示

image-20240719171358258

尝试: 通过在tsconfig.app.json 中添加如下类型include依然无效

json
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "auto-imports.d.ts", "components.d.ts"],

解决: 通过在vite.config.ts中导入插件配置时,添加dts属性,可以解决该问题

image-20240523170818086

针对ElMessage等组件引入样式

问题: ElMessage等组件无法自动引入,只能手动引入,但是引入后样式也需要另外引入

解决:

方法一: 在main.ts中手动引入样式

image-20240719171413155

方法二: 使用插件 vite-plugin-style-import

  • 1、安装:npm i vite-plugin-style-import -D

  • 2、在vite.config.ts 中配置

    image-20240719171434787

    image-20240719171447994

  • 3、由于缺失了consola插件,需要另外安装

    sh
    npm i consola -D

ElMessage缺少声明

通过import { ElMessage } from 'element-plus'导入ElMessage 组件时报如下错误:

报错: 模块 ""element-plus"" 没有导出的成员 "ElMessage"。

分析: 这是由于 IDE无法识别到element-plus中的ElMessage 类型,需要手动添加类型声明

env.d.ts中添加类型声明

ts
// element-plus类型声明
declare module 'element-plus' {
  import { ElMessage } from 'element-plus'
  export class ElMessage {
    static success(message: string): void
    static warning(message: string): void
    static info(message: string): void
    static error(message: string): void
  }
}

注意: 目前在使用自动导入的情况下已没有该问题

vue中deep的使用场景

vue中如果在组件的根元素上的样式可以不用:deep() ,但是如果想选中组件内部的元素就需要:deep()

image-20230606174549631

image-20230606174348617

nextTick

在 Vue 3 中,nextTick() 是一个全局 API,用于在下一轮 DOM 更新循环之后执行延迟回调。它的语法如下:

js
Vue.nextTick(callback)

其中,callback 是一个函数,它将在下一轮 DOM 更新循环中被调用。

由于在 Vue.js 中,数据更新是异步的,所以在某些情况下,您可能无法立即得到数据更新的最新值。在这种情况下,你可以使用 nextTick() 来确保你的回调在 Vue.js 更新 DOM 后执行。

示例:

image-20230613164219741