项目简介

技术栈

  • Vue3 + TypeScript
  • Vite 构建工具
  • Element Plus UI框架
  • Pinia 状态管理
  • Vue Router 路由管理
  • Axios 网络请求

运行展示

登录组件
登录组件
登录组件
登录组件
登录组件

项目梳理

项目梳理请见文章

搭建和基础配置

项目搭建

使用vue3+pnpm搭建项目
创建项目
令人愉悦的绿色画面
创建项目

原始目录

.vscode\extensions.json:推荐安装的插件
package.json\安装的依赖和一些命令

@别名配置

两个文件夹里的别名配置@作用不一样
vite.config.ts:导出配置;别名配置(@指向src)打包的时候用
tsconig.json:自己添加放在带app的文件里,最终都会合并到tsconfig.json里
ts配置;别名配置(@指向src)供vscode读取写代码时候有提示

让App被识别成组件(可选)

main.ts中import App from './App.vue'
现在这里的App没有被识别成组件,在env.d.ts中加入代码

1
2
3
4
5
6
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent
export default component
}

目录调整

把没用的删了建立新的文件夹

代码规范配置

集成editorconfig配置

.editorconfig文件配置:
用于解决不同IDE编译器开发同一项目时,维护一致的编码风格(避免乱码)
很多开源项目都有这个东西,下面的代码以后其他项目也能用
在我搭建项目时,这个文件自动给我配置了一些规则,原来的不全,我注释掉了
VSCode要安装一个插件:EditorConfig for VS Code(小老鼠头)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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

prettier工具

Prettier 是一款强大的代码格式化工具,基本上前端能用到的文件格式它都可以搞定,是当下最流行的代码格式化工具

  1. 安装prettier
1
pnpm install prettier -D
  1. 配置.prettierrc文件:
    在创建项目时我选择了这个,原来的不全,在文件夹里修改代码即可
  • useTabs:使用tab缩进还是空格缩进,选择false;
  • tabWidth:tab是空格的情况下,是几个空格,选择2个;
  • printWidth:当行字符的长度,推荐80,也有人喜欢100或者120;
  • singleQuote:使用单引号还是双引号,选择true,使用单引号;
  • trailingComma:在多行输入的尾逗号是否添加,设置为 none,比如对象类型的最后一个属性后面是否加一个,;
  • semi:语句末尾是否要加分号,默认值true,选择false表示不加;
1
2
3
4
5
6
7
8
{
"useTabs": false,
"tabWidth": 2,
"printWidth": 80,
"singleQuote": true,
"trailingComma": "none",
"semi": false
}
  1. 创建.prettierignore忽略文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /dist/*
    .local
    .output.js
    /node_modules/**

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

    /public/*
  2. VSCode要安装prettier的插件,让ctrl+s即可生效
  3. VSCode中修改配置
  • settings =>format on save => 勾选上
  • settings => editor default format => 选择 prettier
  1. 测试prettier是否生效
  • 测试一:在代码中保存代码;
  • 测试二:配置一次性修改的命令(如果没生效再做)
    在package.json中配置一个scripts:
    (我的文件中已经有"format": "prettier --write src/")
    1
    "prettier": "prettier --write ."

使用ESLint检测(我之前做过,这里没有按照配置说明配置)

VSCode需要安装ESLint插件:
解决eslint和prettier冲突的问题:

  1. 安装插件:
    (vue在创建项目时,如果选择prettier,那么这两个插件会自动安装)
1
pnpm install eslint-plugin-prettier eslint-config-prettier -D
  1. 添加prettier插件:
    1
    2
    3
    4
    5
    6
    7
    8
    extends: [
    "plugin:vue/vue3-essential",
    "eslint:recommended",
    "@vue/typescript/recommended",
    "@vue/prettier",
    "@vue/prettier/@typescript-eslint",
    'plugin:prettier/recommended'
    ],
  2. VSCode中eslint的配置
1
2
3
4
5
6
7
8
9
10
11
"eslint.lintTask.enable": true,
"eslint.alwaysShowStatus": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},

css样式重置

assets文件夹下建css文件夹,放一些样式重置规则reset.less
(我复制的以前写过的规则)
pnpm install normalize.css
npm install less -D

路由配置

创建路由实例

pnpm add vue-router@4
router/index.ts文件创建路由实例

1
2
3
4
5
6
7
8
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
history: createWebHashHistory(),
routes: []
})
export default router

  • 路由:管理单页应用(SPA)中不同URL路径与组件的映射关系
  • 路由实例:通过createRouter创建,是Vue Router的核心对象,管理路由的配置和跳转
  • 使用哈希模式通过URL的#符号来管理路由路径
  • 哈希模式无需服务器配置,更适合开发环境或部署在静态服务器上
  • 别忘记最后将创建的路由实例导出并在main.ts中引入并挂载到Vue实例上

创建组件

路由最重要的映射关系path=>component,现在还没组件
在views(视图)文件夹下创建对应的组件

解决组件单个单词命名警告问题

默认的配置里面不让使用单个单词命名(login.vue),给我报了警告
警告
z这个里是eslint的配置,在tsconfig.json中添加

  1. 只在某个文件中不适用该规则
    在script部分顶部添加注解关闭规则
    eslint-disable-next-line vue/multi-word-component-names
    1
    2
    3
    4
    5
    6
    <script>
    eslint-disable-next-line vue/multi-word-component-names
    export default {
    name: 'Button' // 单个单词命名
    }
    </script>
  2. 全局禁用该规则(我用的这个)
    在eslint.config.js中最后添加:
    1
    2
    3
    4
    5
    module.exports = {
    rules: {
    'vue/multi-word-component-names': 'off'
    }
    }

生成代码片段(借用网站)

由于这些路由组件的代码类型高度重合,使用代码片段更方便开发,用这个网站生成代码片段:https://snippet-generator.app

Snippet Generator 是一个在线代码片段生成器,主要用于快速生成适用于 VS Code(或其他编辑器)的代码片段配置文件。它通过图形化界面简化了代码片段的创建过程,避免手动编写复杂的 JSON 配置。
可视化界面:无需手动编写 JSON,直接输入代码和触发前缀即可生成代码片段。
占位符自动处理:支持通过 $1、$2 等占位符定义光标跳转位置,提升代码填充效率

我想我的每个组件都是这样的类似代码

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="login">
<h2>login</h2>
</div>
</template>

<script setup lang="ts"></script>

<style lang="less" scoped>
.login {
color: orange;
}
</style>

我用这个网站这样做,把右边的copy
生成代码片段
在vscode中配置
vscode配置代码片段
点进去,跟着编译器指示,我选择的新建当前项目文件文件夹的代码片段文件
新建一个文件名(我vue3ts),回车,出现一个空的{}和一堆注解,把注解换成网站上复制的代码,保存
vscode配置代码片段
试一下,新建.vue文件,输入tsvue回车,成功啦
创建login、main组件(Login.vueMain.vue

配置路由

回到router/index.ts文件,配置路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: '/main'
},
{
path: '/main',
name: 'main',
component: () => import('@/views/main/Main.vue')
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login/Login.vue')
}
]
})
export default router

测试路由

回到`App.vue``,在template中添加一个router-view标签,用来显示路由组件

1
2
3
4
5
6
7
<template>
<div>Hello小聂同学</div>
<router-link to="/main">去main页面</router-link>
<hr />
<router-link to="/login">去login页面</router-link>
<router-view></router-view>
</template>

运行,成功啦
路由测试成功

完善–notfound页面

如果用户输入一个不存在的路由,显示一个404页面
创建文件view/not-found/NotFound.vue
配置路由
path: '/:pathMatch(.*)'匹配所有未被其他路由规则捕获的路径
:pathMatch:这是一个动态路由参数的名称(自定义的)
(.*):正则表达式,表示匹配任意字符(包括空字符串),捕获所有路径

1
2
3
4
5
{
path: '/:pathMatch(.*)',
name: 'notFound',
component: () => import('@/views/not-found/NotFound.vue')
}

成功notfound页面

状态管理pinia

首先pnpm install pinia
在main.ts中引入pinia并挂载到Vue实例上

1
2
3
import pinia from './store'
const app = createApp(App)
app.use(pinia)

写个例子测试

store/index.ts

1
2
3
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

数据放在仓库里,创建一个counter.ts文件测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineStore } from 'pinia'

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

export default useCounterStore

Main.vue中引入pinia,并使用store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div class="main">
<h2>main: {{ counterStore.counter }}-{{ counterStore.doubleCounter }}</h2>
<button @click="changeCounter">修改counter</button>
</div>
</template>

<script setup lang="ts">
import useCounterStore from '@/store/counter'

const counterStore = useCounterStore()

function changeCounter() {
counterStore.changeCounterAction(999)
}
</script>

跑一跑,成功pinia应用

axios

pnpm install axios
创建service文件夹,这里用的老师封装好的代码文件,cv进来直接使用的

测试网络请求

在login.vue中测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import hyRequest from '@/service'

hyRequest
.get({
url: '/category/1'
})
.then((res) => {
console.log(res)
})
</script>

<style lang="less" scoped>
.login {
color: orange;
}
</style>

开发模式和生产模式

service/config/index.ts

  1. 手动区分开发环境和牛产环境
    用哪个留哪个,不用的注释掉
    1
    2
    export const BASE_URL ='http://coderwhy.dev:8008'
    export const BASE_URL ='http://codercba.prod:806'
  2. 代码逻辑判断当前环境
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //下面三行返回布尔值
    console.log(import.meta.env.DEV)//是否是开发环境
    console.log(import.meta.env.PROD)//是否生产环境
    console.log(import.meta.env.SSR)//是否是服务器端渲染

    let BASE URL =''
    if(import.meta.env.PRoD){
    BASE URL ='http://codercba.prod:8000'
    }else{
    BASE URL = 'http://coderwhy.dev:8000'
    }
    代码笔记:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // 1.区分开发环境和生产环境
    // export const BASE_URL = 'http://coderwhy.dev:8000'
    // export const BASE_URL = 'http://codercba.prod:8000'

    // 2.代码逻辑判断, 判断当前环境
    // vite默认提供的环境变量
    // console.log(import.meta.env.MODE)
    console.log(import.meta.env.DEV) // 是否开发环境
    console.log(import.meta.env.PROD) // 是否生产环境
    console.log(import.meta.env.SSR) // 是否是服务器端渲染(server side render)

    let BASE_URL = ''
    if (import.meta.env.PROD) {
    //BASE_URL = 'http://localhost:8880'
    BASE_URL = 'http://123.207.32.32:5000'
    // BASE_URL = 'http://codercba.prod:8000'
    } else {
    // BASE_URL = 'http://localhost:8880'
    BASE_URL = 'http://123.207.32.32:5000'
    // BASE_URL = 'http://coderwhy.dev:8000'
    }

    console.log(BASE_URL)

    // 3.通过创建.env文件直接创建变量
    console.log(import.meta.env.VITE_URL)

    export const TIME_OUT = 10000
    export { BASE_URL }

Element-Plus集成

使用时一句话:用组件,调属性,不合适的再覆盖

使用方法

看官方文档,我使用按需导入-自动导入的方法
pnpm add -D unplugin-vue-components unplugin-auto-import
然后根据官方文档配置vite.config.ts文件
pnpm add element-plus
文件夹会多出两个文件:auto-imports.d.ts和components.d.ts,用来帮我们声明
用什么组件直接把<template></template>部分中的代码CV进来,ts部分会自动导入,不用CV
试试看,成功引用Element-Plus集成

在tsconfig,js文件中添加配置

我的项目中tsconfig.app.json文件夹中本来是
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
把前面按需导入-自动导入配置生成的两个文件也放进去
变成

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

为什么要这样做呢:

  1. 类型声明生效的必要条件
    TypeScript编译器只会处理被include包含的文件(以及被files指定的文件)
    auto-imports.d.ts和components.d.ts包含了全局类型声明
    如果不包含它们,TS编译器会忽略这些声明,导致类型检查失效

  2. 自动生成的声明文件特性
    这两个文件由unplugin-auto-import和unplugin-vue-components插件动态生成
    默认位于项目根目录(或指定目录)的非源码位置
    需要显式告知TS编译器这些声明文件的位置

  3. 回顾vite项目的配置结构
    tsconfig.json:基础配置
    tsconfig.app.json(通过extends继承):应用代码的专属配置
    tsconfig.node.json:Vite配置等Node端代码的配置

调整窗口大小

让App.vue中的大盒子填满整个屏幕
不用关心父元素是否填满了屏幕,用下面两行代码

1
2
3
4
.app {
width: 100vw;
height: 100vh;
}

之前用来测试是否配置成功的无用代码清掉了,开始正式布局

登录页

登录页login/login.vue

登录页布局

登录框组件导入

组件思想:把登录框设置成一个组件
在login/children-cpns(子组件)文件夹/创建login-panel.vue(登录面板)
在login.vue中引入login-panel.vue,并使用

1
2
3
4
5
6
7
8
9
10
<template>
<div class="login">
<h2>login</h2>
<login-panel></login-panel>
</div>
</template>

<script setup lang="ts">
import LoginPanel from './children-cpns/login-panel.vue'
</script>

登录框组件布局:

  1. 思路:
    先把里面的元素都摆出来,然后再布局美化,从element-plus组件库找对应的组件进来替换掉原来的元素,再继续调整布局
  2. 居中调整
    样式调整:flex布局,组件要居中,login.vue独占页面,里面内容要居中,在login.vue
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    .login {
    color: rgb(174, 119, 208);
    display: flex;
    justify-content: center;
    align-items: center;

    width: 100%;
    height: 100%;
    background: url('../../assets/img/login-bg.svg');
    }
  3. 调整调整再调整
    调整的细节不写笔记了,代码里有注释,一点一点慢慢搭建
    登录组件一点点完善如图:
    登录组件
    在绑定v-model时,多去控制台看看数据有没有绑定成功,如图
    v-model绑定检查

登录框组件逻辑

绑定属性

目前只是做好了样式,没有给组件绑定属性
完善组件的属性和对应变量的声明,比如帐号密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<!-- tab下帐号登录的表单部分 -->
<div class="pane-account">
<!--label-width调整宽度 -->
<!-- :model="account"绑定表单,获取整个对象 -->
<el-form :model="account" label-width="60px" size="large" status-icon ref="formRef">
<el-form-item label="帐号" prop="name">
<el-input v-model="account.name" />
</el-form-item>

<el-form-item label="密码" prop="password">
<el-input v-model="account.password" />
</el-form-item>
</el-form>
</div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
const account = reactive({
name: '',
password: ''
})
</script>

登录表单校验

这部分在element组件库有,在form表单组件右侧导航栏中有,仔细阅读人家的代码
校验规则一般写在el-form里面,给他添加属性和属性值,在ts中定义具体规则
校验规则到底该怎么写,模仿人家代码的格式,官方文档组件下面有很长规则代码
下面是我的账号密码表单组件和对应的校验规则,不难,要专心注意细节,官方文档一点一点读着写

登录表单校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<template>
<!-- tab下帐号登录的表单部分 -->
<div class="pane-account">
<!--label-width调整宽度 -->
<!-- :model="account"绑定表单,获取整个对象 -->
<el-form
:model="account"
:rules="accountRules"
label-width="60px"
size="large"
status-icon
ref="formRef"
>
<!--prop="name"目的:进行标识,方便校验规则找到对谁起作用 -->
<el-form-item label="帐号" prop="name">
<el-input v-model="account.name" />
</el-form-item>

<el-form-item label="密码" prop="password">
<el-input v-model="account.password" />
</el-form-item>
</el-form>
</div>
</template>

<script setup lang="ts" name="account">
import { reactive, ref } from 'vue'
//FormRules校验规则,需要导入
import type { FormRules, FormInstance, ElMessage } from 'element-plus'

// 定义accout数据
const account = reactive({
name: '',
password: ''
})
//定义校验规则,看官方文档,模仿人家的写法
//数组类型:多个校验规则,一个表单可以放多个
//required: true必须填,如果不填,那么trigger: 'blur',意思是失去焦点的时候触发
//记得要绑定到表单上,给表单添加属性:rules="accountRules"
const accountRules: FormRules = {
name: [
{ required: true, message: '必须输入帐号信息哦', trigger: 'blur' },
{ pattern: /^[a-z0-9]{6,20}$/, message: '帐号必须是6~20个字母或数字', trigger: 'blur' }
],
password: [
{ required: true, message: '必须输入密码哦', trigger: 'blur' },
{ pattern: /^[a-z0-9]{4,12}$/, message: '密码必须是4~12个字母或数字', trigger: 'blur' }
]
}
</script>

登录请求与表单校验

用户填写完帐号密码,点击登录,此时数据在pane-account组件中,而登录按钮在pane-account组件的父组件里
方法1:把子组件的数组传递给父组件(这里不用它)
方法2:父组件中的登录按钮触发后,传递给子组件,让子组件执行方法
所以在子组件中写登录逻辑,注意必须暴露出去,父组件才能触发它

难点代码: ref<InstanceType<typeof xxx>>()

const accountRef = ref<InstanceType<typeof PaneAccount>>()

这里一知半解的感觉,没关系,记住用法就可以
获取子组件的实例accountRef,注意要把它绑定到子组件上
不能直接ref<PanelAccount>,accountRef是由PanelAccount创建的实例对象
构造器返回值的实例
PaneAccoun相当于一个类

表单校验

在登录时,校验表单数据是否验证成功,组件库提供了相关方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

function loginAction(isKeep: boolean) {
console.log('当前账号数据:', account)

// 是否通过了验证,这个isValid就是表单校验的结果,组件提供给我们的
formRef.value?.validate((isValid) => {
console.log('表单校验结果:', isValid)
if (isValid) {
const name = account.name
const password = account.password
// 1.登录操作
loginStore.accountLoginAction({ name, password })

} else {
//如果用户点击提交时输入内容不符合规则,弹窗提示用户
ElMessage.warning({ message: '账号或密码输入的规则错误哦' })
}
})
}

表单校验warning弹窗

弹窗代码怎么用起来呢
ElMessage.warning({ message: '账号或密码输入的规则错误哦' })
注意它是写在ts里面,不是template里面,要先导入它
import { ElMessage } from 'element-plus'
此时还是用不了,因为它没有样式,要在main.ts引入样式文件
下面两行都能实现warning,但是第二种从哪儿找到有些难
import 'element-plus/dist/index.css'导入所有样式
import 'element-plus/theme-chalk/el-message.css'导入el-message样式

登录表单校验

网络请求与token保存

网络请求的东西直接写在组件下的ts业务逻辑里不好,一般要单独封装,所以在service文件夹下建login文件夹,里面放登录相关的网络请求

1
2
3
4
5
6
export function accountLoginRequest(account: any) {
return nieRequest.post({
url: '/login',
data: account
})
}

组件中调用函数

1
2
3
4
5
6
7
8
//发送登录请求的请求
import { accountLoginRequest } from '@/service/login/login'
const name = account.name
const password = account.password
accountLoginRequest({ name, password }).then((res) => {
console.log('res:', res)
})

这部分我卡了六个小时,最后发现是coderwhy拼成了codewhy,可笑的是我在接口软件和终端调试请求时,用户名写的都是coderwhy,回到登录页面,每次一写的都是codewhy,太不应该了阿,也算是深刻的教训了
不要再拼写错了

网络请求放仓库,保存登录状态的

在store/login建useLoginStore,把用户登录请求放到这里
在账号登录组件pane-account里导入仓库,把要用的方法取出来,在表单校验成功后调用仓库的方法实现登录请求,请求成功
网络请求放到登录仓库
登录成功,把返回的结果保存到仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const useLoginStore = defineStore('login', {
state: (): ILoginState => ({
token: '',
id: '',
name: ''
}),
actions: {
async accountLoginAction(account: {name:string, password:string}) {
//1.拿到登录信息发送请求
// 加上await不需要再写.then了
const loginRes = await accountLoginRequest(account)
console.log('登录请求放在登录仓库,loginRes:', loginRes)
//2.登录成功,把返回的结果保存到仓库
this.token = loginRes.data.data.token
this.id = loginRes.data.data.id
this.name = loginRes.data.data.name
console.log('token', this.token, this.id, this.name)
},
}
})

(开始我保存失败,因为我少了一个data.)
网络请求放到登录仓库

永久保存用户token

两种方式都可以,看需求
localCache.setCache('token', this.token)
localCache.setCache('token', this.token)
记得把仓库里的token:""也修改
token: localStorage.getItem('token') ?? '',
成功后如下图,开始刷新之后token为空,现在刷新完也有值
网络请求放到登录仓库

完善type文件定义数据类型

创建type.ts文件,放类型规则,这个文件可以放单独的type文件夹里,也可以发在要使用这些规则的功能文件夹下,放哪儿你写代码你来定

完善token,防止拼写错误(很多项目都这样做)

export const LOGIN_TOKEN = 'login/token'
import { LOGIN_TOKEN } from '@/types/constants'
localCache.setCache(LOGIN_TOKEN, this.token)

封装缓存工具类

  1. 定义 CacheType 枚举:区分两种缓存类型(local/session)
  2. 创建 NieCache 类:封装通用的缓存操作方法(set/get/delete/clear)
  3. 根据传入的 CacheType 决定使用 localStorage 还是 sessionStorage
  4. 提供 setCache / getCache / deleteCache / clearCache 方法
  5. 创建两个实例:localCache 和 sessionCache
  6. 导出这两个实例,供其他模块使用
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    enum CacheType {
    local = 'local',
    session = 'session'
    }
    //localStorage:数据永久保存,除非手动清除。
    //sessionStorage:数据只在当前会话有效,关闭浏览器后清除
    // NieCache它封装了对浏览器本地存储的操作
    class NieCache {
    storage: Storage

    //构造函数 constructor 接收一个参数 type(也就是上面定义的 CacheType)
    //根据这个参数决定使用哪种存储方式
    constructor(type: CacheType) {
    this.storage = type === CacheType.local ? localStorage : sessionStorage
    }

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

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

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

    clearCache() {
    this.storage.clear()
    }
    }
    //创建两个实例并导出
    const localCache = new NieCache(CacheType.local)
    const sessionCache = new NieCache(CacheType.session)

    export { localCache, sessionCache }

页面跳转

最简单:在store,actions后面添加router.push('/main')

导航守卫

router/index.ts添加导航守卫

1
2
3
4
5
6
7
8
9
10
11
12
// 导航守卫
// 参数: to(跳转到的位置)/from(从哪里跳转过来)
// 返回值: 返回值决定导航的路径(不返回或者返回undefined, 默认跳转)
// 举个栗子: / => /main
// to: /main from: / 返回值: /abc
router.beforeEach((to) => {
// 只有登录成功(token), 才能真正进入到main页面
const token = localCache.getCache(LOGIN_TOKEN)
if (to.path.startsWith('/main') && !token) {
return '/login'
}
})

添加记住密码功能

  1. 理解:
    如果用户在登录时勾选“记住密码”,那么应该把用户输入的帐号密码记住,下次用户回到登录页面自动填充账号和密码

    现在浏览器右侧提示的记住账号和密码是浏览器本身的功能,不是网站本身的功能

  2. 思路:
    在登录组件中通过isRemPwd变量的值确定是否勾选记住密码,把这个值传递给帐号登录子组件的loginAction方法中,子组件方法接受,判断是否保存,如果不记住密码要把,同时修改默认的帐号密码定义

  3. 关键代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    //登录页组件传值给子组件
    const isRemPwd = ref(false)
    accountRef.value?.loginAction(isRemPwd.value)
    //子组件接受
    function loginAction(isKeep: boolean) {
    console.log('子组件收到的参数,是否保存:', isKeep)
    //判断是否要记住密码
    if (isKeep) {
    localCache.setCache('name', name)
    localCache.setCache('password', password)
    } else {
    localCache.deleteCache('name')
    localCache.deleteCache('password')
    }
    //记住密码--如果cache里有保存用户的数据,用用户的数据填空,否则再用''
    // ?? 非空断言
    const account = reactive({
    name: localCache.getCache('name') ?? '',
    password: localCache.getCache('password') ?? ''
    })

    }

    选择记住密码后重新回到login页面,页面自动填写帐号密码
    记住密码成功

进阶:记住密码按钮状态保持

如果用户上一次登录勾选了“记住密码”那么下一次进入登录页面,“记住密码”选项默认被勾选,如果用户上一次登录没有勾选“记住密码”,那么下一次进入登录页默认不勾选

1
2
3
4
const isRemPwd = ref<boolean>(localCache.getCache('isRemPwd') ?? false)
watch(isRemPwd, (newValue) => {
localCache.setCache('isRemPwd', newValue)
})

用户权限管理与缓存(基于用户的访问控制)

获取用户权限

根据登录用户不同,呈现不同的后台管理系统内容(具备不同的操作权限)
目前用户登录后,从登录接口能拿到token,用户id,用户name
再根据id,发送请求获取用户详情(用户角色等详细信息)
查看接口文档,会发现这个操作需要授权(要有token)
应该把token放到header里,可以在请求时写

1
2
3
4
5
6
7
8
export function getUserDetailById(id: number) { 
return HyRequest.get({
url: `/users/'${id}`,
headers: {
Authorization: localCache.getCache(LOGIN_TOKEN)
}
})
}

但是开发中我们希望每个接口发送请求 都携带token,每次都单独放一个太重复了这时候要放到拦截器里帮我们在请求中携带,在每个请求的config里都帮我们携带

用拦截器给每个请求携带token

(拦截器使用老师封装好的代码,我只进行了使用)

  1. 流程理解:
    用户登录成功后,后端返回token
    token被存储在本地缓存中(使用 localCache.setCache(LOGIN_TOKEN, token) )
    之后的每次请求,这个拦截器都会自动运行
    拦截器从缓存中获取token,并添加到请求头中
    服务器接收到带有token的请求,验证用户身份

这种方式的优点:
自动化:不需要在每个请求中手动添加token
统一管理:所有的token处理逻辑都在一个地方
类型安全:使用TypeScript确保类型安全
可维护性:易于修改和维护token的处理逻辑
2. 拦截器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const hyRequest = new HYRequest({
baseURL: BASE_URL,
//如果请求超过这个时间还没有响应,就会自动失败
timeout: TIME_OUT,
//interceptors拦截器配置
interceptors: {
//requestSuccessFn是一个请求成功的拦截器函数,
//它的作用是在每个请求发送之前自动添加认证头token
requestSuccessFn: (config) => {
// 每一个请求都自动携带token
const token = localCache.getCache(LOGIN_TOKEN)
//如果请求头存在且token也存在,就将token添加到请求头中
if (config.headers && token) {
// 类型缩小,确保每个里面都有值
//使用 Bearer 认证方式,这是一种常见的JSON Web Token认证方式
config.headers.Authorization = 'Bearer ' + token
}
return config
}
}
})

获取coderwhy的用户信息成功
获取用户信息成功

如何指定state类型

使用泛型+箭头函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface ILoginState {
token: string
userInfo: any
userMenus: any
}

const useLoginStore = defineStore('login', {
// 如何制定state的类型
state: (): ILoginState => ({
token: '',
userInfo: {},
userMenus: []
}),
...

获取用户菜单信息

类似获取用户详细信息
先根据用户id获取用户详细信息,从用户详细信息中得到用户角色,再根据用户角色获取该角色的菜单信息(根据角色获取权限)

1
2
3
4
5
6
// 5.根据role的id获取菜单
const roleId = userInfo.data.role.id
const userMenusResult = await getRoleMenus(roleId)
const userMenus = userMenusResult.data
this.userMenus = userMenus
console.log('userMenus', userMenus)

获取用户菜单信息成功

本地缓存个人信息和菜单信息

放到store中的数据是一种内存缓存,刷新会消失,期望一直存在需要进行本地缓存
store/login.ts

1
2
3
4
5
6
//6. 保存结果到本地缓存
//下面这一行要放在上面,因为下面两个请求需要token要,不能为了好看把这个也放下去
localCache.setCache(LOGIN_TOKEN, this.token)

localCache.setCache('useInfo', this.userInfo)
localCache.setCache('userMenus', userMenus)

同时修改state中对数据的定义

1
2
3
4
5
6
state: (): ILoginState => ({
//进行本地缓存后,改为这样
token: localStorage.getItem('token') ?? '',
userInfo: localCache.getCache('userInfo') ?? {},
userMenus: localCache.getCache('userMenus') ?? []
}),

本地缓存信息成功
本地缓存信息成功

store为什么看不见呢(细节)

在main页面中,明明之前保存了但是开始看不到store,为什么呢
原因是刚开始开发这个页面,还没写ts,而pinia中有很多个store,在你没有使用的时候它不会出现,需要在mian页面使用(导入拿到)一下它,这样就能看到了

1
2
3
4
import useLoginStore from '@/store/login/login'
const loginStore = useLoginStore()
//获取菜单,同时使用loginStore了,现在页面的pinia里有菜单的数据
const userMenus = loginStore.userMenus

首页

首页布局

先把结构搭出来,在和后端给的数据做对比,如果返回数据很乱,用一个映射的工具函数把很乱的数据处理成自己想要的格式,再把数据放进界面展示

搭架子

用element-ui的布局组件,(Basic基础组件/布局容器/常见布局页面
组件选取
在main.vue先搭架子,然后放组件

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="main">
<el-container class="main-content">
<el-aside width="200px">Aside</el-aside>
<el-container>
<el-header>Header</el-header>
<el-main>Main</el-main>
</el-container>
</el-container>
</div>
</template>
//放组件

首页–左侧菜单栏布局

  1. 选取组件
    element-ui的导航组件(导航/菜单/侧栏)
    注意复制的代码是el-menu部分,没用到的爆红的先删掉,用到再加
    菜单栏选取
    理解菜单组件代码
    菜单栏选取
  2. 先手动搭建目标菜单
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    <div class="menu">
    <el-menu text-color="#b7bdc3" active-text-color="#fff" background-color="#001529">
    <!-- 1.系统总览 -->
    <el-sub-menu>
    <template #title>
    <el-icon><Monitor /></el-icon>
    <span>系统总览</span>
    </template>
    <el-menu-item>核心技术</el-menu-item>
    <el-menu-item>商品统计</el-menu-item>
    </el-sub-menu>
    <!-- 2.系统管理 -->

    <el-sub-menu>
    <template #title>
    <el-icon><Setting /></el-icon>
    <span>系统管理</span>
    </template>
    <el-menu-item>用户管理</el-menu-item>
    <el-menu-item>部门管理</el-menu-item>
    <el-menu-item>菜单管理</el-menu-item>
    <el-menu-item>角色管理</el-menu-item>
    </el-sub-menu>

    <!-- 3.商品中心 -->
    <el-sub-menu>
    <template #title>
    <el-icon><ShoppingBag /></el-icon>
    <span>商品中心</span>
    </template>
    <el-menu-item>商品类别</el-menu-item>
    <el-menu-item>商品信息</el-menu-item>
    </el-sub-menu>

    <!-- 4.随便聊聊 -->
    <el-sub-menu>
    <template #title>
    <el-icon><Monitor /></el-icon>
    <span>随便聊聊</span>
    </template>
    <el-menu-item>你的故事</el-menu-item>
    <el-menu-item>故事列表</el-menu-item>
    </el-sub-menu>
    </el-menu>
    </div>
  3. 动态获取菜单–获取菜单名
    在这里我卡了会儿,原因是没有仔细看数据格式
    应该是v-for="item in userMenus.data",开始写成了v-for="item in userMenus",少了.data
    动态获取菜单名
  4. 动态组件展示图标
    1
    2
    3
    4
     <!-- 字符串: el-icon-monitor => 组件 component动态组件 -->
    <el-icon>
    <component :is="item.icon.split('-icon-')[1]" />
    </el-icon>
    动态获取菜单名
  5. 完整菜单代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <!-- 菜单栏内容 -->
    <div class="menu">
    <el-menu
    default-active="3"
    :collapse="isFold"
    text-color="#b7bdc3"
    active-text-color="#fff"
    background-color="#001529"
    >
    <!-- 动态获取菜单:先遍历整个菜单,用id作为Key, -->
    <template v-for="item in userMenus.data" :key="item.id">
    <!-- 加上 :index="item.id + ''" 这样就知道要展开哪个,不会全部展开了 -->
    <el-sub-menu :index="item.id + ''">
    <template #title>
    <!-- 字符串: el-icon-monitor => 组件 component动态组件 -->
    <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.path">
    {{ subitem.name }}
    </el-menu-item>
    </template>
    </el-sub-menu>
    </template>
    </el-menu>

首页–头部布局

菜单栏折叠功能搭建

面包屑

  1. 选取控制折叠组件
    最开始的状态:两个动态的图标都展示
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <template>
    <div class="main-header">
    <!-- 首页头部面包屑 -->
    <div class="menu-icon" @click="handleMenuIconClick">
    <!-- 希望展开状态下展示第一个,折叠状态展示第二个 -->
    <el-icon size="28px"><Fold /></el-icon>
    <el-icon size="28px"><Expand /> </el-icon>
    </div>
    <div class="content">
    <div class="breadcrumb">面包屑,左侧两个图标随切换展示</div>
    <header-info />
    </div>
    </div>
    </template>
  2. 动态组件切换折叠状态
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <template>
    <div class="main-header">
    <!-- 首页头部面包屑 -->
    <div class="menu-icon" @click="handleMenuIconClick">
    <el-icon size="28px">
    <component :is="isFold ? 'Expand' : 'Fold'" />
    </el-icon>
    </div>
    <div class="content">
    <div class="breadcrumb">面包屑</div>
    <header-info />
    </div>
    </div>
    </template>
    1
    2
    3
    4
    5
    6
    mport { ref } from 'vue'
    const isFold = ref(false)
    function handleMenuIconClick() {
    //切换是否折叠状态isFold,配合动态组件使用
    isFold.value = !isFold.value
    }
    切换是否折叠状态isFold
    面包屑

折叠菜单切换(组件通信)

通过面包屑的点击来切换左侧菜单栏是否折叠,折叠要改变两部分:菜单栏内容和菜单栏宽度

  1. 菜单栏折叠
    在element-ui的菜单组件中,有规定通过属性collapse的true/false来控制菜单栏是否折叠
  2. 让父组件改变宽度
    左侧菜单栏和顶部面包屑都是父组件main.vue的子组件
    子组件main-header内部改变,要把事件和状态传递给父组件
    父组件接受子组件的事件和状态,改变父组件中设置宽度的值<el-aside :width="isFold ? '60px' : '210px'">

关键代码

  1. 让兄弟组件改变折叠状态
    父组件把是否折叠传递给菜单子组件,决定菜单是否折叠,文字是否显示
    <main-menu :is-fold="isFold" ></main-menu>
    <h2 class="title" v-show="!isFold">聂聂管理系统</h2>
    1
    2
    3
    4
    5
    6
    7
    //接受父组件传递的数组决定是否折叠菜单
    defineProps({
    isFold: {
    type: Boolean,
    default: false
    }
    })
    菜单折叠与否展示

头部内容

从组件库找对应的组件一点点搭建
菜单折叠与否展示

style中子组件的根元素可以被父组件直接选中,不需要加deep;子组件背包包裹的其他父组件不能选中

路由配置(有缺陷方法)

  1. 先创建页面,再根据路径和关系配路由
    在哪里创建动态路由的文件,怎么命名,看后端返回的数据命名
    我这里先在views/main下建analysis文件夹和system文件夹,下面放main页面标签里对应展示的页面
  2. 再在router里给所有页面配置路由
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    {
    path: '/main',
    component: () => import('../views/main/main.vue'),
    children: [
    {
    path: '/main/analysis/overview',
    component: () =>
    import('../views/main/analysis/overview/overview.vue')
    },
    {
    path: '/main/analysis/dashboard',
    component: () =>
    import('../views/main/analysis/dashboard/dashboard.vue')
    },
    {
    path: '/main/system/user',
    component: () => import('../views/main/system/user/user.vue')
    },
    {
    path: '/main/system/role',
    component: () => import('../views/main/system/role/role.vue')
    }
    ]
    },

弊端

这样实现在地址栏输入地址即可跳转,让本不该有权限能访问对应页面的人也能够根据地址跳转

那么如果没有权限的人进入页面,他能看到数据吗
应该是需要用token验证才能展示数据,看后端是否会验证
但是作为前端,不能取决于后端是否验证,开始就不该让用户进去

菜单点击实现路由跳转(重要)

在左侧导航菜单中,根据点击的子菜单不同,获取到不同子菜单的url,直接用router.push跳转

1
2
3
4
5
6
<!-- 添加@click="handleItemClick(subitem)点击实现路由跳转 -->
<template v-for="subitem in item.children" :key="subitem.id">
<el-menu-item :index="subitem.id + ''" @click="handleItemClick(subitem)">
{{ subitem.name }}
</el-menu-item>
</template>
1
2
3
4
function handleItemClick(item: any) {
const url = item.url
router.push(url)
}

动态路由(重难点)

根据用户角色的权限信息,动态地添加路由
登录的接口中请求三个内容:token,用户信息(包含角色信息role对象),菜单信息

基于角色的动态路由管理

  1. 用一个枚举类型包含所有的路由
    1
    2
    3
    4
    5
    consy roles  = { 
    "superAdmin":[所有的路由].=>router.main.children,
    "admin":[一部分路由].=>router.main.children,
    "sericer":[少部分路由].=>router.main.children
    }
  2. 弊端
    每增加一个角色类型,都要增加一个key/value改变代码(前端来做修改代码需要重新发布,后端动态添加需要组织好json数据,后端很难写),所以做的很难,用的少

基于菜单的动态路由管理(重要)

  1. 思路:根据userMenus包含的菜单,添加到路由里,映射成路由
  2. 先创建文件
    根据后端返回的菜单地址数据的结构,建立文件夹,和views中的文件嵌套关系保持一致:在router/main文件夹中创建analysis文件夹里 包含若干文件夹和文件,都是可能的每一个路由

比如dashboard.ts文件

1
2
3
4
export default {
path: '/main/analysis/dashboard',
component: () => import('@/views/main/analysis/dashboard/dashboard.vue')
}

goods.ts文件

1
2
3
4
5
6
7
const goods = () => import('@/views/main/product/goods/goods.vue')
export default {
path: '/main/product/goods',
name: 'goods',
component: goods,
children: []
}

文件创建

也可以用coderwhy老师的自动添加页面工具生成(目前没明白)
建文件位置对应好方便后续写一些自动化工具
npm instiall coderwhy -g
coderwhy ad3page_setup departent -d ser/views/main/system/department
coderwhy ad3page_setup category -d ser/views/main/system/category
coderwhy ad3page_setup chat -d ser/views/main/story.chat

  1. 根据菜单,动态地添加路由对象(独立的文件中)
  • 从store获取用户菜单信息userMenus(在store/login/login.ts中)
  • 动态获取路由对象,放到数组中
    路由对象都在独立的文件中
    从文件中将所有路由对象先读取到数组中
  • 根据菜单去匹配正确的路由加到main路由里
    router.addRoute(‘main’,xxx)
  1. 封装map-menus文件,菜单映射路由
    在utils文件夹创建文件,负责把用户菜单里的路径映射成路由
    最终store/login/login.ts中调用文件中的方法,传递用户菜单过去,返回路由,再把映射回来的路由添加到main路由里
    1
    2
    3
    //动态添加路由,这里开始卡住,因为传的少了.data
    const routes = mapMenusToRoutes(userMenus.data)
    routes.forEach((route) => router.addRoute('main', route))

    这里我犯了个错,开始进入main页面无法动态加载路由,控制台报警告。原因是我的router中没有给main路由配置name属性,导致动态添加的路由找不到main无法添加成为main的子路由
    Vue Router 在动态添加路由时,需要通过 name 属性来识别父路由
    router.addRoute(‘main’, route) 中的 ‘main’ 参数需要与父路由的 name 属性匹配
    动态添加路由对象成功

动态路由功能成功,点击菜单可以调换到对应路由页面
点击菜单可以调换到对应路由页面

解决刷新页面路由丢失问题

刷新main页面时应该保存动态路由的状态
问题如下图,此时虽然完成了基于菜单的动态路由管理,但是用户登录后刷新页面,路由会丢失,router里只剩下默认的main,login,404路由
解决刷新页面路由丢失问题
关键代码:

1
2
const routes = mapMenusToRoutes(userMenus.data)
routes.forEach((route) => router.addRoute('main', route))

原因是:目前这段动态添加路由的代码只会在用户登录时被执行
解决思路:让用户刷新页面时也执行一遍动态添加路由的代码
不光登录时要映射一次动态路由,刷新的时候也要映射一次
还是在store/login/login.ts中添加

用户在进行刷新时,判断用户是否登录,是否拥有菜单
再细一点,如果用户在login页面刷新不需要判断,在main页面刷新要判断
继续理解,如果用户要从cache里读取userMenus时,也要动态加载路由(而不只是登录 时动态加载)

这里代码一点点写完了自己检查也没问题,但功能实现不了,拜托AI老师帮我检查代码,发现use,user拼写错误,说明用常量声明key真的很重要
拼写错误

改着改着userMenus的数据又出问题了,对我来说最喜欢的解决办法是都在控制台答应出来然后一个个检查改,这里问AI反而没解决,我觉得是因为他不知道后端给我传的数据是什么样子的
而且我不该这样起名,仓库里有userMenu,函数中还用userMenu接受.data后的数据,后续代码多了发现自己会乱掉,直接改命名为userMenuResData

1
2
3
4
5
6
7
//检查数据
const roleId = userInfo.data.role.id
const userMenusResult = await getRoleMenus(roleId)
console.log('userMenusResult', userMenusResult)
const userMenus = userMenusResult.data
console.log('userMenus( userMenusResult.data)', userMenus)
console.log('userMenus.data( userMenusResult.data.data)', userMenus.data)

.data问题
成功解决(详细的注释在代码里score/login.ts):

1
2
3
4
5
6
7
8
const roleId = userInfo.data.role.id
const userMenusResult = await getRoleMenus(roleId)
const userMenusData = userMenusResult.data.data
console.log('userMenusData', userMenusData)//这个是期望保存的数据
this.userMenus = userMenusData

localCache.setCache('userInfo', this.userInfo)
localCache.setCache('userMenus', this.userMenus)

下图.data问题和刷新重新加载路由解决:
.data问题和刷新重新加载路由解决
因为这里的userMenus比之前多加了层.data,所以在左侧菜单列表中脱一层.data
template v-for="item in userMenus.data" :key="item.id">
变为template v-for="item in userMenus" :key="item.id">

动态路由第一次进入main页面自动匹配子路由(听着容易做很难)

登录成功时,要给用户展示一个页面,第一次进入的页面应该是动态路由注册的所有页面中的第一个页面(不同用户拥有的路由不一样,所以第一个页面也不一样)
再梳理一下思路

  1. 当用户登录成功后,后端会返回该用户的权限菜单列表
  2. mapMenusToRoutes 函数会:
  • 加载本地所有路由配置
  • 将用户菜单与本地路由进行匹配
  • 记录第一个匹配到的菜单项到 firstMenu
  • 为一级菜单添加重定向到其第一个子菜单
  • 添加所有匹配的二级菜单路由
  1. 当用户访问 /main 路径时
  • 路由守卫会检查到路径是 /main
  • 自动重定向到 firstMenu.url (第一个匹配到的菜单路径)
  • 这样就实现了进入主页面时自动跳转到第一个子菜单页面
  1. 这样的设计可以:
    确保用户总能看到一个有效的页面内容
    避免 /main 路径下显示空白页面
    提供更好的用户体验

封装方法在utils/map-menus-to-routes.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// firstMenu记录第一个被匹配到的菜单项
// 用于实现进入 /main 路径时的自动重定向
export let firstMenu: any = null
//userMenus是用户的菜单列表数组
export function mapMenusToRoutes(userMenus: any[]) {
// 1.加载本地路由
const localRoutes = loadLocalRoutes()

// 2.根据菜单去匹配正确的路由
const routes: RouteRecordRaw[] = []
for (const menu of userMenus) {
for (const submenu of menu.children) {
//用户菜单中的url和本地路由的path进行匹配,如果本地路由地址和用户菜单地址一样
//那么就把这个菜单对应的路由添加到数组routes中
const route = localRoutes.find((item) => item.path === submenu.url)
//如果用户菜单中的 URL 和本地路由的 path 相匹配
if (route) {
// 1.给route的顶层菜单增加重定向功能(但是只需要添加一次即可)
//检查是否已经为当前一级菜单设置了重定向,如果没有设置过,就添加一个重定向配置
//一级菜单重定向
if (!routes.find((item) => item.path === menu.url)) {
routes.push({ path: menu.url, redirect: route.path })
}
// 添加二级菜单路由
//直接将匹配到的路由配置添加到路由数组中
routes.push(route)
}
// 记录第一个被匹配到的菜单,这个 firstMenu 会用于进入 /main 路径时的自动重定向
//如果 firstMenu 还没有值,并且找到了匹配的路由,就把当前的子菜单记录为第一个菜单
if (!firstMenu && route) firstMenu = submenu
}
}
return routes
}

在router/index.ts中添加

1
2
3
4
5
6
import { firstMenu } from '@/utils/map-menus'
// 如果是进入到main,如果firstMenu 存在
// 返回firstMenu的url属性作为重定向目标,自动跳转到第一个子菜单页面
if (to.path === '/main') {
return firstMenu?.url
}

上述都完成后,进入main页面刷新后样式:
当前页面样式

main页面刷新–根据路径匹配menu(停留在刷新前页面+子菜单)

  1. 理解:
    刚才完成了第一次进入main页面进入的是用户第一个匹配到的菜单路由,但是用户在页面点击刷新,希望刷新之后显示的依旧是刷新时的路由页面,而不是默认的firstMenu
  2. 思路:根据路径匹配菜单
    根据刷新前的url路径去匹配对应的菜单,这个方法mapPathToMenu()还是封装在utils/map-menus-to-routes.ts中,在main-menu.vue中调用
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    /**
    * 根据路径去匹配需要显示的菜单
    * @param path 需要匹配的路径
    * @param userMenus 所有的菜单
    */
    export function mapPathToMenu(path: string, userMenus: any[]) {
    for (const menu of userMenus) {
    for (const submenu of menu.children) {
    //子菜单的路径等于要匹配的路径,返回这个子菜单给main-menu.vue
    if (submenu.url === path) {
    return submenu
    }
    }
    }
    }
    在main-menu.vue中调用:
    1
    2
    3
    4
    5
    6
    7
    8
    // 3.ElMenu的默认菜单
    const route = useRoute()
    const defaultActive = computed(() => {
    const pathMenu = mapPathToMenu(route.path, userMenus)
    console.log('pathMenu', pathMenu)
    console.log("pathMenu.id + ''", pathMenu.id + '') //返回一个数字字符串,给菜单的:default-active="defaultActive"和
    return pathMenu.id + ''
    })
    这里才意识到,之前到现在我的被选中的菜单栏子菜单一直没有高亮,原因是:index属性的属性值错误
    1
    2
    3
    4
    <!-- <el-menu-item :index="subitem.path" @click="handleItemClick(subitem)"> -->
    <!-- 被选中子菜单不高亮展示的原因:上边错误代码,下面正确代码 -->
    <el-menu-item :index="subitem.id + ''" @click="handleItemClick(subitem)">

    .data问题和刷新重新加载路由解决

面包屑实现

上面布局的时候用了组件占了放面包屑的位置,现在具体实现
element-ui/导航/面包屑组件,我选的“图标分隔符”的面包屑,单独封装一个组件(main-header/c-cpns/header-crumb.vue),放进之前给面包屑留位置的div里

面包屑需要拿到当前子菜单和它父级的名字,在utils/map-menus.ts中封装方法返回面包屑需要的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

interface IBreadcrumbs {
name: string
path: string
}
export function mapPathToBreadcrumbs(path: string, userMenus: any[]) {
// 1.定义面包屑
const breadcrumbs: IBreadcrumbs[] = []

// 2.遍历获取面包屑层级
for (const menu of userMenus) {
for (const submenu of menu.children) {
if (submenu.url === path) {
// 面包屑里先加父级菜单
breadcrumbs.push({ name: menu.name, path: menu.url })
// 再加子菜单
breadcrumbs.push({ name: submenu.name, path: submenu.url })
}
}
}
return breadcrumbs
}

在面包屑组件中调用

1
2
const breadcrumbs = mapPathToBreadcrumbs(route.path, userMenus)
})

路径怎么办呢,看官方文档
面包屑路径

1
2
3
4
5
6
7
8
9
10
11
<div class="curmb">
<!--el-breadcrumb最外层包裹面包屑的di -->
<el-breadcrumb separator-icon="CaretRight">
<template v-for="item in breadcrumbs" :key="item.name">
<!-- 每一节小面包 -->
<el-breadcrumb-item :to="item.path">
{{ item.name }}
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>

面包屑实时更新(用计算属性)

现在完成的面包屑是静态的(只获取了一次地址),在切换菜单路径变化后没有重新调用方法更新面包屑
这时候要用计算属性,当里面依赖的数据改变时,会自动刷新调用方法

1
2
3
const breadcrumbs = computed(() => {
return mapPathToBreadcrumbs(route.path, userMenus)
})

面包屑路径

点击父菜单跳转完善(仔细理解)

比如上图,在点击父分类“随便聊聊”时,找到的是404页面,因为父路径没有东西
解决方法:只要点击父路径都进入到里面的第一个子菜单
在map-menys.ts封装菜单转路由的方法中完善

1
2
3
4
5
6
7
8
9
10
11
if (route) {
// 1.给route的顶层菜单增加重定向功能(但是只需要添加一次即可)
//检查是否已经为当前一级菜单设置了重定向,如果没有设置过,就添加一个重定向配置
//一级菜单重定向
if (!routes.find((item) => item.path === menu.url)) {
routes.push({ path: menu.url, redirect: route.path })
}
// 添加二级菜单路由
//直接将匹配到的路由配置添加到路由数组中
routes.push(route)
}

在main-menu.vue中,也把获取菜单的方法变成计算属性

1
2
3
4
5
6
7
const route = useRoute()
const defaultActive = computed(() => {
const pathMenu = mapPathToMenu(route.path, userMenus)
console.log('pathMenu', pathMenu)
console.log("pathMenu.id + ''", pathMenu.id + '') //返回一个数字字符串
return pathMenu.id + ''
})

用户管理页面

查询信息功能部分

查询布局–element布局

放在user文件夹/c-cpns文件夹/user-search.vue中
使用组件/layout布局,看下图理解
layout布局
查询部分表单如下:
这里写一行20,但是前三个8已经占满了一行,那么后面的几个8会自动另起一行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<el-form :model="searchForm" ref="formRef" label-width="80px" size="large">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="用户名" >
<el-input v-model="searchForm.name" placeholder="请输入查询的用户名" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="真实姓名" >
<el-input v-model="searchForm.realname" placeholder="请输入查询的真实姓名" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="手机号码" >
<el-input v-model="searchForm.cellphone" placeholder="请输入查询的手机号码" />
</el-form-item>
</el-col>

<el-col :span="8">
<el-form-item label="状态" >
<el-select
v-model="searchForm.enable"
placeholder="请选择查询的状态"
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="创建时间" >
<!--type="daterange" 显示时间范围,有它选两个日期没它选一个-->
<el-date-picker
v-model="searchForm.createAt"
type="daterange"
range-separator="-"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>

查询和重置按钮布局

放在user文件夹/c-cpns文件夹/user-search.vue中
组件/Button按钮和icon小组件一起用

1
2
3
4
<div class="btns">
<el-button icon="Refresh" @click="handleResetClick">重置</el-button>
<el-button icon="Search" type="primary" @click="handleQueryClick">查询</el-button>
</div>

样式慢慢改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.search {
background-color: #fff;
padding: 20px;

.el-form-item {
padding: 20px 30px;
margin-bottom: 0;
}

.btns {
text-align: right;
padding: 0 50px 10px 0;
.el-button--primary {
--el-button-bg-color: rgb(98, 106, 239);
}
.el-button {
height: 36px;
}
}
}

layout布局

重置功能实现

在表单 组件中给每一个子表单添加prop属性,和searchForm中的名称对应,方便重置找到属性,比如:

1
2
3
4
5
6
7
8
9
10
<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="cellphone">
<el-input v-model="searchForm.cellphone" placeholder="请输入查询的手机号码" />
</el-form-item>
</el-col>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { reactive, ref } from 'vue'
import type { ElForm } from 'element-plus'

const searchForm = reactive({
name: '',
realname: '',
cellphone: '',
enable: 1,
createAt: []
})

// 重置操作
const formRef = ref<InstanceType<typeof ElForm>>()
function handleResetClick() {
formRef.value?.resetFields()
}
//查询操作(还没接后端接口)
function handleQueryClick() {
console.log('handleQueryClick')
}

element组件内容中文化

在使用时间选择组件时,月份星期显示都为英文,想要把它改成中文
element-plus组件显示的英文变中文:
右上角指南/左侧进阶/国际化,我用的是下图代码
改为中文
在App.vue中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import router from './router'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<el-config-provider :locale="zhCn">
<div class="app">
Hello小聂同学
<!-- <router-link to="/main">去main页面</router-link>-->
<router-link to="/login">去login页面</router-link>
<router-view></router-view>
</div>
</el-config-provider>
</template>

成功,现在组件中默认英文的地方都变成了中文

用户信息功能部分

写在user文件夹/c-cpns文件夹/user-content.vue中
这里不光要展示服务器的用户列表,还会展示上面自查询返回的用户数据,所以不再是简单的数据,用Post请求

获取用户列表

  1. 用户信息展示分为:
    头部(说明列表,添加用户按钮+功能)
    中间(展示用户数据列表)
    底部(分页)
  2. 发送数据请求
    发送数据请求放在store/main/system/system.ts中,组件中调用
    先看看是否能拿到数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import { postUsersListData } from '@/service/main/system/system'
    import { defineStore } from 'pinia'
    import type { ISystemState } from './type'

    const useSystemStore = defineStore('system', {
    state: (): ISystemState => ({
    usersList: [],
    }),
    actions: {
    async postUsersListAction() {
    const usersListResult = await postUsersListData()
    console.log(usersListResult)
    }
    }
    })

    export default useSystemStore
    组件中调用,获取数据
    1
    2
    3
    4
    import useSystemStore from '@/store/main/system/system'
    // 1.调用仓库的查询数据请求,发起action,请求usersList的数据
    const systemStore = useSystemStore()
    systemStore.postUsersListAction()
    看控制台,成功拿到用户数据
    获取数据成功

表格展示数据

把获取到的数据展示到页面列表中

  1. 数据类型规范
    控制台看到的后端数据是json格式,要把它转换成ts数据,还是用到之前的json to ts转换网址(直接搜就能用)
    把转化后的的单独放到store/main/system/types.ts中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    export interface IUser {
    id: number
    name: string
    realname: string
    cellphone: number
    enable: number
    departmentId: number
    roleId: number
    createAt: string
    updateAt: string
    }

    export interface ISystemState {
    usersList: IUser[]
    usersTotalCount: number
    }

  2. 把数据放进仓库
    store/main/system/system.ts中,给刚获取到的数据.data再.data,控制台看,把目标数据存到仓库state里
    数据的规范使用刚才type.ts文件中的数据类型
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import { postUsersListData } from '@/service/main/system/system'
    import { defineStore } from 'pinia'
    import type { ISystemState } from './type'//类型规范

    const useSystemStore = defineStore('system', {
    state: (): ISystemState => ({
    usersList: [],
    usersTotalCount: 0
    }),
    actions: {
    async postUsersListAction() {
    const usersListResult = await postUsersListData()
    //console.log(usersListResult)
    // console.log('usersListResult.data', usersListResult.data)
    // console.log(usersListResult.data.data)
    //console.log(usersListResult.data.data.list)
    const { totalCount, list } = usersListResult.data.data
    this.usersTotalCount = totalCount
    this.usersList = list
    //console.log(this.usersTotalCount)
    }
    }
    })

    export default useSystemStore
  3. 组件展示数据
    user-content.vue中获取把仓库的数据放到表格中
    表格还是用组件element-plus/Datas数据展示/Table表格/带边框表格
    仔细读代码:
    组件表格中的数据使用:data="tableData"填充,用prop属性和data中的数据绑定,width属性指定这一列的宽度,如果没有规定宽度,那么没有规定宽度的列会平均分配剩下的宽度
    布置好组件表格:data="usersList",ts中const { usersList } = storeToRefs(systemStore)展示数据
  4. 添加多选框列和序号列
    多选功能在组件库中也有,还是Table表格/多选,把代表多选框的列拿进代码
    <el-table-column type="selection" width="50px" />
    序号列也从组件库找,找有序号的表格,把列那一行放进来<el-table-column type="index" label="序号" width="60px" />
  5. 表格最后一列的删除和编辑按钮
    组件/button按钮,两个按钮放进最后一列
    1
    2
    3
    4
    <el-table-column align="center" label="操作" width="150px">
    <el-button size="small" icon="Edit" type="primary" text> 编辑 </el-button>
    <el-button size="small" icon="Delete" type="danger" text> 删除 </el-button>
    </el-table-column>
  6. 目前完整代码展示
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    <template>
    <div class="content">
    <div class="header">
    <h3 class="title">用户列表</h3>
    <el-button type="primary">新建用户</el-button>
    </div>
    <div class="table">
    <el-table :data="usersList" border style="width: 100%">
    <el-table-column align="center" type="selection" width="50px" />
    <el-table-column align="center" type="index" label="序号" width="60px" />

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

    <el-table-column align="center" label="创建时间" prop="createAt" />
    <el-table-column align="center" label="更新时间" prop="updateAt" />

    <el-table-column align="center" label="操作" width="150px">
    <el-button size="small" icon="Edit" type="primary" text> 编辑 </el-button>
    <el-button size="small" icon="Delete" type="danger" text> 删除 </el-button>
    </el-table-column>
    </el-table>
    </div>
    <div class="pagination">分页</div>
    </div>
    </template>
    <script setup lang="ts">
    import { storeToRefs } from 'pinia'
    import useSystemStore from '@/store/main/system/system'

    // 1.调用仓库的查询数据请求,发起action,请求usersList的数据
    const systemStore = useSystemStore()
    systemStore.postUsersListAction()

    // 2.获取usersList数据,进行展示
    const { usersList } = storeToRefs(systemStore)
    </script>
    完成,效果如下图
    用户信息展示

处理数据–状态列

根据后端返回的值自定义展示数据:
比如后端返回的状态是1或0,期望状态列是具体的汉字表述而不是数字,那么要使用插槽自定义展示数据
先拿到数据然后用作用域插槽把数据放进去

1
2
3
4
5
6
7
8
9
10
11
<el-table-column align="center" label="状态" prop="enable" width="100px">
<!-- 作用域插槽,原来只有上面,下面新增 -->
<template #default="scope">
<el-button
size="small"
:type="scope.row.enable ? 'primary' : 'danger'"
plain
>
{{ scope.row.enable ? '启用' : '禁用' }}
</el-button>
</template>

处理数据–时间列

时间格式化进行展示
Day.js库 pnpm add dayjs
utils文件夹建format.ts文件,定义时间格式化方法

1
2
3
4
5
6
7
8
9
10
11
12
13
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
// 上面这两行导入虽然爆红,但是不影响,时间已经被格式化了
dayjs.extend(utc)

export function formatUTC(utcString: string, format: string = 'YYYY/MM/DD HH:mm:ss') {
// 默认格式化后的时间是零时区,东八区要.utcOffset(8)加八个小时
//也可以在形参上不规定 format: string = 'YYYY/MM/DD HH:mm:ss'
//在下面.format('YYYY/MM/DD HH:mm:ss')
//现在的写法更优雅,上面注释掉的更顺着思路
const resultTime = dayjs.utc(utcString).utcOffset(8).format(format)
return resultTime
}

组件中导入import { formatUTC } from '@/utils/format'并使用

1
2
3
4
5
6
7
8
9
10
<el-table-column align="center" label="创建时间" prop="createAt">
<template #default="scope">
{{ formatUTC(scope.row.createAt) }}
</template>
</el-table-column>
<el-table-column align="center" label="更新时间" prop="updateAt">
<template #default="scope">
{{ formatUTC(scope.row.updateAt) }}
</template>
</el-table-column>

完成,效果如下图
数据处理

底部分页器功能部分(重难点)

很常见的功能,刚学的时候很费劲,后面仔细看下面的代码和注解,应该能豁然开朗

  1. 引入组件
    还是组件库找分页组件,数据展示/分页里面找组件代码放进去,读代码,修改,最终:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <div class="pagination">
    <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :page-sizes="[10, 20]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="usersTotalCount"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
    />
    </div>
  2. 查询用户列表post请求要增加查询信息queryInfo参数
    1
    2
    3
    4
    5
    6
    export function postUsersListData(queryInfo: any) {
    return hyRequest.post({
    url: '/users/list',
    data: queryInfo
    })
    }
  3. 仓库接收用户信息,调用了postUsersListData(),要对应着给它加形参
    1
    2
    3
    4
    5
    6
    async postUsersListAction(queryInfo: any) {
    const usersListResult = await postUsersListData(queryInfo)
    const { totalCount, list } = usersListResult.data.data
    this.usersTotalCount = totalCount
    this.usersList = list
    }
  4. 页面调用仓库中的方法时同样要传递参数,封装网络请求函数
    现在一进页面要查询数据,分页数量变要查询,页码变要查询,总查询,所以给查询请求封装一个函数进行网络请求
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function fetchUserListData(formData: any = {}) {
    // size每页的数据数
    const size = pageSize.value
    //offset偏移量,第一页偏移0条数据,第二页偏移第一页展示的数据量以此类推
    const offset = (currentPage.value - 1) * size
    //这两个参数都是接口文档规定的要传的参数
    const pageInfo = { size, offset }
    // 2.发送网络请求
    systemStore.postUsersListAction(pageInfo)
    }
    功能实现,如下图
    底部分页器功能

条件查询数据

要实现条件查询获取数据再展示,查询的组件展示数据的组件是兄弟关系,他们之间要通信借助父组件user.vue

事件总线为什么不用
事件总线虽然能实现,但是数据也可能被别处获取使用,可能变得不可控
事件总线一般用于跨度大远距离的组件通信,现在是兄弟组件,距离不远,可以借助父组件,没必要用

  1. 查询子组件向父组件传递数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 发送给父组件,定义自定义事件
    const emit = defineEmits(['queryClick', 'resetClick'])
    const searchForm = reactive({
    name: '',
    realname: '',
    cellphone: '',
    enable: 1,
    createAt: []
    })
    //查询
    function handleQueryClick() {
    //console.log('searchForm', searchForm)
    emit('queryClick', searchForm)
    }
  2. 父组件接收子组件传递的数据
    <user-search @query-click="handleQueryClick" @reset-click="handleResetClick" />
  3. 父组件要用content子组件的方法,需要content组件提供方法并暴露
    content.vue
    原来的fetchUserListData()方法要完善,不光要携带页码和分页两个参数,还要包含查询组件传过来的参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 定义事件
    function fetchUserListData(formData: any = {}) {
    // size每页的数据数
    const size = pageSize.value
    //offset偏移量,第一页偏移0条数据,第二页偏移第一页展示的数据量以此类推
    const offset = (currentPage.value - 1) * size
    const pageInfo = { size, offset }
    //queryInfo把两部分要传的参数综合起来
    const queryInfo = { ...pageInfo, ...formData }
    console.log('queryInfo', queryInfo)
    // 发起网络请求
    systemStore.postUsersListAction(queryInfo)
    }
    //把这个方法暴露出去才能让父组件调用
    defineExpose({ fetchUserListData })
  4. 父组件调用content子组件的方法
    1
    2
    3
    4
    5
    6
    7
    // 先拿到content组件,再对content组件操作
    const contentRef = ref<InstanceType<typeof UserContent>>()
    //把search传递的formData传给content组件的fetchUserListData()方法
    function handleQueryClick(formData: any) {
    console.log('user.vue的formData', formData)
    contentRef.value?.fetchUserListData(formData)
    }
  5. 跑一下,但是报错了呢
    条件查询
    error
    为什么呢,打印的数据都正确,
    我想条件查询用户名含有o的用户,手动固定参数查询试试看
    fetchUserListData(formData: any = {}) 方法中的
    const queryInfo = { ...pageInfo, ...formData }
    我改成const queryInfo = { ...pageInfo, name: 'o' }
    刷新页面,成功展示了所有带o的用户信息,看来问题出现在...formData这个参数上
    error
    找到问题了,在查询表单默认数据这里
    1
    2
    3
    4
    5
    6
    7
    8
    const searchForm = reactive({
    name: '',
    realname: '',
    cellphone: '',
    enable: 1,
    //createAt: [] 开始是这样,会出错,服务器会认为没有和空数组匹配的项,都不符合,不会返回数据,改成下面的空字符串就好了
    createAt: ''
    })
    再试,成功error

重置查询条件

点击重置按钮不光要情况查询表单,还要让用户列表清单回到默认展示的效果,所以也需要触发兄弟组件的获取数据方法,和查询数据很多重复部分不重复记了

  1. 查询组件
    1
    2
    3
    4
    5
    const emit = defineEmits(['resetClick'])
    function handleQueryClick() {
    console.log('searchForm', searchForm)
    emit('queryClick', searchForm)
    }
  2. 父组件
    <user-search @query-click="handleQueryClick" @reset-click="handleResetClick" />
    1
    2
    3
    4
    //重置操作
    function handleResetClick() {
    contentRef.value?.fetchUserListData()
    }

删除用户部分

插槽获取ID

先要知道点击的是那条数据的删除,要拿到数据的ID,用作用域插槽

1
2
3
4
5
6
7
8
9
10
<template #default="scope">
<el-button
size="small"
icon="Delete"
type="danger"
text
@click="handleDeleteBtnClick(scope.row.id)"
>
删除
</el-button>

delete请求

后端接口文档这个是delete请求
service/main/sysystem/system.ts

1
2
3
4
5
export function deleteUserById(id: number) {
return hyRequest.delete({
url: `/users/${id}`
})
}

store/main/system/system.ts封装发送请求

1
2
3
4
5
6
7
8
//删除用户数据请求
async deleteUserByIdAction(id: number) {
//调用service删除接口,记得先导入
const deleteResult = await deleteUserById(id)
console.log(deleteResult)
// 删除后要重新请求新的数据,获取获取第一页数据
this.postUsersListAction({ offset: 0, size: 10 })
}

组件调用

1
2
3
4
function handleDeleteBtnClick(id: number) {
console.log('触发ID', id)
systemStore.deleteUserByIdAction(id)
}

成功,截图如下
删除成功

新建用户

弹出弹窗

新建会出现一个弹窗,这个弹窗编辑用户也能用,逻辑也很多,单独封装一个组件c=cpns/user-modal.vue
弹出的对话框从element组件里找,反馈组件/对话框放进去,对话框内容部分换成表单,script里定义初始值
modal组件中添加显示表单与否方法,暴露出去,让父组件调用

1
2
3
4
5
const dialogVisible = ref(false)//默认看不见,父组件调用再看见
function setModalVisible(){
dialogVisible.value = true
}
defineExpose({ setModalVisible })

content.vue
给按钮添加点击事件,点击按钮触发事件发送求给父组件

1
2
3
4
const emit = defineEmits(['newClick'])
function handleNewUserClick() {
emit('newClick')
}

user.vue
<user-content ref="contentRef" @new-click="handleNewClick" />

1
2
3
4
const modalRef = ref<InstanceType<typeof UserModal>>()
function handleNewClick() {
modalRef.value?.setModalVisible()
}

完善对话框–选项请求

类似查询表单,这里也放表达,
选择用户角色选择部门的选项应该来自服务器

  1. 创建请求方法
    这个请求不应该属于单独的页面,多个地方都可能用这个请求,单独放一个文件service/main/建main.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import hyRequest from '..'

    //获取所有用户角色
    export function getEntireRoles() {
    return hyRequest.post({
    url: '/role/list'
    })
    }
    //获取所有部门信息
    export function getEntireDepartments() {
    return hyRequest.post({
    url: '/department/list'
    })
    }
  2. 封装请求
    放在store/main/main.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    import { getEntireDepartments, getEntireRoles } from '@/service/main/main'
    import { defineStore } from 'pinia'
    //获取用户角色和部门类别应该再用户登录后就发送请求获取信息
    //记得在store/login中添加调用请求的代码
    interface IMainState {
    entireRoles: any[]
    entireDepartments: any[]
    }

    const useMainStore = defineStore('main', {
    state: (): IMainState => ({
    entireRoles: [],
    entireDepartments: []
    }),
    actions: {
    async fetchEntireDataAction() {
    const rolesResult = await getEntireRoles()
    const departmentsResult = await getEntireDepartments()
    //console.log('rolesResult', rolesResult)
    //console.log('departmentsResult', departmentsResult)
    // 保存数据
    this.entireRoles = rolesResult.data.data.list
    this.entireDepartments = departmentsResult.data.data.list
    }
    }
    })

    export default useMainStore
  3. 登录时调用,刷新时再调用
    不缓存因为要最新的更好一i点
    store/login/login.ts的登录方法和刷新方法中都添加

    1
    2
    3
    //8.请求所有roles/departments数据
    const mainStore = useMainStore()
    mainStore.fetchEntireDataAction()
  4. 对话框展示信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <el-form-item label="选择角色" prop="roleId">
    <el-select v-model="formData.roleId" placeholder="请选择角色" style="width: 100%">
    <template v-for="item in entireRoles" :key="item.id">
    <el-option :label="item.name" :value="item.id" />
    </template>
    </el-select>
    </el-form-item>
    <el-form-item label="选择部门" prop="departmentId">
    <el-select v-model="formData.departmentId" placeholder="请选择部门" style="width: 100%">
    <template v-for="item in entireDepartments" :key="item.id">
    <el-option :label="item.name" :value="item.id" />
    </template>
    </el-select>
    </el-form-item>
    1
    2
    3
    4
    // 2.获取roles/departments数据
    const mainStore = useMainStore()
    const systemStore = useSystemStore()
    const { entireRoles, entireDepartments } = storeToRefs(mainStore)
  5. 成功截图
    获取用户角色和部门信息成功

发送请求

填表测试,数据有被拿到
获取用户角色和部门信息成功
service/system.ts

1
2
3
4
5
6
export function newUserData(userInfo: any) {
return hyRequest.post({
url: '/users',
data: userInfo
})
}

store,还是记得要导入newUserData

1
2
3
4
5
6
7
8
async newUserDataAction(userInfo: any) {
//发送简历新用户请求
const newResult = await newUserData(userInfo)
console.log(newResult)

// 重新获取数据
this.postUsersListAction({ offset: 0, size: 10 })
}

组件中调用

1
2
3
4
5
6
7
// 点击确定新建
const systemStore = useSystemStore()
function handleConfirmClick() {
dialogVisible.value = false
systemStore.newUserDataAction(formData)

}

新增两条用户数据成功,截图如下
新增用成功

编辑用户

对话框组件,根据父传过来的参数显示title

1
2
3
4
5
6
<el-dialog
v-model="dialogVisible"
:title="isNewRef ? '新建用户' : '编辑用户'"
width="30%"
center
>

如果是编辑把原有数据添加进表单中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 表单加载
function setModalVisible(isNew: boolean = true, itemData?: any) {
dialogVisible.value = true
isNewRef.value = isNew
if (!isNew && itemData) {
// 编辑数据
for (const key in formData) {
formData[key] = itemData[key]
}
editData.value = itemData
} else {
// 新建数据
for (const key in formData) {
formData[key] = ''
}
editData.value = null
}
}

service,store,兄弟通信和前面大差不大,不赘述了
编辑成功截图(手机号码目前没修改成功):
编辑用户成功
编辑用户成功

封装用可复用的网路请求

其他页面的功能都和上面实现的用户功能相似,信息的增删查改
网络请求大致三个部分:页面触发增删查改;store封装请求;service发送请求
封装思路:
页面把页面的pageName和要做的增删查改操作发送给store,service再接收store传过来的pageName,拼接url发送请求
(如果接口不工整,前端应该手动封装工具把接口变得规整再实现复用)

网络请求复用—部门管理查询部分为例

先不复用,靠cv用户管理页面+手动修改实现了这部分的所有功能,然后再修改search部分代码实现可复用的代码

  1. 命名统一
    看接口和页面名称,是统一工整的,那么就好操作了(如果接口不工整,前端应该手动封装工具把接口变得规整再实现复用)
  2. service用pageName拼接地址
    原来的url类似
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    export function deleteUserById(id: number) {
    return hyRequest.delete({
    url: `/users/${id}`
    })}
    export function editUserData(id: number, userInfo: any) {
    return hyRequest.patch({
    url: `/users/${id}`,
    data: userInfo
    })
    }
    现在封装成下面这种
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    export function postPageListData(pageName: string, queryInfo: any) {
    return hyRequest.post({
    url: `/${pageName}/list`,
    data: queryInfo
    })
    }
    export function deletePageById(pageName: string, id: number) {
    return hyRequest.delete({
    url: `/${pageName}/${id}`
    })
    }
  3. store封装请求
    对应store的接收和发送也添加pageName,以查找和修改为例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //查找数据
    async postPageListAction(pageName: string, queryInfo: any) {
    const pageListResult = await postPageListData(pageName, queryInfo)
    const { totalCount, list } = pageListResult.data.data

    this.pageList = list
    this.pageTotalCount = totalCount
    },
    //编辑数据
    async editPageDataAction(pageName: string, id: number, pageInfo: any) {
    const editResult = await editPageData(pageName, id, pageInfo)
    console.log('editResult', editResult)
    this.postPageListAction(pageName, { offset: 0, size: 10 })
    }
  4. 组件发送pageName
    通过config.ts传递pageName属性,发送请求带着它
    1
    2
    3
    4
    5
    6
    7
      const queryInfo = { ...pageInfo, ...formData }
    systemStore.postPageListAction(pageName, queryInfo)
    }
    // 5.删除/新建/编辑的操作
    function handleDeleteBtnClick(id: number) {
    systemStore.deletePageByIdAction(pageName, id)
    }
  5. 实现截图:新增部门成功
    新增部门成功

网络请求复用–content部分

前面以及把service和store改成了复用的部分,content部分网络请求复用只需要把写死的页面名变成config.ts中的pageName属性传入组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//发送网络请求
function fetchPageListData(formData: any = {}) {
// 1.获取offset/size
const size = pageSize.value
const offset = (currentPage.value - 1) * size
const pageInfo = { size, offset }
const queryInfo = { ...pageInfo, ...formData }
systemStore.postPageListAction(props.contentConfig.pageName, queryInfo)
}

//删除/新建/编辑的操作
function handleDeleteBtnClick(id: number) {
systemStore.deletePageByIdAction(props.contentConfig.pageName, id)
}

复用成功
修改代码
(补充:在对页面进行新建和编辑操作之后,应该重新获取完整的信息)

1
2
3
//重新获取完整的数据
const mainStore = useMainStore()
mainStore.fetchEntireDataAction()

封装可复用的高阶组件快速搭建页面

其他页面的和用户管理页面很相似,除了数据不同,要展示的样式都是上面搜索清单,下面展示信息表格,为了避免代码的重复和每一个新页面修改很多
写一个配置文件,封装一个可复用的页面,快速搭建页面
这些封装好的组件后来都放到了components文件夹下用文件夹包了起来

search部分复用—部门管理为例

先不复用,靠cv用户管理页面+手动修改实现了这部分的所有功能,然后再修改search部分代码实现可复用的代码

  1. 建立专门针对页面的search部分进行配置的文件
    部门管理内容在department文件夹中,在里面建config文件夹/search.config.ts文件
    先简单写数据,比如
    1
    2
    3
    4
    5
    const searchConfig = {
    xxx:"Nie"
    yyy:123
    }
    export default searchConfig
  2. 在这部分页面的父组件department.vue中引入并传给search组件
    import searchConfig from './config/search.config
    1
    2
    3
    4
    5
    <page-search
    :search-config="searchConfig"
    @query-click="handleQueryClick"
    @reset-click="handleResetClick"
    />
  3. 在search组件接收,注意写法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    interface IProps {
    searchConfig:{
    // 对象里的属性和config文件里的属性对应
    xxx:string
    yyy:number
    }
    }
    //这样指定props里包含一个searchConfig,和它对应的类型
    const props = defineProps<IProps>({})
  4. 修改config内容成目标内容
    观察搜索部分,想要动态展示的数据formItems是数组,把不确定的动态的东西放进数组里,比如下图的这种数据,让它自动生成
    修改代码
    search.config.ts
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    const searchConfig = {
    formItems: [
    {
    //这里增加类型因为不止有input类型,可能还有日期选择等
    prop: 'name',
    type: 'input',
    label: '部门名称',
    placeholder: '请输入查询的部门名称',
    initialValue: '我是默认填进去的值'
    },
    {
    type: 'input',
    prop: 'leader',
    label: '部门领导',
    placeholder: '请输入查询的领导名称'
    },
    {
    type: 'date-picker',
    prop: 'createAt',
    label: '创建时间'
    }
    ]
    }
    export default searchConfig
  5. search组件表单中获取数据
    原来空架子
    1
    2
    3
    4
    <el-form :model="searchForm" ref="formRef" label-width="80px" size="large">
    <el-row :gutter="20">
    </el-row>
    </el-form>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <el-form :model="searchForm" ref="formRef" label-width="80px" size="large">
    <el-row :gutter="20">
    <template v-for="item in searchConfig.formItems" :key="item.prop">
    <!-- 这个span也能抽出去设置宽度 -->
    <el-col :span="8">
    <el-form-item :label="item.label" :prop="item.prop">
    <!-- 这里不一定都是imput,用动态组件或者template+if判断
    我觉得动态组件拼接有些难,用后者 -->
    <template v-if="item.type === 'input'">
    <el-input v-model="searchForm[item.prop]" :placeholder="item.placeholder" />
    </template>
    <!-- type一共没几种,多写写 -->
    <template v-if="item.type === 'date-picker'">
    <el-date-picker
    v-model="searchForm[item.prop]"
    type="daterange"
    range-separator="-"
    start-placeholder="开始时间"
    end-placeholder="结束时间"
    />
    </template>
    </el-form-item>
    </el-col>
    </template>
    </el-row>
    </el-form>
  6. search组件ts获取数组
    searchForm的数据不能写死,遍历config
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    interface IProps {
    searchConfig: {
    // 对象里的属性和config文件里的属性对应
    formItems: any[]
    }
    }
    //这样指定props里包含一个searchConfig,和它对应的类型
    const props = defineProps<IProps>({})

    // 定义form的数据
    const initialForm: any = {}
    for (const item of props.searchConfig.formItems) {
    initialForm[item.prop] = item.initialValue ?? ''
    }
    const searchForm = reactive(initialForm)
  7. search部分复用成功截图
    之前的配色为了好区分,没有设计,现在重新搭配了页面颜色

search部分复用成功
功能正常,能够查询
search部分复用成功

search部分复用—角色管理使用复用内容

(好神奇!!这样好方便!!!!)
角色管理在departemnt/role文件夹下,在role.vue中引入并使用组件

1
2
import PageSearch from '@/components/page-search/page-search.vue'
import searchConfig from './config/search.config'

<page-search :search-config="searchConfig" />
再写个config文件,然后就成功啦!
search部分复用成功
看效果
search部分复用成功

content表单复用—部门管理

把写死的content部分改为可复用的组件
content表单复用-

  1. 分解
    这部分是表格,分为表格头部(表格名和新建数据按钮)和表格内容两个部分,在content.config.ts中用listHeader和listBody两部分存自定义数据。
    文件还是放在部分文件夹下的config文件夹里
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const contentConfig = {
    pageName:"department"
    listHeader: {
    title: '我是复用的部门列表',
    btnTitle: '我是复用添加部门'
    },
    listBody: {}
    }
    export default contentConfig

  2. 在父组件中引入,传递给子组件
    <page-search :search-config="searchConfig"/>
  3. 子组件接收数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    interface Iprops {
    contentConfig: {
    pageName: string
    listHeader?: {
    title?: string
    btnTitle?: string
    }
    listBody: any[]
    }
    }
    const props = defineProps<Iprops>()
  4. 子组件使用数据–表单头部改复用
    如果没有数据,给一个默认的展示数据
    1
    2
    3
    4
    5
    6
    7
        <h3 class="title">部门列表</h3>
    <el-button type="primary" @click="handleNewUserClick">新建部门</el-button>
    <!-- 上面原来的改为下面的 -->
    <h3 class="title">{{ contentConfig?.listHeader?.title ?? '数据列表' }}</h3>
    <el-button type="primary" @click="handleNewUserClick">{{
    contentConfig?.listHeader?.btnTitle ?? '新增数据'
    }}</el-button>
    content表格头部复用
    按顺序做vscode有提示好写很多
    content复用
  5. 表单内容改复用
    每一个el-table-column的内容都可能不一样,让每一列根据配置文件listBody对象数组动态生成,每一个对象就是每一列的数据
    表格中v-for+v-if
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    <el-table :data="pageList" border style="width: 100%">
    <template v-for="item in contentConfig.listBody" :key="item.prop">
    <template v-if="item.type === 'timer'">
    <el-table-column align="center" v-bind="item">
    <template #default="scope">
    {{ formatUTC(scope.row[item.prop]) }}
    </template>
    </el-table-column>
    </template>
    <template v-else-if="item.type === 'handler'">
    <el-table-column align="center" v-bind="item">
    <template #default="scope">
    <el-button
    size="small"
    icon="Edit"
    type="primary"
    text
    @click="handleEditBtnClick(scope.row)"
    >
    编辑
    </el-button>
    <el-button
    size="small"
    icon="Delete"
    type="danger"
    text
    @click="handleDeleteBtnClick(scope.row.id)"
    >
    删除
    </el-button>
    </template>
    </el-table-column>
    </template>
    <template v-else-if="item.type === 'custom'">
    <el-table-column align="center" v-bind="item">
    <template #default="scope">
    <slot :name="item.slotName" v-bind="scope" :prop="item.prop" hName="why"></slot>
    </template>
    </el-table-column>
    </template>
    <template v-else>
    <el-table-column align="center" v-bind="item" />
    </template>
    </template>
    </el-table>
    content表单复用-

modal对话框复用

核心方法和前面一样,不重复做笔记了

动态获取选项中的数据

选项中想要有服务器返回的部门列表数据该怎么做,使用计算属性,获取放进仓库中的部门信息,放进oconfig里面,再传给组件展示数据

1
2
3
4
5
6
7
8
9
10
11
12
13
const modalConfigRef = computed(() => {
const mainStore = useMainStore()
const departments = mainStore.entireDepartments.map((item) => {
return { label: item.name, value: item.id }
})
modalConfig.formItems.forEach((item) => {
if (item.prop === 'parentId') {
item.options.push(...departments)
}
})

return modalConfig
})

module对话框复用

setup中相同逻辑的抽取

setup语法糖中有部分逻辑永远是相似的,可以进行抽取
在父组件department.vue中,帮助search组件和content组件兄弟通信的(重置,查询)代码,还有content组件和mocal组件兄弟通信的(新建,查询)代码,其他部分父组件也会用到,会出现重复,把这部分使用hooks进行抽取
想抽取部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 点击search, content的操作
const contentRef = ref<InstanceType<typeof PageContent>>()
function handleQueryClick(queryInfo: any) {
contentRef.value?.fetchPageListData(queryInfo)
}
function handleResetClick() {
contentRef.value?.fetchPageListData()
}

// 点击content, modal的操作
const modalRef = ref<InstanceType<typeof PageModal>>()
function handleNewClick() {
modalRef.value?.setModalVisible()
}
function handleEditClick(itemData: any) {
modalRef.value?.setModalVisible(false, itemData)
}

在hooks文件夹下创建usePageContent.ts文件

全用复用组件搭建角色管理页面

内容全部使用只用了一个vue和三个config文件,实现页面搭建和数据的获取与展示!!
module对话框复用
module对话框复用

全用复用组件搭建菜单管理页面(完善content组件实现多级菜单展示效果)

看接口返回的菜单数据,后端传递的每一种数据作为每一列,再config.ts文件中是对象数组的一个对象

理解后端数据,用户的权限管理不光有路由的权限管理,还有按钮的权限管理(比如有的用户只有某个页面的查看权限,没有该部分的删除和编辑权限),比如system:role:create; system:role:delete

展示一级菜单二级菜单

菜单包含一级菜单和二级菜单,想要页面的展示效果是一级菜单下能展开二级菜单,该怎么做呢

  1. 仔细读官方文档: element/Table表格/树形数据与懒加载

    当 row 中包含 children 字段时,被视为树形数据。 渲染嵌套数据需要 prop 的 row-key。 此外,子行数据可以异步加载。 设置 Table 的lazy属性为 true 与加载函数 load 。 通过指定 row 中的hasChildren字段来指定哪些行是包含子节点。 children 与hasChildren都可以通过 tree-props 配置。

  2. 修改content复用组件
    给el-table添加row-key=””属性,treeProps属性,这两个属性放在config文件中更好
  3. 修改config.ts文件
    想要用element组件库展开数据,那么config数据中不可以有type类型
    因为再el-table每一列进行v-for,v-if,v-else时,最后的v-else有v-bind=”item”,本来给el-table添加row-key属性会默认给这种列添加type,如果config里面写,会把本来要添加的type覆盖掉,导致不显示展开样式
    1
    2
    3
    <template v-else>
    <el-table-column v-bind="item"/>
    </template>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    const contentConfig = {
    pageName: 'menu',
    listHeader: {
    title: '菜单列表',
    btnTitle: '新建菜单'
    },
    listBody: [
    { label: '菜单名称', prop: 'name', width: '180px' },
    { label: '级别', prop: 'type', width: '120px' },
    { label: '菜单url', prop: 'url', width: '150px' },
    { label: '菜单icon', prop: 'icon', width: '200px' },
    { label: '排序', prop: 'sort', width: '120px' },
    { label: '权限', prop: 'permission', width: '150px' },

    { type: 'timer', label: '创建时间', prop: 'createAt' },
    { type: 'timer', label: '更新时间', prop: 'updateAt' },
    { type: 'handler', label: '操作', width: '150px' }
    ],
    childrenTree: {
    rowKey: 'id',
    treeProps: {
    children: 'children'
    }
    }
    }
    export default contentConfig
  4. 完善content复用组件
    在el-table上绑定v-bind="contentConfig.childrenTree"
    1
    <el-table :data="pageList" border style="width: 100%" v-bind="contentConfig.childrenTree">
    在IProps中添加可选的childrenTree属性
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    interface IProps {
    contentConfig: {
    pageName: string
    listHeader?: {
    title?: string
    btnTitle?: string
    }
    listBody: any[]
    childrenTree?: any
    }
    }
  5. 成功截图
    content实现多级惨淡展示效果

完善复用组件–插槽实现自定义标签(展示树形菜单)

想要在“添加菜单”modal中展示菜单列表让用户选择。自定义modal里可以放插槽,插槽里可以放任何东西,比如h2标签等

  1. 简单例子
    1
    2
    3
    <template v-if="item.type === 'custom'">
    <slot :name="item.slotName"></slot>
    </template>
    父组件使用时
    1
    2
    3
    <page-modal :modal-config="modalconfig" ref="modalRef">
    <template #span><span>自定义span标签</span></template>
    <template #btn> <button> 自定义button标签</button></template></page-modal>
    在config中,多个自定义组件区分在slotName不同,type都为custom
  2. 插槽实现自定义菜单列表(树形结构)
    element组件库/数据展示/树形控件/树节点的选择
    在父组件role.vue使用树形结构注意:props
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <page-modal :modal-config="modalConfig" ref="modalRef">
    <template #menulist>
    <el-tree
    ref="treeRef"
    :data="entireMenus"
    show-checkbox
    node-key="id"
    :props="{ children: 'children', label: 'name' }"
    />
    </template>
    </page-modal>
  3. 获取菜单
    与前面获取部门和角色一样,在role.vue中使用
    1
    2
    3
    // 获取完整的菜单
    const mainStore = useMainStore()
    const { entireMenus } = storeToRefs(mainStore)
  4. 实现截图
    content实现多级菜单展示效果

完善:action触发后分页回到第一页

在modal组件点击新建或编辑或删除之后,content页面应该回归到1页面
modal页面点击确定之后,content页面改变,兄弟组件通信,继续借助父组件
新增,编辑,删除的操作,都是在store/system的actions里面,判断只要做了edit\delete\new操作,那么就进行页码回归(修改curretPage.value为1)
在page-content.vue中监听actions里的操作

1
2
3
systemStore.$onAction((args) => {
console.log('监听到actions', args)
})

用户权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1.发起action,请求usersList的数据
const systemStore = useSystemStore()
const currentPage = ref(1)
const pageSize = ref(10)
//后面添加:监听store/stsyem中actions
//用actions参数,它本身接收一个回调,当action执行成功时触发
systemStore.$onAction(({ name, after }) => {
after(() => {
if (
name === 'deletePageByIdAction' ||
name === 'editPageDataAction' ||
name === 'newPageDataAction'
) {
currentPage.value = 1
}
})
})
//获取页面数据
fetchPageListData()

完善:新建和编辑角色功能

创建角色(树形结构组件)

创建角色的modal要携带菜单数据
实现新建用户的请求,要拿到mentList数据
在element组件库/树形控件往下翻有非常多的事件,用cheak事件
在el-tree里绑定 @check="handleElTreeCheck"
先看看里面的两个参数是什么

1
2
3
function handleElTreeCheck(data1: any, data2: any) {
console.log('点击了复选框,两个数据', data1, data2)
}

获取多级菜单
data1是点击的节点的信息(id,url,name,sort,type)
data2:checkedKeys点击节点的id;halfCheckedKeys半选是它父节点的id;halfCheckedNodes它父节点的对象
看结果得知,通过checkedKeyshalfCheckedKeys可以得到选择的菜单项的id
modal组件增加otherInfo?: any

1
2
3
4
5
6
7
8
9
10
11
interface IModalProps {
modalConfig: {
pageName: string
header: {
newTitle: string
editTitle: string
}
formItems: any[]
}
otherInfo?: any
}

role.vue中绑定otherInfo
1
2
3
4
5
6
7
 const otherInfo = ref({})
function handleElTreeCheck(data1: any, data2: any) {
const menuList = [...data2.checkedKeys, ...data2.halfCheckedKeys]
console.log(data2.checkedKeys)
console.log(menuList)
otherInfo.value = { menuList }
}

获取多级菜单
把数据绑定给page-modal组件::other-info="otherInfo"
1
2
3
4
5
6
7
8
9
10
11
12
<page-modal :modal-config="modalConfig" :other-info="otherInfo" ref="modalRef">
<template #menulist>
<el-tree
ref="treeRef"
:data="entireMenus"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'name' }"
@check="handleElTreeCheck"
/>
</template>
</page-modal>

现在modal接收到了菜单数据,发送前进行判断otherInfo数据,有的话合并进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function handleConfirmClick() {
dialogVisible.value = false

let infoData = formData
if (props.otherInfo) {
infoData = { ...infoData, ...props.otherInfo }
}

if (!isNewRef.value && editData.value) {
// 编辑用户的数据
systemStore.editPageDataAction(props.modalConfig.pageName, editData.value.id, infoData)
} else {
// 创建新的部门
systemStore.newPageDataAction(props.modalConfig.pageName, infoData)
}
}

新建角色成功
获取多级菜单

编辑角色(递归获取最底层id,菜单回显)

  1. 思路
    点击编辑按钮,弹出的modal对话框需要已经勾选了这个角色原来的菜单子菜单信息,这部分获取信息的操作之前封装在hooks/usePageModal.ts中
    还是官方文档,树形结构/事件有.setCheckedKeys()方法,把菜单对应的id数组放进去可以展示
  2. 封装获取id工具
    现在返回的数据是嵌套关系的,封装一个工具用来把里面的id取出来utils/map-menys.ts文件
    目标是找到最底层的菜单对应的id,不要父菜单的id,因为选中父菜单的id会自动把它的子菜单全部选中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /**
    * 菜单映射到id的列表
    * @param menuList
    */
    export function mapMenuListToIds(menuList: any[]) {
    //要拿到所有的id组合的数组
    const ids: number[] = []
    //递归获取id
    function recurseGetId(menus: any[]) {
    for (const item of menus) {
    if (item.children) {
    //如果遍历的有子菜单,那么再遍历一次
    recurseGetId(item.children)
    } else {
    //如果遍历的没有子菜单,进入了最底部的id,把最底部的id取出来
    //目标是找到最底层的菜单对应的id,不要父菜单的id,
    //因为选中父菜单的id会自动把它的子菜单全部选中
    ids.push(item.id)
    }
    }
    }
    recurseGetId(menuList)
    return ids
    }
  3. 父组件调用
    1
    2
    3
    4
    5
    6
    7
    8
    import { mapMenuListToIds } from '@/utils/map-menus'
    const treeRef = ref<InstanceType<typeof ElTree>>()
    function editCallback(itemData: any) {
    nextTick(() => {
    const menuIds = mapMenuListToIds(itemData.menuList)
    treeRef.value?.setCheckedKeys(menuIds)
    })
    }

    nextTick()是Vue3提供的一个方法,用于等待DOM更新完成,然后执行回调函数
    以下内容来自官方文档
    等待下一次 DOM 更新刷新的工具方法
    当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。
    nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。

  4. 成功回显截图
    获取多级菜单

按钮权限管理

对页面中核心的增删改查功能进行权限管理

按钮权限在哪里

关于增删改查操作的权限在菜单管理里面有对应的字段,”角色管理”可以对角色拥有的权限进行管理
用户权限
在请求userMenus数据时,已经包含了按钮权限数据
在第三级数据里permission字段,通过这个判断是有按钮权限
用户权限

获取用户按钮权限数据保存到store中

  1. 在登录仓库中添加字段,调用方法
    在获取userMents菜单信息的地方store/login/login.ts里,去获取登录用户的所有按钮权限数据,放到permissions[]数组里,在store中添加permissions字段,下面这两段不光在用户登录之后触发,在刷新之后也应该触发
    1
    2
    3
    //9.按钮权限
    const permissions = mapMenusToPermissions(userMenusData)
    this.permissions = permissions
  2. 封装映射关系方法
    从userMenus里映射出permission,也是一种映射关系,mapMenusToPermissions方法也放utils/map-menus.ts里
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    /**
    * 从菜单里映射出按钮的权限
    * @param menuList 菜单的列表
    * @returns 按钮权限的数组(字符串数组)
    */
    export function mapMenusToPermissions(menuList: any[]) {
    const permissions: string[] = []
    //用递归,遍历传进来的menus
    //在storage保存的json数据中,有关按钮权限的数据type=3
    //所以,在递归函数中,判断type=3,就push到permissions中
    //如果不是第三级,那么可能是第一级可能第二级,拿出来继续做递归
    //有的只有第二层,没有第三层,再找children为null
    //此时const item of null会报错,所以如果没有chidren用空数组代替
    //const item of [] 什么都不会做,相当于不遍历直接结束循环,不会报错
    function recurseGetPermission(menus: any[]) {
    for (const item of menus) {
    if (item.type === 3) {
    permissions.push(item.permission)
    } else {
    recurseGetPermission(item.children ?? [])
    }
    }
    }
    recurseGetPermission(menuList)

    return permissions
    }

  3. 成功截图
    看仓库,已经成功获取到了用户按钮权限信息
    用户权限

页面权限校验

  1. content组件中判断
    由于增删改都在page-content组件中,去这里判断
    在一进入page-content组件时,判断当前的用户有没有增删改查四个权限,用v-if对应的布尔值判断是否展示对应的按钮,比如:
    1
    2
    <el-button v-if="isCreate" type="primary" @click="handleNewUserClick">{{contentConfig?.listHeader?.btnTitle ?? '新增数据'
    }}</el-button>
    如果编辑和删除都没有,那么整个列都不展示
    对于查询操作,如果没有就不进行请求方法
    1
    2
    3
    4
    5
    // 4.定义函数, 用于发送网络请求
    function fetchPageListData(formData: any = {}) {
    //判断有没有查询权限,没有直接return,不展示数据
    if(!isQuery) return
    ...
  2. 动态获取权限,抽hooks
    1
    2
    3
    4
    const loginStore = useLoginStore()
    const {permissions} = loginStore
    const isCreate =permissions.find((item)=>item.includes(${props.contentConfig.pageName}:create))
    ....
    把这部分抽取出一个hook,usePermissions.ts
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import useLoginStore from '@/store/login/login'

    function usePermissions(permissionID: string) {
    const loginStore = useLoginStore()
    const { permissions } = loginStore
    //注意这里哦,想要发挥的一定是一个布尔类型
    //第一种方法
    //return Boolean(permissions.find((item) => item.includes(permissionID)))
    // 第二种方法:
    //两个感叹号,第一个感叹号取反,第二个再取反,转回对应的boolean类型
    return !!permissions.find((item) => item.includes(permissionID))
    }
    export default usePermissions

  3. content.vue页面调用
    1
    2
    3
    4
    5
    import usePermissions from '@/hooks/usePermissions'
    const isCreate = usePermissions(`${props.contentConfig.pageName}:create`)
    const isDelete = usePermissions(`${props.contentConfig.pageName}:delete`)
    const isUpdate = usePermissions(`${props.contentConfig.pageName}:update`)
    const isQuery = usePermissions(`${props.contentConfig.pageName}:query`)
  4. search组件中判断
    思路一样,const isQuery = usePermissions(`${props.searchConfig.pageName}:query`)<div class="search" v-if="isQuery">

数据可视化页面

  1. 先死数据写出样子,再封装组件,再放真实数据
    放在views/main/analysys/dashboard/dashboard。vue为父组件,analysys/dashboard/c-cpns/下是页面的上下部分两个子组件
  • 数据展示:递增动画实现方式:
    1.countup: 数字增加动画库(比较轻量级)
    2.专门的一些动画库: gsap(没有上面的轻量,如果需要多种动画效果再引入这个用)
  • 鼠标放到右上角小图标上,能展示文字提示的气泡,用element-plus的反馈组件/Tooltip文字提示组件
    1
    2
    3
    4
    5
    6
    7
    8
    <template>
    <div class="count-card">
    <div class="header">
    <span>商品总销量</span>
    <el-tooltip content="所有商品的总销量"placement="top" effec="light">
    <el-icon><Warning /></el-icon>
    </el-tooltip>
    </div>
  1. 从后端拿到数据
    service/main/analysys/analysis.ts
    1
    2
    3
    4
    5
    6
    7
    import hyRequest from '@/service'

    export function getAmountListData() {
    return hyRequest.get({
    url: '/goods/amount/list'
    })
    }
    专门放一个store管理这部分数据
    store/main/analysys/analysis.ts
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import { defineStore } from 'pinia'
    interface IAnalysisState {
    amountList: any[]
    }
    const useAnalysisStore = defineStore('analysis', {
    state: ():IAnalysisState => ({
    amountList: any[]
    }),
    actions: {
    async fetchAnalysisDataAction() {
    const amountResult = await getAmountListData()
    this.amountList = amountResult.data.data
    }
    }

    export default useAnalysisStore

    dashboard.vue
    1
    2
    3
    4
    5
    6
    7
    import { storeToRefs } from 'pinia'
    import useAnalysisStore from '@/store/main/analysis/analysis'
    // 一进页面发起数据的请求,数据保存到了store中
    const analysisStore = useAnalysisStore()
    analysisStore.fetchAnalysisDataAction()
    //从store中拿到数据,用storeToRefs变成响应式数据
    const { amountList } = storeToRefs(analysisStore)
  2. 给组件使用数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!-- 1.顶部数字的数据展示 -->
    <el-row :gutter="10">
    <template v-for="item in amountList" :key="item.amount">
    <el-col :span="6" :xs="24" :sm="12" :md="8" :lg="6">
    <!-- 用v-bind的前提是json格式的数据和组件属性名保持一致 -->
    <count-card v-bind="item" />
    </el-col>
    </template>
    </el-row>

数据增长动画

  1. 引入countup库
    pnpm install countup.js
    pnpm add countup.js
    CountUp.js库

  2. 使用库
    从库中拿到类,创建实例对象
    countUp()方法:接收2-3个参数,参数一为执行动画的元素,参数二为结束数字,参数三为配置项(如startVal开始数字参数,perfix前缀参数)
    dashboard.vue

    1
    <span ref="count1Ref">{{number1}}</span>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { computed } from 'vue'
    const count1Ref = ref<HTMLelement>()
    onMounted(() => {
    //加感叹号非空断言,表示值一定不为空
    const countUp1 = new CountUp(count1Ref.value!,props.number1, {
    startVal:10000
    })
    countUp1.start()
    })

  3. 添加人民币符号(灵活)
    添加一个销售额属性,当数字为销售额,添加人民币符号
    CountUp()第三个参数中,有增加前缀的参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const countOption = {
    prefix: props.amount === 'saleroom' ? '¥' : ''
    }
    onMounted(() => {
    const countup1 = new CountUp(count1Ref.value!, props.number1, countOption)
    const countup2 = new CountUp(count2Ref.value!, props.number2, countOption)
    countup1.start()
    countup2.start()
    })
  4. 卡片展示效果
    希望每部分数据都在一个单独的卡片上
    element组件库/数据展示/卡片/基础用法就够了

  5. 成功截图
    数字动态可视化展示

EChart图表展示

  1. 在项目里集成ECharts,
  2. 导入 import * as echarts from ‘echarts’,
  3. echarts.init()初始化echarts实例
  4. 然后写配置Options,宽度默认 百分之百,一定要 自己添加高度

三层封装

  1. 封装page-echarts文件夹,/src/base-echart(第一层封装)
  2. 进行抽取,不同类型的echarts图表封装不同的子组件
    比如饼图pie-echarts(第二层封装 ),在里面引入baseEchart
    把整个配置文件options从base-echarts抽取出去,让别的组件(比如pie-echart)往里传
    类型报错处理:
    EChart类型报错处理
    直接<base-echart :option="option"/> 会报”option”类型错误,需要进行import type{ EChartsOption} from 'echarts'
    再给option明确指定类型
    const option: EChartsOption={配置项}
  3. 第三层封装--抽离数据:把数据传入第二层封装的options里
    数据从service里面拿到,然后放到store里,再传到页面,页面再传递给它调用的组件里
    完善:刚开始数据是空的,后续拿到数据,数据会变化,所以用计算属性const options = computed<>(()=>{})这样可以拿到最新的数据
    数据更新后,也要重新进行setOption
    使用watchEffect()监听,在setOption外面包裹即可
    1
    2
    3
    watchEffect(()=>{
    echartInstance.setOption(props.option)
    })

效果展示

一共用到五种echart图标,每一种封装一个组件components/page-echarts/src/
EChart图表展示
EChart图表展示