后台管理项目

技术栈

vue3+vite+vue-router+pinia+element-plus+echarts+mockjs+localstorage+axios

项目职责

  1. 借助 element-plus + vue-router 负责整个系统 UI 结构实现单页面应用
  2. 针对左侧菜单权限问题,为了实现不同角色登录左侧菜单展示不同,采用了动态路由的实现
  3. 针对首页 echarts 表格的展示,缩小页面会导致表格展示不全的问题做了适配的处理
  4. 用到了面包屑缓存提升了用户的体验,主要解决用户在操作的时候不小心关闭浏览器,重新打开可以快速定位到上次浏览的页面
  5. 针对左侧路由和面包屑以及 tab 栏进行了联动处理
  6. 针对用户可能在 url 上输入非法地址做了 404 的路由处理
  7. 整个系统数据交互在前期植入了 mock 模拟数据,没有阻塞接口地址的调用,大大提升了后期联调的效率
  8. 针对 axios 进行二次封装,集中处理请求前和请求后的操作,其中还用到了 mock 开关和三种环境的配置,可以通过配置迅速打开和关闭 mock,系统可以自动根据当前环境调取不同的接口地址

计算属性参与 v-for

在这里渲染侧边栏属性的时候我们用了这样的 v-for

list 数据部分

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
const list = ref([
{
path: "/home",
name: "home",
label: "首页",
icon: "House",
url: "Home",
},
{
path: "/mall",
name: "mall",
label: "商品管理",
icon: "VideoPlay",
url: "Mall",
},
{
path: "/user",
name: "user",
label: "用户管理",
icon: "User",
url: "User",
},
{
path: "/other",
label: "其他",
icon: "Location",
children: [
{
path: "/page1",
name: "page1",
label: "页面1",
icon: "Setting",
url: "Page1",
},
{
path: "/page2",
name: "page2",
label: "页面2",
icon: "Setting",
url: "Page2",
},
],
},
]);

script 部分

1
2
const noChildren = computed(() => list.value.filter((item) => !item.children));
const hasChildren = computed(() => list.value.filter((item) => item.children));

template 部分

1
2
3
4
5
6
7
8
9
<el-menu-item
v-for="item in noChildren"
:index="item.path"
:key="item.path"
@click="clickMenu(item)"
>
<el-sub-menu v-for="item in hasChildren" :index="item.path" :key="item.path">
</el-sub-menu
></el-menu-item>

数据源计算

Q1:nochildren 和 haschildren 是什么 我们为什么要用它们作为遍历的数据源

filter 是 JavaScript 数组的一个内置方法,用于筛选数组中符合条件的元素(它会遍历整个数组),并返回一个包含所有符合条件元素的新数组(不会改变原数组)。

1
const 新数组 = 原数组.filter(回调函数);

所以首先 我们是用 filter 在 list 中渲染了我们需要的满足条件的元素

为什么要在前面添加上计算属性呢

这种方式的优势是:

  1. 计算属性会自动响应 list 的变化,当数据更新时,视图会自动重新渲染
  2. 把过滤逻辑放在计算属性中,使模板更加简洁清晰
  3. 计算属性会缓存结果,比直接在模板中使用 list.filter() 性能更好

数据源区别

Q2:nochildren 和 haschildren 的区别是什么

在 Vue 的 v-for 中使用 in noChildrenin hasChildren 的核心区别在于遍历的数据源不同,这直接决定了渲染的内容差异,本质上是由你定义的两个计算属性的过滤逻辑决定的:

  1. 数据源的区别

    • v-for="item in noChildren":遍历的是没有子项的元素集合
      对应的计算属性逻辑是 list.value.filter(item => !item.children),即只保留 children 为假值(nullundefined、空数组 [] 等)的项。
    • v-for="item in hasChildren":遍历的是有子项的元素集合
      对应的计算属性逻辑是 list.value.filter(item => item.children),即只保留 children 为真值(非空数组、有实际内容的子项)的项。
  2. 实际渲染效果的区别
    假设你的 list 数据如下:

    1
    2
    3
    4
    5
    6
    const list = ref([
    { id: 1, name: "菜单1", children: [] }, // 无有效子项(空数组)
    { id: 2, name: "菜单2", children: null }, // 无实际子项
    { id: 3, name: "菜单3", children: [{ id: 31 }] }, // 有子项
    { id: 4, name: "菜单4", children: [{ id: 41 }] }, // 有子项
    ]);
    • 使用 noChildren 会渲染 id=1id=2 的项(因为它们的 children 被判定为 “无”)
    • 使用 hasChildren 会渲染 id=3id=4 的项(因为它们的 children 被判定为 “有”)
  3. 业务场景的区别
    这种区分通常用于分类展示数据,也就是说对于有没有子项这件事分开展示,比如:

    • 菜单组件中,把 “叶子节点”(无下级菜单)和 “父节点”(有下级菜单)分开渲染,可能给它们设置不同的样式或交互逻辑
    • 树形结构中,单独处理可展开的节点(有子项)和不可展开的节点(无子项)

简单说,两者的区别就是 “筛选出的数据集不同”,分别对应 “无子项” 和 “有子项” 的两种数据分类,最终会在页面上渲染出不同的内容。

mockjs

当我们拿到接口文档但是暂时还没有数据的时候

作为前端 我们应该学会制造假数据 但并不是我们直接写死一系列数据

而是应该吧交互请求的流程根据接口文档跑通 并且还要制造出数据

故而我们引入了一个工具 mockjs 可以拦截 Ajax 请求 把我们需要的假数据根据接口文档 制造出来

1
npm install mockjs

我们在 src 下创建 api 文件夹 在其中创建 mock.js 用于实现拦截 ajax 请求

1
2
3
4
5
import Mock from "mockjs";
import homeApi from "./mockData/home.js";

//1.拦截的路径 2.拦截的方法 3.制造出的假数据
Mock.mock(/api\/home\/getTableData/, "get", homeApi.getTableData);

第一个位置是正则表达式作为拦截的路径(即后端给我们的后端接口的后半部分)

第二个部分是请求拦截的方法

第三个部分是我们提前预设好的假数据 我们一般会在 api 下放一个 mockData 文件夹来存放所有的 mock 数据 我们在 mockData 文件夹下创建一个 home.js 用于存放 home 组件部分的假数据

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
export default {
getTableData: () => {
return {
code: 200,
data: {
tableData: [
{
name: "oppo",
todayBuy: 500,
monthBuy: 3500,
totalBuy: 22000,
},
{
name: "vivo",
todayBuy: 300,
monthBuy: 2200,
totalBuy: 24000,
},
{
name: "苹果",
todayBuy: 800,
monthBuy: 4500,
totalBuy: 65000,
},
{
name: "小米",
todayBuy: 1200,
monthBuy: 6500,
totalBuy: 45000,
},
{
name: "三星",
todayBuy: 300,
monthBuy: 2000,
totalBuy: 34000,
},
{
name: "魅族",
todayBuy: 350,
monthBuy: 3000,
totalBuy: 22000,
},
],
},
};
},
};

注意这里需要返回一个函数

当然做完上述的准备工作之后我们还需要到 main 里面去注册一下

1
import "@/api/mock.js";

此后我们还是正常写 axios 来进行网络请求

1
2
3
4
5
6
7
8
axios({
url:'/api/home/getTableData' // 这里也是后端的接口文档
method: 'get'
}).then(res=>{
if(res.data.code === 200) {
tableData.value = res.data.data.tableData
}
})

具体关于 axios 的部分我们写在下一个点中

axios 请求

Axios 是一个基于 promise 网络请求库 (具体的介绍及用法直接看文档Axios 中文文档 | Axios 中文网

基本使用

这里我们直接介绍基本用法(配置对象方式调用)

1
2
3
4
5
6
7
8
9
10
axios({
url:'/api/home/getTableData' //请求的后端接口地址(此处为相对路径,实际会拼接基础路径,通常在 Axios 实例中配置)
method: 'get' // HTTP 请求方法(这里是 get,还可支持 post、put、delete 等)
}).then(res=>{ // 这里的res是我们接收到的数据

//下述的判断都是根据我们接收到的res对象中的数据来写的 这些字段都是后端数据规定好了的
if(res.data.code === 200) {
tableData.value = res.data.data.tableData
}
})

二次封装

但现在面临的问题是 在一个项目中我们可能需要很多相似的请求 请求中很多相似的部分需要写 那么我们可以通过二次封装 axios 来进行优化 通过拦截器 在请求或响应被 then 或 catch 处理前拦截它们 从而实现对发送请求前和响应前的公共部分的一些判断 or 操作

文档中的拦截器如下

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
// 添加请求拦截器
axios.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么
return config;
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error);
}
);

// 添加响应拦截器
axios.interceptors.response.use(
function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response;
},
function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error);
}
);

当然这里面的操作我们都会根据实际情况操作

在实际开发中 我们可以给自定义的 axios 实例添加拦截器

因为有些场景需要多个 Axios 实例(如对接多个后端域名 有不同的认证方式 token) 每个实例我们配置独立的来拦截器 互不干扰

1
const service = axios.create();

括号中可以传入配置对象(如请求基本路径等 后续如果用实例放松请求 URL 会自动拼接在该路径后面)

下面以该项目中的用法为例介绍用法

我们在 api 文件夹下创建 request 文件用于二次封装 axios

1.添加请求拦截器和响应拦截器

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 axios from "axios";
import { ElMessage } from "element-plus";

const service = axios.create();
const NETWORK_ERROR = "网络错误,请稍后再试!";

// 添加请求拦截器
service.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么
return config;
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error);
}
);

// 添加响应拦截器
service.interceptors.response.use((res) => {
const { code, data, msg } = res.data;
if (code === 200) {
return data;
} else {
ElMessage.error(msg || NETWORK_ERROR);
return Promise.reject(msg || NETWORK_ERROR);
}
});

可以看到,响应拦截器中实际上就是我们对

1
2
3
4
5
6
7
.then(res=>{ // 这里的res是我们接收到的数据

//下述的判断都是根据我们接收到的res对象中的数据来写的 这些字段都是后端数据规定好了的
if(res.data.code === 200) {
tableData.value = res.data.data.tableData
}
})

这一段判断数据是否符合规则的代码的处理,这样在后续组件的调用中 就无需关注复杂的拦截逻辑

2.封装请求函数

1
2
3
4
5
6
function request(options) {
options.method = options.method || "get";
return service(options);
}

export default request;

定义了一个 request 函数,用于统一调用 Axios 实例:

给请求参数 options 设置默认方法为 get(若未指定请求方法,则默认用 GET)。

调用 service(options) 发送请求,并返回 Promise 对象(方便后续用 async/await 或 .then() 处理)。

导出 request 函数,供项目中其他文件导入使用。

!注意 后面我们在实际的 api 中都会使用这个 request 也就是会复用这些拦截器中的代码逻辑 这也就是二次封装的便利之处

options 是传递给 request 函数的请求配置对象,它包含了发送 HTTP 请求所需的各种参数(如 URL、请求方法、参数等)。这个对象的结构与 Axios 原生的请求配置完全一致,是前端与后端接口通信的核心参数集合。

3.封装 api

进一步地 我们希望能够封装一下某个特定的 axios 请求的配置要求

即我们上面写的 options

为了满足这个想法 我们在 api 文件夹下面创建一个 api.js 文件

用于整个项目 api 的统一管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//整个项目api的统一管理

import request from "@/api/request.js";

//请求首页左侧表格数据

export default {
getTableData() {
return request({
url: "/api/home/getTableData",
method: "get",
}); //request里面就放的是上面的options
},
};

显然这里我们放的就是 axios 请求里的配置部分 我们把每次请求的具体请求都统一放在这个文件中 方便统一管理

4.全局注册

当然在上述准备工作完成之后 我们需要到 main 里面 去注册挂载一下

这里我们把 api 全局注册 也对应着上面我们说 api 文件是管理整个项目 api 的

1
2
3
import api from "@/api/api";

app.config.globalProperties.$api = api;
  1. **app.config.globalProperties**:
    • 这是 Vue3 提供的一个对象,用于注册全局可访问的属性
    • 所有添加到这个对象上的属性,会被注入到每个组件的实例中,类似于 Vue2 中的 Vue.prototype
  2. **$api**:
    • 是自定义的全局属性名(通常以 $ 开头,这是 Vue 生态的约定,用于区分全局属性和组件自身属性)。
    • 命名为 $api 通常是因为它挂载的是项目中所有接口请求方法的集合对象。

5.实际使用

我们在实际需要请求的组件中使用

1
2
3
import { ref, getCurrentInstance, onMounted } from "vue";

const { proxy } = getCurrentInstance();
  • getCurrentInstance() 是 Vue3 提供的一个内置 API,用于获取当前组件的实例对象(Instance)。
  • 解构出的 proxy 是组件实例的代理对象,它等价于 Vue2 中的 this,可以通过它访问组件的属性、方法、全局注册的对象等。
1
2
3
4
const getTableData = async () => {
const data = await proxy.$api.getTableData();
tableData.value = data.tableData;
};

异步请求

proxy 获取全局 api 然后调用 api 中我们封装的 getTableData() 获得到 data

然后把之前定义的对象 tableData 赋值为我们 data 中拿到的 tableData

就实现了对 axios 的二次封装与调用

引入配置文件

在实际的开发工作中 实际上我们的请求地址可能有很多个 (譬如测试场景 开发场景 它们的 url 就是不同的) 所以我们希望有一个方法能够快速简便地改变 url 所以我们引入配置文件来预设这些地址

在 src 目录下创建一个 config 文件夹用于存放配置文件

index.js

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
/**
* 环境配置文件
* 一般在企业级项目里面有三个环境
* 开发环境
* 测试环境
* 线上环境
*/


// 当前的环境
const env = import.meta.env.MODE || 'prod'

const EnvConfig = {
development: {
baseApi: '/api',
mockApi: 'https://mock.apifox.cn/m1/4068509-0-default/api',
},
test: {
baseApi: '//test.future.com/api',
mockApi: 'https://mock.apifox.cn/m1/4068509-0-default/api',
},
pro: {
baseApi: '//future.com/api',
mockApi: 'https://mock.apifox.cn/m1/4068509-0-default/api',
},
}

export default {
env,
mock:false, // 对于mock测试的开关 即是否使用mock地址 在后续的代码逻辑中可以看到
...EnvConfig[env]
}
  • import.meta.env.MODE:是 Vite 构建工具提供的环境变量,用于获取当前项目运行的环境模式(如 development,test,prod)。
    • 开发环境通常通过 npm run dev 启动,MODEdevelopment
    • 生产环境通过 npm run build 构建,MODEproduction(这里代码用 prod 表示);
    • 测试环境需额外配置,MODEtest
  • || 'prod':设置默认值,若无法获取环境模式,默认使用生产环境(prod),避免配置缺失。
  • 最终 env 变量存储当前环境标识(如 development)。

剩下的就比较易懂了 主要是在这里直接预设好几个地址

对应的 我们对 request 请求进行一些改变让其更符合工程化的要求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function request(options) {
options.method = options.method || "get";
//关于get请求参数的调整 我们都统一成data请求
if (options.method.toLowerCase() === "get") {
options.params = options.data;
}

//对mock的开关做一个处理
let isMock = config.mock; //默认值
if (typeof options.mock !== "undefined") {
isMock = options.mock;
}

//针对环境做一个处理
if (config.env === "prod") {
//不能用mock
service.defaults.baseURL = config.baseApi;
} else {
//开发环境
service.defaults.baseURL = isMock ? config.mockApi : config.baseApi;
}
return service(options);
}

下面分条进行解释

1
2
3
if (options.method.toLowerCase() === "get") {
options.params = options.data;
}

统一处理 GET 请求的参数格式,将开发者传入的 data 参数自动转换为 GET 请求所需的 params 参数,简化请求调用时的参数传递逻辑。

具体来说:

  1. 判断请求方法
    options.method.toLowerCase() === "get" 用于检查当前请求是否为 GET 方法(忽略大小写,如 GetGET 都会被识别)。
  2. 参数转换
    若为 GET 请求,则执行 options.params = options.data
    • 在 Axios 中,GET 请求的参数需要放在 params 属性中(会被自动拼接到 URL 后面,格式为 ?key=value)。
    • 而 POST/PUT 等请求的参数通常放在 data 属性中(作为请求体发送)。
    • 这段代码允许开发者在调用 GET 请求时,也像 POST 那样使用 data 传递参数,然后自动转换为 params,减少记忆负担。
1
2
3
4
5
//对mock的开关做一个处理
let isMock = config.mock; //默认值
if (typeof options.mock !== "undefined") {
isMock = options.mock;
}

上面代码在配置文件中导出了 mock 用于控制是否是 mock 测试 所以我们弄一个开关来处理这个变量

但同时 考虑到某些组件请求时可能有单独的 mock 要求 故而我们在 api 里也定义一个 mock

1
2
3
4
5
6
7
8
getTableData() {
return request({
url: '/home/getTableData',
method: 'get',
mock: false
})
},

来实现完善的 mock 开关(用的是覆盖原值的方式)

1
2
3
4
5
6
7
8
//针对环境做一个处理
if (config.env === "prod") {
//不能用mock
service.defaults.baseURL = config.baseApi;
} else {
//开发环境
service.defaults.baseURL = isMock ? config.mockApi : config.baseApi;
}

根据当前项目环境(生产 / 非生产)动态设置 Axios 实例的基础请求地址

  • 判断是否为生产环境
    if (config.env === 'prod') 检查当前环境是否为生产环境(prod)。
    生产环境通常是项目上线后用户实际使用的环境,必须调用真实后端接口,禁止使用 mock 数据,否则会导致功能异常。
  • 生产环境处理
    service.defaults.baseURL = config.baseApi
    为 Axios 实例的默认基础地址赋值为生产环境的真实接口地址(如 //future.com/api),确保所有请求都指向真实后端服务。
  • 非生产环境处理
    else 分支涵盖开发环境(development)和测试环境(test),这些环境中可能需要使用 mock 数据进行开发或测试。
    service.defaults.baseURL = isMock ? config.mockApi : config.baseApi 是一个三元表达式:
    • isMocktrue(启用 mock),则使用模拟接口地址(config.mockApi,如 Apifox 的 mock 服务地址);
    • isMockfalse(不启用 mock),则使用对应环境的真实接口地址(config.baseApi,如开发环境的 /api 或测试环境的 //test.future.com/api)。

由于我们这里给实例的基础地址赋值了 所以对应的 我们要给实例文件一个 baseURL

1
2
3
const service = axios.create({
baseURL: config.baseApi,
});

需要说明的一点是 上述我们写的 config.xxapi 都是根据当前 config 的 env(环境)自动匹配的对应环境的 api 也就对应着我们在配置文件中分了三种情况写 api

echarts 表格

在此项目中我们实现了三个图表 折线图柱状图饼状图的渲染image-20250915110450957

Apache ECharts 更详实的用法看官方文档

初始化

首先我们引入库

1
npm install echarts

基于准备好的 dom 初始化 echarts 实例

1
2
3
4
5
//譬如我们可以通过document事件找到节点
var myChart = echarts.init(document.getElementById("main"));

//也可以像该项目一样 需要在 HTML 中先定义一个 <div> 节点,并且通过 CSS 使得该节点具有宽度和高度 我们给这个节点添加ref属性 就可以在proxy中找到了
const threeEchart = echarts.init(proxy.$refs["videoEchart"]);

需要注意的是,使用这种方法在调用 echarts.init 时需保证容器已经有宽度和高度了。

初始化的时候,传入该节点,图表的大小默认即为该节点的大小,除非声明了 opts.widthopts.height 将其覆盖。

图表的引入

可以按需引入 也可以全部引入

1
2
3
4
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
import * as echarts from "echarts/core";
// 引入柱状图图表,图表后缀都为 Chart
import { BarChart } from "echarts/charts";

配置表格样式与数据

每一种表格都有相应的配置项 每个配置项包含的内容有样式内容和对应数据

详细见配置手册 Documentation - Apache ECharts

柱状图配置

参数 说明
title 标题组件,用于定义图表的标题。它包含了 text(标题文本)、left(标题水平位置)、top(标题垂直位置)等属性
tooltip 提示框组件,用于展示图表中某个数据项的详细信息。可以设置 trigger(触发类型)、axisPointer(指示器配置)等
legend 图例组件,展示了不同系列的标记、颜色和名字。可以通过 data 属性定义图例的数据数组
grid 网格组件,用于控制图表的网格,包括 left、right、top、bottom、width 和 height 等属性来控制网格的位置和大小
xAxis X 轴配置,通常用于显示类别数据。可以设置 type(轴类型)、data(类别数据数组)、axisLabel(轴标签样式)、axisLine(轴线样式)等
yAxis Y 轴配置,通常用于显示数值数据。可以设置 type(轴类型)、name(轴名称)、axisLabel(轴标签样式)、splitLine(分隔线样式)等
series 系列列表,用于定义图表的数据内容。每个系列通过 type 指定为 ‘bar’ 来表示柱状图。data 属性用于定义系列中的数据点,还可配置 barWidth(柱宽)、barGap(柱间距)等
color 用于定义系列的颜色,可以是一个具体的颜色值,也可以是一个颜色数组,用于多彩柱状图
animation 动画配置,用于设置图表初始化或数据更新时的动画效果。可以设置 duration(动画时长)和 easing(缓动效果)
dataset 数据集配置,用于定义图表的数据源。source 属性用于指定外部数据,transform 属性用于对数据进行处理
toolbox 工具箱组件,提供了数据视图、下载等功能。可以设置 feature 属性来定义工具箱中的功能(如导出图片、数据视图、重置等)
dataZoom 数据区域缩放组件,用于放大和缩小图表的显示区域。可以设置 type(数据区域缩放的类型,如 ‘inside’ 或’slider’)
visualMap 视觉映射组件,用于根据数据值大小对图形的颜色、大小等进行映射。可以设置 min、max(数据的最小值和最大值)和 orient(布局方向)等
itemStyle 柱状图的样式配置,可设置颜色、边框、圆角等(如 borderRadius 可设置柱子圆角)
label 柱状图上的标签配置,可设置是否显示(show)、位置(position)、格式化(formatter)等
markPoint 标记点配置,用于标记最大值、最小值等特定数据点
markLine 标记线配置,用于标记平均值、自定义值等参考线
barCategoryGap 不同类目之间的间距,默认为 ‘20%’
barGap 同类别下系列间的间距,默认为 ‘30%’

折线图配置

参数 说明
title 标题组件,用于定义图表的标题。它包含了 text(标题文本)、left(标题水平位置)、top(标题垂直位置)等属性
tooltip 提示框组件,用于展示图表中某个数据项的详细信息。可以设置 trigger(触发类型)、axisPointer(指示器配置)等
legend 图例组件,展示了不同系列的标记、颜色和名字。可以通过 data 属性定义图例的数据数组。
grid 网格组件,用于控制图表的网格,包括 left、right、top、bottom、width 和 height 等属性来控制网格的位置和大小
xAxis X 轴配置,通常用于显示类目数据。可以设置 type(轴类型)、data(类目数据数组)、axisLabel(轴标签样式)等
yAxis Y 轴配置,通常用于显示数值数据。可以设置 type(轴类型)、name(轴名称)、axisLabel(轴标签样式)等
series 系列列表,用于定义图表的数据内容。对于折线图,type 应设置为 ‘line’。data 属性用于定义系列中的数据点
color 用于定义系列的颜色,可以是一个具体的颜色值,也可以是一个颜色数组,用于多彩折线图
animation 动画配置,用于设置图表初始化或数据更新时的动画效果。可以设置 duration(动画时长)和 easing(缓动效果)
dataset 数据集配置,用于定义图表的数据源。source 属性用于指定外部数据,transform 属性用于对数据进行处理
dataZoom 数据区域缩放组件,用于放大和缩小图表的显示区域。可以设置 type(数据区域缩放的类型,如 ‘inside’ 或’slider’)
visualMap 视觉映射组件,用于根据数据值大小对图形的颜色、大小等进行映射。可以设置 min、max(数据的最小值和最大值)和 orient(布局方向)等
lineStyle 折线样式配置,可以设置折线的宽度、类型、颜色等。例如,{color: ‘#ff0000’, width: 2} 表示红色的折线,宽度为 2
areaStyle 填充样式配置,用于设置折线图下方的填充颜色和透明度。例如,{color: ‘rgba (255, 0, 0, 0.3)’} 表示红色的填充,透明度为 0.3
markLine 或 markPoint 用于在图表中标记特定的数据点或数据线。可以设置 data 属性来定义标记的内容和位置

饼状图配置

参数 说明
title 标题组件,用于定义图表的标题。它包含了 text(标题文本)、left(标题水平位置)、top(标题垂直位置)等属性
tooltip 提示框组件,用于展示图表中某个数据项的详细信息。可以设置 trigger(触发类型)、axisPointer(指示器配置)等
legend 图例组件,展示了不同系列的标记、颜色和名字。可以通过 data 属性定义图例的数据数组
grid 网格组件,用于控制图表的网格,包括 left、right、top、bottom、width 和 height 等属性来控制网格的位置和大小
radius 饼图的半径,可以是绝对值,也可以是相对于容器大小的百分比。可以设置为数组,分别表示内半径和外半径,用于创建环形图
center 饼图的中心位置,可以是绝对值,也可以是百分比。通常设置为 [‘50%’, ‘50%’] 表示居中
series 系列列表,用于定义图表的数据内容。对于饼图,type 应设置为 ‘pie’。data 属性用于定义系列中的数据点,通常包括 value(数据值)和 name(数据项名称)
color 用于定义系列的颜色,可以是一个具体的颜色值,也可以是一个颜色数组,用于多彩饼图
label 标签的样式配置,包括 show(是否显示标签)、position(标签位置)、formatter(标签内容格式化器)等
labelLine 标签直线的样式配置,用于连接标签和对应的扇形块
itemStyle 扇形块的样式配置,可以设置 borderColor(扇形块边框颜色)、borderWidth(扇形块边框宽度)等
emphasis 鼠标悬停或高亮时的系列样式,可以用来设置高亮时的标签、扇形块等的样式
roseType 用于设置是否为南丁格尔图(半径不等的饼图),可以取值 ‘radius’ 或 ‘area’
sort 是否根据数据值进行排序,可以取值 ‘asc’(升序)、’desc’(降序)或 false(不排序)
hoverAnimation 鼠标悬停时的动画效果,可以设置为 true(开启)或 false(关闭)

xAxis 与 yAxis 中的 data 属性用于储存 x,y 轴中显示的数据内容

series 中的数据用于储存图表中每一个点的数据内容(可能是一个柱子 一条线 一块饼)

在该项目中

1
2
3
4
5
6
7
8
xOptions.xAxis.data = orderData.date;
xOptions.series = Object.keys(orderData.data[0]).map((val) => {
return {
name: val,
data: orderData.data.map((item) => item[val]),
type: "line",
};
});
1
Object.keys(orderData.data[0])

Object.keys(对象) 是 JavaScript 的内置方法,用于获取对象中所有自身可枚举属性的键名,并返回一个包含这些键名的数组。

orderData.data[0] 是一个对象(比如 { 销量: 100, 利润: 20 }),那么 Object.keys() 会返回这个对象的所有键名组成的数组(比如 ['销量', '利润'])。

1
2
3
4
5
6
7
xOptions.series = Object.keys(orderData.data[0]).map(val => {
return {
name: val,
data: orderData.data.map(item => item[val]),
type: 'line',
}
})

map() 是数组的方法,用于遍历数组并返回一个新数组,新数组的元素是原数组元素经过回调函数处理后的结果。

代码中用到了两次 map()

  • 外层 map():遍历 Object.keys() 返回的键名数组(比如 ['销量', '利润']),为每个键名生成一个图表系列配置。(因为 series 的数据是若干个点 故而我们给每一个点生成一个图表系列配置)
  • 内层 map():遍历 orderData.data 数组(数据源数组),从每个数据项中提取当前键名对应的值,组成该系列的数据数组。val 是 “要取的属性名”,item[val] 是 “从每条数据中取这个属性名对应的值”,最终通过 map 收集成一个数组,作为当前系列的 data

渲染图表

最后我们需要渲染一下这个图表

1
twoEchart.setOption(xOptions);

响应容器大小的变化

监听图表容器的大小并改变图表大小

在有些场景下,我们希望当容器大小改变时,图表的大小也相应地改变。

比如,图表容器是一个高度为 400px、宽度为页面 100% 的节点,你希望在浏览器宽度改变的时候,始终保持图表宽度是页面的 100%。

这种情况下,可以监听页面的 resize 事件获取浏览器大小改变的事件,然后调用 echartsInstance.resize 改变图表的大小。

这里我们使用的是浏览器原生 API 来监视元素的尺寸变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const observer = ref(null);

//监听页面的变化
//如果监听的容器大小发生变化 改变了之后 会执行回调函数
observer.value = new ResizeObserver(() => {
oneEchart.resize();
twoEchart.resize();
threeEchart.resize();
});

//容器存在
if (proxy.$refs["echart"]) {
observer.value.observe(proxy.$refs["echart"]);
}
if (proxy.$refs["userEchart"]) {
observer.value.observe(proxy.$refs["userEchart"]);
}
if (proxy.$refs["videoEchart"]) {
observer.value.observe(proxy.$refs["videoEchart"]);
}

ResizeObserver 是浏览器原生的一个 API,用于监听元素的尺寸变化(比如容器宽度 / 高度改变)。当被观察的元素大小发生变化时,会触发传入的回调函数。

实现搜索功能

页面结构

实现了一个基于 Element UI 的搜索功能,主要通过表单输入、参数传递和接口调用来完成数据筛选

1
2
3
4
5
6
<el-form-item lable="请输入">
<el-input placeholder="请输入用户名" v-model="formInline.keyWord"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</el-form-item>

这是一个 element UI 的表单按钮组件 点击按钮时会触发 handlesearch 方法 这是搜索功能的入口

并且用一个 v-model 双向绑定了我们需要的输入数据 便于我们后续对搜索关键词的处理

处理输入数据

1
2
3
4
5
6
7
8
9
// 存储表单输入的搜索关键词
const formInline = reactive({
keyWord: "", // 绑定到输入框的v-model
});

// 存储接口请求参数
const config = reactive({
name: "", // 要搜索的姓名关键词
});
  • 使用reactive创建响应式对象,确保数据变化能被 Vue 追踪
  • formInline.keyWord用于绑定搜索输入框的值
  • config对象存储实际传给接口的参数

定义 formInline.keyWord 为储存 v-model 绑定的输入数据

这个 config 的作用是我们需要在点击搜索后 将输入数据扔回 axios 请求中返回匹配的数据 也就是说 这个 config 是需要传入 axios 请求的

搜索逻辑

1
2
3
4
5
6
const handleSearch = () => {
// 将表单输入的关键词赋值给接口参数
config.name = formInline.keyWord;
// 调用获取数据的方法
getUserData();
};
  • 这是搜索按钮的点击事件处理函数
  • 核心逻辑是将用户输入的搜索关键词(formInline.keyWord)赋值给接口请求参数(config.name
  • 然后调用getUserData方法重新获取数据

像上面说的 我们为 name 赋值 从而实现在数据中搜索 keyword

那么按照之前的逻辑 我们只需要重写一下 getUserData 让其处理一下这个传入的参数就可以了

数据请求与渲染

首先我们给 getUserData 的 api 加一个返回值 让其可以处理我们传入的 config

1
2
3
4
5
6
7
8
getUserData(data) {
return request({
url: '/home/getUserData',
method: 'get',
mock: false,
data
})
},

那么对应地 我们也需要在组件代码里传入这个参数

我们根据传入的 config 的 name 去数据中匹配元素 从而渲染到 tableData 里 这就是实现搜索结果的详细过程

1
2
3
4
5
6
7
8
9
10
11
const getUserData = async () => {
// 调用API接口,传入包含搜索关键词的config参数
let data = await proxy.$api.getUserData(config);

// 处理返回的数据,映射到表格数据
tableData.value = data.list.map((item) => ({
...item,
// 转换性别为文字显示
sexLabel: item.sex === 1 ? "男" : "女",
}));
};

在写完这里的时候我有一个疑问 我们为了实现这个功能在这里添加了一个需要传入的参数 config 那么当一开始要渲染数据的时候是怎么处理的呢?

实际上 config 这里的 name 的初始化是空字符串 所以是不会对初始数据请求造成影响的

实现分页功能

实际上 这里的分页功能跟搜索功能是非常像的 都是用户进行一个操作过后 需要根据这个操作重新渲染页面 所以我们依旧把分页的相关数据储存在 config 内 是同理的 在需要渲染的时候重新传入 重新请求数据即可

页面结构

1
2
3
4
5
6
7
8
<el-pagination
class="pager"
background
layout="prev, pager, next"
:total="config.total"
size="small"
@current-change="handleChange"
/>
  • 这是 Element UI 提供的分页组件<el-pagination>
  • 核心属性说明:
    • :total="config.total":绑定总数据条数,告诉分页组件一共有多少条数据(从后端接口获取)
    • layout="prev, pager, next":定义分页组件的布局,包含 “上一页”、”页码”、”下一页”
    • @current-change="handleChange":页码改变时触发的事件,会把新页码作为参数传递
    • size="small":设置分页组件为小尺寸
    • background:为分页按钮添加背景色

分页参数管理

1
2
3
4
5
const config = reactive({
name: "", // 搜索关键词
total: 0, // 总数据条数(由后端返回)
page: 1, // 当前页码,默认第一页
});
  • config 对象中专门用两个属性管理分页相关数据:
    • page:记录当前需要展示的页码,会传递给后端接口
    • total:存储后端返回的总数据条数,用于分页组件计算总页数

这个传入逻辑跟刚才的搜索功能实现的逻辑是一样的

页码切换逻辑

1
2
3
4
5
const handleChange = (val) => {
// val是新的页码(由分页组件传递过来)
config.page = val; // 更新当前页码
getUserData(); // 重新请求数据
};
  • 这是页码改变时的事件处理函数
  • 当用户点击分页组件的 “上一页”、”下一页” 或具体页码时:
    1. 分页组件会把选中的新页码通过val参数传递给handleChange
    2. 更新config.page为新页码
    3. 调用getUserData()重新请求数据(此时会带上新的页码参数)

实现删除功能

页面结构

1
2
3
4
5
6
<template #="scope">
<el-button type="primary" size="small">编辑</el-button>
<el-button type="primary" size="small" @click="handleDelete(scope.row)"
>删除</el-button
>
</template>
  • 这是表格中每行数据的操作按钮区域(使用了 Element UI 的插槽语法)

  • scope.row 表示当前行的数据对象(包含该用户的完整信息,如idname等)

    scope 是一个对象,由表格组件自动传入,包含当前行的所有信息,主要属性有:

    scope.row:当前行的数据对象(最重要的属性),比如包含 idnameage 等字段。

    scope.$index:当前行的索引(从 0 开始)。

  • 点击 “删除” 按钮时,会调用handleDelete方法并传入当前行数据(scope.row

API 封装

1
2
3
4
5
6
7
8
9
10
11
12
deleteUser(data) {
return request({
url: '/user/deleteUser',
method: 'post',
mock: false,
data
})
}

//在mock.js中
Mock.mock(/api\/user\/deleteUser/,"post",userApi.deleteUser)

删除逻辑

在 mockData 中的 user.js 上添加逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 拦截删除请求并使用Mock处理
Mock.mock(/api\/user\/deleteUser/, "post", userApi.deleteUser);

// 删除用户的具体处理逻辑
deleteUser: (config) => {
// 从请求体中解析出id
const { id } = JSON.parse(config.body);

// 验证参数
if (!id) {
return {
code: -999,
message: "参数不正确",
};
} else {
// 从用户列表中过滤掉要删除的用户(模拟数据库删除操作)
List = List.filter((u) => u.id !== id);
return {
code: 200,
message: "删除成功",
};
}
};
  1. 从请求体中解析出要删除的用户 ID(id
  2. 验证 ID 是否存在:
    • 若不存在,返回错误信息(code: -999
    • 若存在,从用户列表(List)中删除该 ID 对应的用户(filter方法过滤掉该用户)
  3. 返回删除成功的响应(code: 200

在 user.vue 组件中设置交互逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ElMessage, ElMessageBox } from "element-plus"; // 这里我们引入它们作为信息弹窗

const handleDelete = (data) => {
ElMessageBox.confirm("此操作将永久删除该用户, 是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
await proxy.$api.deleteUser({ id: data.id });
ElMessage({
showClose: true,
type: "success",
message: "删除成功",
});
getUserData();
})
.catch(() => {
ElMessage({
type: "info",
message: "已取消删除",
});
});
};

我们传入 id 便于进行 id 过滤逻辑

同时最后我们需要重新 getUserData 渲染一下以得到删除后的数据

实现新增与编辑功能

总页面结构

对于新增功能 我们希望实现的功能是当我们点击新增按钮的时候出现一个表单用于添加信息 这些信息最终会被渲染到数据中

显然我们首先需要一个表单组件来作为新增(以及后面的编辑)操作的组件

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
51
52
53
54
55
<el-dialog
v-model="dialogVisible"
:title="action == 'add' ? '新增用户' : '编辑用户'"
width="35%"
:before-close="handleClose"
>
<!--需要注意的是设置了:inline="true",
会对el-select的样式造成影响,我们通过给他设置一个class=select-clearn
在css进行处理-->
<el-form :inline="true" :model="formUser" :rules="rules" ref="userForm">
<el-row>
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="formUser.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="年龄" prop="age">
<el-input v-model.number="formUser.age" placeholder="请输入年龄" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item class="select-clearn" label="性别" prop="sex">
<el-select v-model="formUser.sex" placeholder="请选择">
<el-option label="男" value="1" />
<el-option label="女" value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出生日期" prop="birth">
<el-date-picker
v-model="formUser.birth"
type="date"
placeholder="请输入"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-form-item label="地址" prop="addr">
<el-input v-model="formUser.addr" placeholder="请输入地址" />
</el-form-item>
</el-row>
<el-row style="justify-content: flex-end">
<el-form-item>
<el-button type="primary" @click="handleCancel">取消</el-button>
<el-button type="primary" @click="onSubmit">确定</el-button>
</el-form-item>
</el-row>
</el-form>
</el-dialog>

对话框部分

1
2
3
4
5
6
<el-dialog
v-model="dialogVisible"
:title="action == 'add' ? '新增用户' : '编辑用户'"
width="35%"
:before-close="handleClose"
>
  1. v-model="dialogVisible"

    • 双向绑定一个布尔值变量 dialogVisible,用于控制对话框的显示与隐藏。

    • dialogVisibletrue 时对话框显示,为 false 时隐藏。

    • const dialogVisible = ref(false);
      
      1
      2
      3
      4
      5

      现在我们给 新增 or 编辑 or 取消 or 确定 button 对应的 click 事件里写上这个改变状态逻辑

      ```js
      dialogVisible.value = true;
  2. :title="action == 'add' ? '新增用户' : '编辑用户'"

    • 通过动态绑定(:v-bind 的简写)设置对话框标题。

    • 使用三元表达式根据 action 变量的值动态切换标题:

      • action'add' 时,标题显示为 “新增用户”

      • 其他情况(通常是 'edit')时,标题显示为 “编辑用户”

      • const action = ref("add");
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23

        显然 对应地 我们需要在编辑 新增 按钮的点击事件中新增逻辑 来改变 action

        3. `width="35%"`

        - 设置对话框的宽度为页面宽度的 35%,也可以使用像素值(如 `width="500px"`)。

        4. `:before-close="handleClose"`

        - 绑定对话框关闭前的回调函数 `handleClose`。
        - 通常用于在关闭对话框前执行一些操作(如数据验证、确认提示等),若需要阻止关闭,可以在函数中返回 `false`。

        #### 新增(编辑)表单

        这里我们主要关注

        ```html
        <el-form
        :inline="true"
        :model="formUser"
        :rules="rules"
        ref="userForm"
        ></el-form>
  • :inline="true"
    控制表单的布局方式:

    • true:表单元素将水平排列( inline 模式),标签和输入框在同一行,节省垂直空间,适合搜索表单等场景。
    • false(默认):表单元素垂直排列,每个表单项单独占一行,适合复杂表单(如用户注册、信息编辑)。
  • :model="formUser"
    绑定表单的数据对象,是表单的 “数据源”:

    • formUser 通常是一个响应式对象(如 reactiveref 创建),存储表单中所有字段的值。
    • 表单内的输入组件(如 <el-input>)会通过 v-modelformUser 的属性双向绑定。 这个双向绑定的数据可以供我们对其进行操作。
    • const formUser = reactive({});
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      - **`:rules="rules"`**

      绑定表单的校验规则,实现表单验证功能:

      `rules` 是一个对象,定义了每个字段的校验规则(如必填、格式验证、长度限制等)。

      当表单提交或输入变化时,Element UI 会自动根据 `rules` 校验 `formUser` 中的数据。

      ```js
      //表单校验规则
      const rules = reactive({
      name: [{ required: true, message: "姓名是必填项", trigger: "blur" }],
      age: [
      { required: true, message: "年龄是必填项", trigger: "blur" },
      { type: "number", message: "年龄必须是数字" },
      ],
      sex: [{ required: true, message: "性别是必选项", trigger: "change" }],
      birth: [{ required: true, message: "出生日期是必选项" }],
      addr: [{ required: true, message: "地址是必填项" }],
      });
  • ref="userForm"

    给表单组件定义一个引用标识,用于在代码中直接操作表单:

    通过 ref 可以调用表单的内置方法(如手动触发校验、重置表单等)。

对话框与表单事件

表单关闭与取消

1
2
3
4
5
6
const handleClose = () => {
//获取表单重置表单
dialogVisible.value = false;

proxy.$refs["userForm"].resetFields();
};
1
2
3
4
5
const handleCancel = () => {
dialogVisible.value = false;

proxy.$refs["userForm"].resetFields();
};

相同点是当我们取消或者关闭 表单消失之后我们都应该把原表单重置一下

新增功能

对于新增功能 我们需要将创建的这个 user 的数据最终渲染到数据中

新增 API

mockdata 部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 增加用户
* @param name, addr, age, birth, sex
* @return {{code: number, data: {message: string}}}
*/
createUser: config => {
const { name, addr, age, birth, sex } = JSON.parse(config.body)
List.unshift({
id: Mock.Random.guid(),
name: name,
addr: addr,
age: age,
birth: birth,
sex: sex
})
return {
code: 200,
data: {
message: '添加成功'
}
}
},

api 与 mock 部分

1
2
3
4
5
6
7
8
9
10
11
 addUser(params) {
return request({
url: '/user/addUser',
method: 'post',
data: params
})
},


Mock.mock(/user\/addUser/, "post", userApi.createUser)

上面这一部分是模拟的后端逻辑 这里就不介绍了

首先我们给新增按钮绑定一个点击事件 handleclick

1
2
3
4
const handleAdd = () => {
dialogVisible.value = true;
action.value = "add";
};

当我们填完数据点击确定后 需要上传这个数据 所以我们给确定按钮绑定一个点击事件 onSubmit

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
const onSubmit = () => {
proxy.$refs["userForm"].validate(async (valid) => {
if (valid) {
let res = null;
formUser.birth = timeFormat(formUser.birth);
if (action.value == "add") {
res = await proxy.$api.addUser(formUser);
}
//这一段逻辑是用来处理编辑部分的
// else {
// res = await proxy.$api.editUser(formUser)

//}
if (res) {
dialogVisible.value = false;
proxy.$refs["userForm"].resetFields();
getUserData();
}
} else {
ElMessage({
showClose: true,
type: "error",
message: "请填写正确的数据",
});
}
});
};

在 Element UI/Element Plus 的表单组件(<el-form>)中,validate 是一个核心方法,用于触发表单的验证逻辑,检查表单字段是否符合预设的校验规则。 即去匹配规定的 rules 部分 会返回一个布尔值

其他的逻辑还是比较简单的 就不再赘述了

编辑功能

编辑 API

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
/**
* 修改用户
* @param id, name, addr, age, birth, sex
* @return {{code: number, data: {message: string}}}
*/
updateUser: (config) => {
const { id, name, addr, age, birth, sex } = JSON.parse(config.body);
const sex_num = parseInt(sex);
List.some((u) => {
if (u.id === id) {
u.name = name;
u.addr = addr;
u.age = age;
u.birth = birth;
u.sex = sex_num;
return true;
}
});
return {
code: 200,
data: {
message: "编辑成功",
},
};
};

模拟后端数据处理 其余部分跟新增功能很像 这里不再写

页面部分

1
2
3
4
5
6
7
8
<template #="scope">
<el-button type="primary" size="small" @click="handleEdit(scope.row)">
编辑
</el-button>
<el-button type="primary" size="small" @click="handleDelete(scope.row)"
>删除</el-button
>
</template>
  • <template #="scope">:这是 Vue 的具名插槽语法(#v-slot 的简写),用于接收表格组件传递的当前行数据。

    • scope 是一个对象,包含了当前行的相关信息,其中 scope.row 表示当前行的数据对象(即表格中这一行对应的完整数据)。

    我们通过插槽把该行的数据对象拿到

添加一个编辑点击事件

1
2
3
4
5
6
7
8
9
10
11
const handleEdit = (data) => {
action.value = 'edit'

dialogVisible.value = true

// Object.assign(formUser, { ...data, sex: '' + data.sex })

nextTick(() => {
Object.assign(formUser, {...data,sex:'' + data.sex})
})

将表格中选中的行数据(待编辑的数据)赋值给表单绑定的对象,从而实现编辑时的 “数据回显” 功能

可以看到这里我们引入了 nextTick 原因是我们在实际使用的过程中发现 当我们点击编辑 取消后再点击新增 原来编辑的用户数据会出现在新表单中 这实际上因为此时 DOM 还未更新 弹窗还没有完成初始化渲染 所以我们需要等待一下再替换数据

  • 为什么需要 nextTick
    这是解决 Vue 中 “DOM 更新时机” 问题的关键:
    • dialogVisible 设为 true 时,弹窗组件开始渲染,但这个渲染过程是异步的(Vue 会将 DOM 更新操作放入队列,批量执行)。
    • 如果直接在设置 dialogVisible 后立即填充数据,此时弹窗可能还未完成渲染,表单组件(如输入框、下拉框)尚未初始化。
    • nextTick 会等待当前 DOM 更新循环完成后再执行回调函数,确保弹窗和表单组件已经渲染完毕,此时填充数据才能正确触发表单的双向绑定,实现数据回显。

简单说:nextTick 保证了数据填充操作是在弹窗和表单完全渲染后执行的,避免出现 “数据已更新但表单未显示” 的问题。

最后我们需要在确定键的 submit 中完善编辑的逻辑

1
2
3
4
5
if (action.value == "add") {
res = await proxy.$api.addUser(formUser);
} else {
res = await proxy.$api.editUser(formUser);
}

实现标签导航功能

pinia 实现状态管理

标签上的内容我们需要根据左侧侧边栏的点击来传递状态 跨组件通信 我们需要使用 pinia 管理

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
import { current } from "hexo/dist/plugins/helper/is";
import { defineStore } from "pinia";

import { ref, computed } from "vue";
function initState() {
return {
isCollapse: false, //侧边栏是否收起
tags: [
{
path: "/home",
name: "home",
label: "首页",
icon: "home",
},
],
currentMenu: null,
};
}

export const useAllDataStore = defineStore("allData", () => {
//refstate属性
//computed getters
//function actions

const state = ref(initState());

function selectMenu(val) {
if (val.name === "home") {
state.value.currentMenu = null;
} else {
let index = state.value.tags.findIndex((item) => item.name === val.name);
index === -1 ? state.value.tags.push(val) : "";
}
}

function undateTags(tag) {
let index = state.value.tags.findIndex((item) => item.name === tag.name);
state.value.tags.splice(index, 1);
}

return {
state,
selectMenu,
undateTags,
};
});

我们新初始化了两个状态 分别是当前标签 tags 和 当前选择的菜单 currentMenu 并且返回了一个选择菜单和添加标签的方法

  1. 初始化状态(initState)

    • tags 数组:存储所有标签页数据,初始只包含「首页」标签

      • 每个标签包含 path(路由路径)、name(路由名称,唯一标识)、label(显示文本)、icon(图标)
    • currentMenu:记录当前选中的菜单(暂时未在标签功能中直接使用)

    • isCollapse:侧边栏折叠状态(与标签功能无关)

  2. 核心方法

    • selectMenu(val):添加标签或切换标签

    • function selectMenu(val) {
        if (val.name === "home") {
          state.value.currentMenu = null;
        } else {
          let index = state.value.tags.findIndex(
            (item) => item.name === val.name
          );
          index === -1 ? state.value.tags.push(val) : "";
        }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 如果是「首页」(`val.name === 'home'`),不重复添加
      - 非首页标签:先检查 `tags` 中是否已存在(通过 `findIndex` 判断),不存在则添加到 `tags` 数组

      - undateTags(tag):删除标签

      - ```js
      function undateTags(tag) {
      let index = state.value.tags.findIndex((item) => item.name === tag.name);
      state.value.tags.splice(index, 1);
      }
      - 找到要删除的标签索引(通过 `name` 匹配),使用 `splice` 从 `tags` 数组中移除

这是标签导航功能的核心逻辑 负责管理标签数据的增删和状态维护

标签组件

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<script setup>

import { useRoute,useRouter } from 'vue-router'
import { ref,computed } from 'vue'
import { useAllDataStore } from '@/store'
import { ro } from 'element-plus/es/locales.mjs'

const store = useAllDataStore()

const tags = computed(()=>store.state.tags)

const route = useRoute()
const router = useRouter()

const handleMenu = (tag) => {
router.push(tag.name)

store.selectMenu(tag)
}

const handleClose = (tag,index) => {
//通过pinia管理
store.undateTags(tag)

//如果点击关闭的tag不是当前页面的话
if(tag.name !== route.name){
return
}

if (index === store.state.tags.length) {
store.selectMenu(tags.value[index - 1])
router.push(tags.value[index - 1].name)
}
else {
store.selectMenu(tags.value[index])
router.push(tags.value[index].name)

}

}

</script>

<template>
<div class="tags">
<el-tag
v-for="(tag,index) in tags"
:key="tag.name"
:closable="tag.name !== 'home'"
:effect="route.name===tag.name ? 'dark':'plain'"
@click = "handleMenu(tag)"
@close="handleClose(tag,index)"
>
{{ tag.label }}
</el-tag>
</div>
</template>

<style lang="less" scoped>
.tags{
margin: 20px 0 0 20px;
}
.el-tag{
margin-right: 10px;
}
</style>
  1. 数据关联
    • 通过 computed(() => store.state.tags) 将 Pinia 中的 tags 数组映射为组件内的响应式数据,实现数据联动
    • 使用 route.name 获取当前路由名称,用于判断哪个标签是「活跃状态」
  2. 标签渲染(template)
    • v-for 遍历 tags 数组,渲染 el-tag 组件
    • :closable="tag.name !== 'home'":首页标签不可关闭,其他标签可关闭
    • :effect:根据当前路由是否与标签匹配,设置不同样式(dark 表示活跃,plain 表示非活跃)
  3. 交互逻辑
    • handleMenu(tag):点击标签时触发
      • 调用 router.push(tag.name) 跳转到对应路由
      • 调用 store.selectMenu(tag) 同步标签状态(如果是新标签则添加)
    • handleClose(tag, index):关闭标签时触发
      • 调用 store.undateTags(tag) 从仓库中删除标签
      • 如果关闭的是「当前活跃标签」,需要自动切换到其他标签:
        • 若删除的是最后一个标签,切换到前一个标签
        • 否则切换到当前索引位置的下一个标签

标签和路由之间的关系

标签(Tags)和路由(Router)是这个导航功能中紧密关联、相互配合的两个核心部分,它们的关系可以概括为:标签是路由的 “可视化表现”,路由是标签的 “跳转目标”。具体来说,它们通过 “路由名称(name)” 实现双向绑定,协同完成页面导航功能。

标签和路由的关联基于一个关键字段 ——路由名称(name,这是两者建立联系的 “唯一标识”。

在路由配置中,每个路由都会有一个唯一的 name,例如:

标签(tags 数组中的元素)的结构与路由一一对应,其中 name 字段直接对应路由的 name

1
2
3
4
5
6
7
// 标签数据结构(与路由的name绑定)
{
path: '/user', // 对应路由的path
name: 'user', // 对应路由的name(关键关联)
label: '用户管理', // 来自路由的meta.label
icon: 'user' // 来自路由的meta.icon
}

结论name 是标签和路由的 “桥梁”,通过它可以明确 “哪个标签对应哪个路由”。

标签和路由的联动体现在用户操作的全流程中,相互触发、相互同步:

  • 当用户点击左侧菜单(如 “用户管理”)时:

    1. 菜单组件会将路由信息(包含 namepathlabel 等)传递给 Pinia 的 selectMenu 方法。
    2. selectMenu 方法检查该 name 对应的标签是否已存在,不存在则添加到 tags 数组(生成新标签)。
    3. 同时,路由系统会通过 router.push(menu.name) 跳转到对应的路由页面。

    结果:标签栏新增标签,页面跳转到对应路由,两者同步更新。

  • 当用户点击标签栏中的某个标签(如 “用户管理” 标签)时:

    1. 标签组件的 handleMenu 方法被触发,获取该标签的 name(如 user)。
    2. 调用 router.push(tag.name),路由系统跳转到 nameuser 的路由页面。
    3. 路由跳转后,route.name 会更新为当前路由的 name,标签组件通过 route.name === tag.name 判断哪个标签为 “活跃状态”,并高亮显示。

    结果:标签点击触发路由跳转,路由状态反作用于标签的显示样式。

  • 当用户关闭某个标签(如 “用户管理”)时:

    1. 如果关闭的是 “当前活跃标签”(即标签的 name 等于 route.name),则需要自动切换到其他标签。
    2. 标签组件会根据剩余标签的索引,调用 router.push(其他标签的name),跳转到对应的路由页面。
    3. 路由跳转后,route.name 更新,标签的活跃状态也随之更新。

    结果:标签删除后,路由自动切换到合理的页面,避免页面空白。

  • 如果用户不通过标签,而是直接修改浏览器 URL(如输入 /user),路由系统会触发跳转:

    1. 路由跳转后,route.name 变为 user
    2. 此时左侧菜单或其他组件可以监听路由变化(通过 watch(route, ...)),调用 selectMenu 方法。
    3. selectMenu 方法会自动添加 nameuser 的标签(如果不存在)。

    结果:路由变化会自动同步到标签栏,确保标签与当前页面一致。

实现面包屑功能

页面结构

1
2
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="current" :to="current.path">{{ current.label }}</el-breadcrumb-item>

现在我们需要传入当前的菜单 故而需要跨组件通信 使用 pinia 储存

数据处理

对于当前的菜单 我们还是使用之前实现标签导航功能的方法 记录一下选择菜单即可

在 store 中 首先我们在 initState 中定义一个 currentMenu 这里就不写了

在 selectMenu 方法中 更新一下这个数据

1
2
3
4
5
6
7
8
9
function selectMenu(val) {
if (val.name === "home") {
state.value.currentMenu = null; // 不用加到面包屑
} else {
state.value.currentMenu = val; // 更新一下
let index = state.value.tags.findIndex((item) => item.name === val.name);
index === -1 ? state.value.tags.push(val) : "";
}
}

最后我们需要在 commonHeader 中 将 current 改成响应式依赖的数据即可

1
const current = computed(() => store.state.currentMenu);

实现登录功能

登录页面的静态结构搭建

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
51
52
53
54
<template>
<div class="body-login">
<el-form :model="loginForm" class="login-container">
<h1>欢迎登录</h1>
<el-form-item>
<el-input
type="input"
placeholder="请输入账号"
v-model="loginForm.username"
></el-input>
</el-form-item>
<el-form-item>
<el-input
type="password"
placeholder="请输入密码"
v-model="loginForm.password"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
</div>
</template>

<style scoped lang="less">
.body-login {
width: 100%;
height: 100%;
background-image: url("../assets/images/background.png");
background-size: 100%;
overflow: hidden;
}

.login-container {
width: 350px;
background-color: #fff;
border: ipx solid #eaeaea;
border-radius: 15px;
padding: 35px 35px 15px 35px;

box-shadow: 0 0 25px #cacaca;
margin: 250px auto;
h1 {
text-align: center;
margin-bottom: 20px;
color: #504550;
}
}

:deep(.el-form-item__content) {
justify-content: center;
}
</style>

这里我们双向绑定一个 loginForm 作为响应式的登录表单对象,用于绑定页面上的用户名和密码输入框

在实际开发中 当我们接受到输入的内容之后会发送网络请求让后端做账号密码的判断 当然这里我们是用 mock 数据模拟的 写在这里便于我们理解整个登录功能的实现

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import Mock from "mockjs";
export default {
getMenu: (config) => {
const { username, password } = JSON.parse(config.body);
// 先判断用户是否存在
// 判断账号和密码是否对应
//menuList用于后面做权限分配,也就是用户可以展示的菜单
if (username === "admin" && password === "admin") {
return {
code: 200,
data: {
menuList: [
{
path: "/home",
name: "home",
label: "首页",
icon: "house",
url: "Home",
},
{
path: "/mall",
name: "mall",
label: "商品管理",
icon: "video-play",
url: "Mall",
},
{
path: "/user",
name: "user",
label: "用户管理",
icon: "user",
url: "User",
},
{
path: "other",
label: "其他",
icon: "location",
children: [
{
path: "/page1",
name: "page1",
label: "页面1",
icon: "setting",
url: "Page1",
},
{
path: "/page2",
name: "page2",
label: "页面2",
icon: "setting",
url: "Page2",
},
],
},
],
token: Mock.Random.guid(),
message: "获取成功",
},
};
} else if (username === "xiaoxiao" && password === "xiaoxiao") {
return {
code: 200,
data: {
menuList: [
{
path: "/home",
name: "home",
label: "首页",
icon: "house",
url: "Home",
},
{
path: "/user",
name: "user",
label: "用户管理",
icon: "user",
url: "User",
},
],
token: Mock.Random.guid(),
message: "获取成功",
},
};
} else {
return {
code: -999,
data: {
message: "密码错误",
},
};
}
},
};

我们登录后想要做的事是

1.跳转到 home 页面

2.这个 home 页面的左侧菜单数据需要跟登录的用户一一对应

也就是说 当我们拿到后端放回的用户字段后 需要传递到左侧菜单中实现对应 跨组件通信故而我们需要使用 pinia

pinia 传递数据实现对应

看 mock 数据可以看出来传递了 menulist 和 token 两个字段 我们把这些定义到 store 中实现跨组件通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function initState() {
return {
isCollapse: false, //侧边栏是否收起
tags: [
{
path: "/home",
name: "home",
label: "首页",
icon: "home",
},
],
currentMenu: null,
menuList: [],
token: "",
routeList: [],
};
}

我们在 useAllData 里添加一个方法用来实现 menulist 的更新渲染

1
2
3
function updateMenuList(menu) {
state.value.menuList = menu;
}

现在我们在 login.vue 的登录方法中添加这个逻辑

同时我们顺便实现一下登录成功后跳转到 home 页面的逻辑 这里是直接用 router.push 实现的

1
2
3
4
5
6
7
8
9
10
11
const handleLogin = async () => {
const res = await proxy.$api.getMenu(loginForm);
if (res) {
//拿到菜单以后
//把得到的用户字段传递到store中
store.updateMenuList(res.menuList);
store.state.token = res.token;
//通过push方法实现页面跳转
router.push("/home");
}
};

最后,我们只需要将左侧菜单栏的数据替换成动态数据即可

1
2
3
4
const store = useAllDataStore();
const list = computed(() => {
return store.state.menuList;
});

这里我们用 computed 来实现响应式依赖

实现动态路由进行路由鉴权

主要是用来处理多账号(权限不同)登录的路由问题

动态路由介绍:https://router.vuejs.org/zh/guide/advanced/dynamic-routing.html#%E5%8A%A8%E6%80%81%E8%B7%AF%E7%94%B1

主要实现了基于后端返回菜单列表(账号权限不同 菜单列表也不同)的动态路由添加功能

实际上和路由守卫比较像 都是防止访问到无权限访问到的地方

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
function addMenu(router, type) {
const menu = state.value.menuList;
const module = import.meta.glob("../views/**/*.vue");
const routeArr = [];
menu.forEach((item) => {
if (item.children) {
item.children.forEach((val) => {
let url = `../views/${val.url}.vue`;
val.component = module[url];
routeArr.push(...item.children);
});
} else {
let url = `../views/${item.url}.vue`;
item.component = module[url];
routeArr.push(item);
}
});
state.value.routeList = [];

let routers = router.getRoutes();

routers.forEach((item) => {
if (item.name === "main" || item.name === "login" || item.name === "404") {
return;
} else {
router.removeRoute(item.name);
}
});
routeArr.forEach((item) => {
state.value.routeList.push(router.addRoute("main", item));
});
}

现在我们分段解释一下每一个部分实现的功能

1
2
3
const menu = state.value.menuList;
const module = import.meta.glob('../views/**/*.vue')
const routeArr = [];

需要传入 router 对象进来

menu 返回后端判断完用户权限后返回的菜单列表

module 使用的是 vite 的动态导入 Vite 支持使用特殊的 import.meta.glob 函数从文件系统导入多个模块

routeArr 是格式化后的路由数组 也就是我们后面添加的路由部分

格式化后端数据为前端配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//格式化菜单路由
menu.forEach(item => {
//如果菜单有children
if(item.children){
//把children遍历格式化
item.children.forEach(val => {
let url=`../views/${val.url}.vue`
//这里通过url取出对应的组件
val.component=module[url]
})
//需要注意的是我们只需要为item.children中的菜单添加路由,所以我们把它解构出来
routeArr.push(...item.children)
}else{
let url=`../views/${item.url}.vue`
item.component=module[url]
routeArr.push(item)
}

这段代码的核心功能是将后端返回的菜单数据格式化为前端路由所需的配置结构,主要处理了菜单与组件的映射关系,并构建可直接用于路由注册的数组。以下是详细解释:

遍历后端返回的原始菜单列表(menu),根据菜单是否包含子菜单(children)进行不同处理,最终生成符合前端路由规范的配置数组(routeArr)。

1
2
// 遍历原始菜单列表中的每个菜单项
menu.forEach(item => {
  • menu 通常是后端返回的原始菜单数据,包含路由路径、菜单名称等信息
  • forEach 循环逐个处理每个菜单项
1
2
// 判断当前菜单项是否包含子菜单
if(item.children){
  • 检查当前菜单项是否有 children 属性(即是否为包含子菜单的父级菜单)
1
2
3
4
5
6
// 遍历子菜单,为每个子菜单配置组件
item.children.forEach((val) => {
let url = `../views/${val.url}.vue`;
// 通过url路径从导入的组件集合中匹配对应的组件
val.component = module[url];
});
  • 若有子菜单,遍历每个子菜单(val
  • 拼接组件路径:val.url 是后端返回的路由标识(如 “dashboard/index”),拼接后得到完整的组件路径(如 “../views/dashboard/index.vue”)
  • 绑定组件:module 是通过 import.meta.glob 导入的所有组件集合,通过拼接的 url 找到对应的组件并赋值给 val.componentcomponent 是 Vue Router 路由配置中必须的属性,用于指定当前路由对应的页面组件,完成路由与组件的映射
1
2
3
    // 将处理好的子菜单解构添加到路由数组(只保留子菜单作为可访问路由)
routeArr.push(...item.children)
}
  • 父级菜单通常只是分组作用,实际可访问的路由是子菜单,因此将处理好的子菜单(item.children)添加到 routeArr
  • 使用扩展运算符(...)是为了将子菜单数组展开,避免形成嵌套数组
1
2
3
4
5
6
7
else{
// 无子菜单的情况,直接为当前菜单项配置组件
let url=`../views/${item.url}.vue`
item.component=module[url]
// 将处理好的菜单项添加到路由数组
routeArr.push(item)
}
  • 若当前菜单项没有子菜单,直接处理该菜单项本身

  • 同样拼接组件路径并绑定组件,然后将其添加到 routeArr

  • 组件映射:通过后端返回的 url 标识,精准匹配到前端对应的 Vue 组件

  • 路由格式化:将原始菜单数据转换为符合 Vue Router 要求的结构(包含 component 等关键属性)

  • 层级处理:区分父子菜单,只将实际可访问的子菜单(或无子菜单的菜单项)加入路由数组

处理完成后,routeArr 就包含了所有可直接用于 router.addRoute() 的路由配置对象,为后续动态添加路由奠定了基础。

路由的清理和重新添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
state.value.routeList = [];

let routers = router.getRoutes();

routers.forEach((item) => {
if (item.name === "main" || item.name === "login" || item.name === "404") {
return;
} else {
router.removeRoute(item.name);
}
});
routeArr.forEach((item) => {
state.value.routeList.push(router.addRoute("main", item));
});

这段代码主要实现了路由的清理与重新添加,是动态路由管理的核心步骤,确保路由状态与当前用户权限保持一致。以下是详细解析:

1
state.value.routeList = [];
  • 清空存储路由记录的数组(routeList),为后续存储新路由做准备

获取并清理现有路由

1
2
3
4
5
6
7
8
9
10
11
let routers = router.getRoutes(); // 获取当前所有已注册的路由

routers.forEach((item) => {
// 保留基础路由(main框架页、login登录页、404页面)
if (item.name === "main" || item.name === "login" || item.name === "404") {
return;
} else {
// 移除其他所有动态添加的路由
router.removeRoute(item.name);
}
});
  • 获取现有路由router.getRoutes() 方法获取当前路由实例中所有已注册的路由配置

  • 路由过滤规则:

    • 保留 main(通常是嵌套路由的父容器)、login(登录页)、404(NotFound 页面)这三个核心路由
    • 移除其他所有动态生成的路由(这些路由通常是基于用户权限动态添加的)
  • 清理目的:避免路由重复注册,或用户权限变更后仍能访问旧路由(比如退出登录后切换用户)

    动态添加新路由

1
2
3
4
routeArr.forEach((item) => {
// 向"main"路由下添加子路由,并将添加结果存入routeList
state.value.routeList.push(router.addRoute("main", item));
});
  • 添加子路由:

    1
    router.addRoute("main", item)

    表示将处理好的路由配置(item)作为子路由添加到 name 为 “main” 的父路由下

    • 这意味着所有动态路由都会嵌套在main路由对应的页面中(通常是包含侧边栏、导航栏的布局页)
  • 记录路由router.addRoute 会返回一个用于移除该路由的函数,将其存入routeList,方便后续可能的路由管理(如需要单独移除某条路由)

  1. 路由重置:先清除旧的动态路由,避免权限变更后出现路由混乱
  2. 权限生效:将基于最新权限生成的路由(routeArr)重新添加到路由系统
  3. 状态同步:通过routeList记录当前有效的动态路由,保持响应式状态与实际路由一致

这一步完成后,前端路由就会与后端返回的菜单权限完全匹配,实现 “用户只能访问其权限范围内的页面” 的控制目标。

登录调用方法实现动态路由

接下来我们只需要在登录按钮的逻辑里调用一下这个方法就可以实现动态路由进一步实现根据用户权限进行路由鉴权了

1
2
3
4
5
6
7
8
9
10
const handleLogin = async () => {
const res = await proxy.$api.getMenu(loginForm);
if (res) {
//拿到菜单以后 在哪里显示
store.updateMenuList(res.menuList);
store.state.token = res.token;
router.push("/home");
store.addMenu(router);
}
};

实现动态路由后 我们再把原先在路由中写死的子路由路径给删掉 保证路由路径是根据用户权限动态实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//制定路由规则
const routes = [
{
path: "/",
name: "main",
component: Main,
redirect: "/login",
children: [],
},
{
path: "/login",
name: "login",
component: () => import("@/views/Login.vue"),
},
{
path: "/404",
name: "404",
component: () => import("@/views/404.vue"),
},
];

实现持久化储存 pinia 数据

目的是 解决刷新后路由不存在的问题

通过 Vue 的响应式监听 + localStorage 持久化 实现了状态的持久化存储,核心逻辑是将用户状态在变化时自动保存到本地,并在页面刷新时重新读取恢复。

监听状态变化表并保存到 localStorage

1
2
3
4
5
6
7
8
9
10
11
12
// 使用watch监听state的变化
watch(
state,
(newObj) => {
// 如果token不存在(如用户退出登录),不保存状态
if (!newObj.token) return;

// 将最新的state转换为JSON字符串,存入localStorage
localStorage.setItem("store", JSON.stringify(newObj));
},
{ deep: true }
); // 开启深度监听,确保嵌套对象变化也能被捕获
  • watch(state, ...):监听响应式状态state的所有变化(包括嵌套属性)
  • 当 state 变化时,先判断 token 是否存在:
    • token不存在(通常是用户退出登录),则不保存状态(避免存储无效的登录状态)
    • token存在(用户已登录),则通过localStorage.setItemstate以 JSON 字符串形式保存
  • { deep: true }:因为state可能是嵌套对象(如包含menuListrouteList等属性),开启深度监听才能捕获到深层属性的变化

页面刷新时重新读取 localStorage

addMenu 方法中,通过type === "refresh"判断是否为页面刷新场景:

1
2
3
4
5
6
7
8
9
10
11
12
if (type === "refresh") {
// 检查localStorage中是否有保存的状态
if (JSON.parse(localStorage.getItem("store"))) {
// 将本地存储的状态恢复到响应式state中
state.value = JSON.parse(localStorage.getItem("store"));
// 重置routeList(因为它存储的是路由操作函数,无法被JSON序列化)
state.value.routeList = [];
} else {
// 若本地无数据(如首次访问),直接返回
return;
}
}

作用

  • 当页面刷新时(通常在main.js中调用addMenu(router, "refresh")),从localStorage读取之前保存的状态
  • 恢复到state.value中,保证刷新后用户的登录状态、菜单权限等数据不丢失
  • 单独重置routeList是因为它存储的是router.addRoute返回的函数,这类函数无法被JSON.stringify序列化(会变成null),因此需要重新初始化

全局启动状态持久化

确保 “状态持久化” 在整个应用启动的 “最早阶段” 生效,避免出现 “状态丢失” 或 “功能异常” 所以我们必须在 vue 入口文件 main.js中调用

需要额外提的一点是,上述代码只是做了一个监听,实则保存刷新后路由的主要逻辑还是放在 addMenu 中的 所以我们最后调用这个方法的时候还是应该调用 addMenu

在 main.js 中

1
2
3
4
5
6
7
8
9
10
11
12
import { useAllDataStore } from "@/stores";

app.use(router).mount("#app");

app.use(pinia);

//这个动态路由的方法必须要在use(pinia)之后使用,因为这样才可以获取到pinia对象
//必须在use(router)之前使用,因为如果是刷新,use(router)后执行完会直接跳转路由,所以需要在他之前执行动态路由方法
const store = useAllDataStore();
store.addMenu(router, "refresh");

app.use(router).mount("#app");

总结

  1. 保存:用户操作导致state变化(如登录成功、权限变更)→ watch监听到变化 → 自动将state存入localStorage
  2. 恢复:页面刷新 → 调用addMenu(router, "refresh") → 从localStorage读取数据 → 恢复到state
  3. 边界处理:用户退出登录时token被清除 → 不保存状态,避免无效数据残留

这种方式通过监听状态变化自动持久化刷新时主动恢复的组合,实现了状态的本地持久化,确保用户在页面刷新后仍能保持登录状态和对应的权限路由配置。核心依赖localStorage的持久化特性(除非手动清除,否则数据一直存在)和 Vue 的响应式监听能力。

实现路由守卫

路由守卫官网 导航守卫 | Vue Router

更具体的用法参考官网文档

路由守卫的核心目标是保障路由访问的合法性与页面逻辑的连贯性,具体场景包括:

  1. 权限控制:拦截未登录用户访问需授权页面(如个人中心、订单页),自动跳转至登录页。
  2. 数据预处理:进入页面前置加载必要数据(如商品详情页加载商品信息),避免页面 “空白等待”。
  3. 导航拦截:阻止用户误操作离开未保存的表单页面(如编辑文档时点击后退,弹出 “是否放弃修改” 提示)。
  4. 日志 / 埋点:记录用户路由跳转行为(如统计 “首页 → 商品页” 的转化率)。
  5. 全局配置:统一处理路由错误(如 404 页面跳转)、添加全局加载状态(如路由切换时显示 loading)。

这里我们写的路由守卫是最简单的一种 即当用户路由错误的时候 我们跳转到 404 页面 (关于路由鉴权的部分我们已经用动态路由实现了)

注册全局前置守卫

main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//getRoutes获得所有路由记录的完整列表。
//这个方法判断要跳转的路由是否存在
function isRoute(to) {
return router.getRoutes().filter((item) => item.path === to.path).length > 0;
}

router.beforeEach((to, from) => {
//如果要跳转的不是login,且token不存在(可以通过不存在token判断出用户未登录)
if (to.path !== "/login" && !store.state.token) {
//跳转到login
return { name: "login" };
}
//如果路由记录不存在
if (!isRoute(to)) {
//跳转到404界面
return { name: "404" };
}
});

下面介绍一下这两段代码

1
2
3
function isRoute(to) {
return router.getRoutes().filter((item) => item.path === to.path).length > 0;
}

获取所有已配置的路由记录,筛选出与目标路径完全匹配的路由

  • 作用:检查目标路由(to)是否在项目已配置的路由列表中。
  • 原理:
    • router.getRoutes():Vue Router 提供的方法,返回所有路由记录的完整列表。
    • 通过 filter 筛选出路径(path)与目标路由完全一致的记录。
    • 如果匹配到至少 1 条记录(length > 0),则返回 true(路由存在),否则返回 false(路由不存在)。
1
2
3
4
5
6
7
8
9
10
11
router.beforeEach((to, from) => {
// 规则1:未登录用户访问非登录页时,强制跳转到登录页
if (to.path !== "/login" && !store.state.token) {
return { name: "login" }; // 跳转到名为 login 的路由
}

// 规则2:如果目标路由不存在,跳转到 404 页面
if (!isRoute(to)) {
return { name: "404" }; // 跳转到名为 404 的路由
}
});

这是 Vue Router 中最常用的全局守卫,会在每次路由跳转前触发,用于拦截或放行路由。

  1. 登录权限控制
    • 条件:to.path !== '/login'(目标路由不是登录页)且 !store.state.token(没有登录凭证,即未登录)。
    • 处理:强制跳转到登录页(通过路由名称 login 定位),阻止未登录用户访问受保护页面。
  2. 路由有效性校验
    • 条件:!isRoute(to)(目标路由不在配置的路由列表中)。
    • 处理:跳转到 404 页面(通过路由名称 404 定位),避免用户访问不存在的路由时出现空白或错误。

在这里我们介绍一下这里传入的 to 到底是什么 这也是我在复盘代码的时候没有搞懂的东西

当你在应用中进行路由跳转时(比如通过 <router-link> 点击、this.$router.push() 调用等),Vue Router 内部会:

  1. 解析目标路由信息,生成一个路由记录对象(Route Location),也就是 to
  2. 同时获取当前所在的路由信息,生成 from 对象
  3. 自动将 tofrom 作为参数传入 beforeEach 守卫函数

简单说:任何触发路由变化的操作,都会让路由系统自动创建 to 对象并传入守卫

to 包含了目标路由的完整信息,常用属性:

  • path:目标路由的路径(如 /user
  • name:目标路由的名称(如果定义了 name
  • params:动态路由参数(如 /user/:id 中的 id
  • query:查询参数(如 ?page=1&size=10
  • meta:路由元信息(自定义的额外数据)

这里的 from 简单来说就是跳转前的路由状态 一般是用来写一些判断 比如说 from 是编辑页 我们做一个内容是否保存的判断等等

这里因为没有例子所以就不再多说了喵

创建 404 页面作为守卫跳转页

1.创建路由

1
2
3
4
5
6
7
8
const routes = [
//也是一级路由
{
path: "/404",
name: "404",
component: () => import("@/views/404.vue"),
},
];

2.在 views 下创建 404.vue 页面

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
<template>
<div class="exception">
<img :src="getImageUrl(404)" />
<el-button class="btn-home" @click="goHome">回到上一个页面</el-button>
</div>
</template>

<script setup>
import { useRouter } from "vue-router";
const router = useRouter();

const getImageUrl = (img) => {
return new URL(`../assets/images/${img}.png`, import.meta.url).href;
};

const goHome = () => {
//go方法:按指定方向访问历史。如果是正数则是路由记录向前跳转,如果是负数则是向后回退
//这里我们回退两个页面到跳转前的页面
router.go(-2);
};
</script>

<style lang="less">
.exception {
position: relative;
img {
width: 100%;
height: 100vh;
}
.btn-home {
position: absolute;
left: 50%;
bottom: 100px;
margin-left: -34px;
}
}
</style>

一些细节 bug 的修改

解决退出登录后 tab 标签不更新

我们用的是动态路由 并且将路由添加到 pinia 中的状态中 故而当我们切换账号的时候 之前的路由还缓存在 list 中 这也影响了 tag 会延续之前点击的 menu 为了解决这个状态残留问题 我们在 pinia 中写一个 clean 方法用来清除原状态

pinia 中的 clean 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const useAllDataStore = defineStore("allData", (a) => {
// 定义重置方法(注:函数名应为 clear,可能是拼写笔误)
function clean() {
// 1. 清除动态添加的路由
state.value.routeList.forEach((item) => {
if (item) item(); // 执行每个路由的删除方法
});

// 2. 重置仓库状态为初始值
state.value = initState(); // initState() 是返回初始状态的函数

// 3. 删除本地缓存的状态数据
localStorage.removeItem("store");
}

return { clearn };
});
  • 清除动态路由:项目中可能通过 “动态路由” 生成菜单(例如根据用户权限加载不同菜单),state.value.routeList 中保存的是 “删除这些动态路由的方法”(通常是路由系统提供的移除函数)。遍历执行这些方法,能彻底删除上一次登录时添加的路由,避免重新登录时残留旧菜单路由。
  • 重置仓库状态initState() 是一个预设的函数,返回仓库的初始状态(例如空的菜单列表、空的标签页数据等)。将 state.value 重置为初始值,会清空内存中保存的所有用户相关状态(如菜单、标签页、用户信息等)。
  • 删除本地缓存:若项目将仓库状态持久化到 localStorage(以 store 为键),退出时删除该缓存,可避免重新登录时从本地读取旧状态数据。

头部组件中的 handleLoginOut 方法(触发退出流程)

1
2
3
4
5
6
7
8
// CommonHeader.vue 中的退出登录处理
const handleLoginOut = () => {
// 1. 调用仓库的清除方法,清空所有状态和路由
store.clean();

// 2. 跳转到登录页
router.push("/login");
};

解决重新登陆后页面为空

这其实是一个动态路由的顺序问题 由于我们用的是动态路由添加路由 所以我们应该首先手动添加后才能对 router 进行操作 简单来说 是我们需要 先添加路由再跳转页面

修改Login.vue中的handleLogin方法,调整路由添加和页面跳转的顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup>
// ... 其他代码保持不变

const handleLogin = async () => {
const res = await proxy.$api.getMenu(loginForm);
if (res) {
// 1. 先更新菜单列表
store.updateMenuList(res.menuList);
// 2. 保存token
store.state.token = res.token;
// 3. 先添加路由(关键修复:确保路由先添加再跳转)
store.addMenu(router);
// 4. 最后跳转到首页
router.push("/home");
}
};
</script>

原来的代码是先跳转再添加路由,这在第一次登录时可能侥幸正常工作,但在重新登录时会出现时序问题:路由还未添加就已经跳转,导致组件无法加载。

调整顺序后,确保了路由先被正确添加到路由系统中,然后再进行页面跳转,此时路由匹配就能找到对应的组件了。