模块三:登录与用户认证

模块三:登录与用户认证

本章概述: 欢迎来到模块三。在本章中,我们将构建一个 真正企业级 的用户认证系统。我们将从零开始,在 Mock 后端实现 双 Token 认证机制。在前端,我们将深度封装 Axios,通过 请求和响应拦截器 实现 Access Token 的无感刷新。最后,我们将结合 ElForm 的强大校验功能和 useMutation,完成一个健壮、安全、用户体验极佳的登录流程,并为应用添加路由守卫,实现完整的认证闭环。

image-20250911155106817

本模块任务清单

任务模块任务名称核心目标与学习要点
后端改造升级 Mock 后端以支持双 Token改造 /login 接口返回双 Token,并新增 /token/refresh 接口用于无感续期。
状态管理升级 Pinia (userStore)改造 userStore,使其能分别存储和管理用户信息、accessTokenrefreshToken
【核心】网络层深度封装 Axios 拦截器实现请求拦截器自动注入 Token,以及响应拦截器处理 401 错误、无感刷新 Token 和重试请求。
UI 与校验构建登录 UI 与表单校验使用 ElForm 构建登录表单,配置声明式校验规则,并为未来的 QQ/手机号登录预留入口。
【核心】数据变更整合 useMutation 完成登录使用 useMutation 处理登录提交,并在 onSuccess 回调中,调用 userStoreaction 将双 Token 持久化。
应用安全实现路由守卫与退出登录使用 vue-router 的导航守卫保护需登录页面,并实现一个能清除所有状态的退出登录功能。

3.1 任务规划与设计思路

一个优秀的前端认证系统,其核心在于 对 Token 的自动化管理,对用户应该是几乎无感的。

认证架构设计
架构设计阶段

在企业级项目中,为什么认证方案通常需要两个 Token?一个 accessToken 不够吗?

架构师

这是一个经典的安全与体验的权衡问题。Access Token 用来访问受保护资源,它的有效期通常很短(比如 30 分钟),即使被截获,风险也有限。但如果让用户每半小时就重新登录一次,体验会非常糟糕。

所以 Refresh Token 就派上用场了,用来换新的 Access Token

架构师

完全正确。Refresh Token 的有效期很长(比如 7 天),它 唯一的作用 就是去换取一个新的、短期的 Access Token。当 Access Token 过期时,我们的前端应用应该能自动、在后台用 Refresh Token 去换一个新的 Access Token,然后无缝地继续之前的操作,用户完全感觉不到这个过程。

这个“自动、在后台”的过程,听起来很复杂。

架构师

这正是我们要深度封装 Axios 拦截器的原因。我们将打造一个“自动化流水线”:请求发出时,自动带上 Access Token;当收到 401 (未授权) 响应时,自动暂停当前请求,去刷新 Token,成功后再自动重试刚才失败的请求。这套机制,就是企业级认证的核心。

我们的搭建路线图将围绕这套企业级认证体系展开:

  1. 升级 Mock 后端: 实现双 Token 认证机制。
  2. 升级 Pinia (userStore): 使其能够管理双 Token。
  3. 【核心】深度封装 Axios 拦截器: 实现 Token 的自动注入与无感刷新。
  4. 构建登录页面 UI 与表单校验: 搭建视图并实现 ElForm 校验。
  5. 整合 useMutation 完成登录: 处理表单提交与回调。

3.2 编码实现:升级 Mock 后端以支持双 Token

要实现前端的无感刷新,后端必须提供相应的支持。我们需要改造 Mock Server,使其从一个简单的数据提供者,升级为一个具备签发和刷新 Token 能力的、更真实的模拟认证服务器。

涉及文件: mock/generate-data.cjs, mock/server.cjs
任务目标:

  1. 让用户数据包含 refreshToken
  2. 改造 /login 接口,使其在登录成功后返回带有过期时间的 accessToken 和一个长期有效的 refreshToken
  3. 新增 /token/refresh 接口,用于根据 refreshToken 换取新的 accessToken
  4. 新增一个中间件,用于模拟 accessToken 的过期校验。

1. 更新数据生成器

首先,我们需要在生成模拟用户数据时,为每个用户添加一个 refreshToken 字段。

请打开 mock/generate-data.cjs 并更新 users 的生成逻辑:

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
// mock/generate-data.cjs
const { faker } = require("@faker-js/faker");
const fs = require('fs');
const path = require('path');

module.exports = () => {
const staticDataPath = path.join(__dirname, 'mock-data.json');
const staticData = JSON.parse(fs.readFileSync(staticDataPath, 'utf-8'));

const data = {
...staticData,
users: [],
};

// 根据 OpenAPI 规范,创建 20 个随机模拟用户
for (let i = 1; i <= 20; i++) {
// 固定账号,方便测试
const account = "3381292732@qq.com";

data.users.push({
id: faker.string.uuid(),
account: account,
password: "123456",
accessToken: faker.string.uuid(),
refreshToken: faker.string.uuid(), // 【新增】刷新令牌
avatar: faker.image.avatar(),
nickname: faker.person.firstName(),
mobile: faker.phone.number(),
gender: faker.person.sex(),
birthday: faker.date.past({ years: 30 }).toISOString().split("T")[0],
cityCode: faker.location.zipCode(),
provinceCode: faker.location.state({ abbreviated: true }),
profession: faker.person.jobTitle(),
});
}
return data;
};

2. 改造 Mock Server

接下来是核心改造。我们将用 Express 中间件的方式,为 Mock Server 添加自定义的认证逻辑。

请打开 mock/server.cjs 并用以下完整代码替换其内容:

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// mock/server.cjs
const jsonServer = require("json-server");
const { faker } = require("@faker-js/faker");
const generateData = require("./generate-data.cjs");

const server = jsonServer.create();
const router = jsonServer.router(generateData());
const middlewares = jsonServer.defaults();

server.use(middlewares);
server.use(jsonServer.bodyParser);

// 【核心】添加一个中间件,模拟 Token 过期
server.use((req, res, next) => {
// 跳过登录和刷新 token 的接口,它们不需要校验
if (req.path === "/login" || req.path === "/token/refresh") {
return next();
}

const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
const parts = token.split("_");
if (parts.length === 2) {
const expirationTime = parseInt(parts[1], 10);
const currentTime = Date.now();

// 如果当前时间大于过期时间,则判定为 token 过期
if (currentTime > expirationTime) {
return res.status(401).json({
message: "Access Token 已过期",
code: "401",
});
}
}
}

next();
});

// 自定义 /login 路由
server.post("/login", (req, res) => {
const { account, password } = req.body;
const db = router.db;
const user = db.get("users").find({ account, password }).value();

if (user) {
const { password, ...userInfo } = user;
// 【核心】生成一个短期的、带过期时间戳的 accessToken
// 为了方便测试,设置为 1 分钟过期
const accessToken = `${faker.string.uuid()}_${Date.now() + 1 * 60 * 1000}`;

res.status(200).json({
code: "200",
msg: "操作成功",
result: {
...userInfo,
accessToken, // 返回新的 accessToken
},
});
} else {
res.status(400).json({
message: "用户名或密码错误",
code: "400",
});
}
});

// 【新增】Token 刷新接口
server.post("/token/refresh", (req, res) => {
const { refreshToken } = req.body;
const db = router.db;
const user = db.get("users").find({ refreshToken }).value();

if (user && refreshToken) {
// 刷新成功,生成一个新的、带过期时间戳的 accessToken
const newAccessToken = `${faker.string.uuid()}_${Date.now() + 30 * 60 * 1000}`; // 30 分钟后过期
res.status(200).json({
code: "200",
msg: "刷新成功",
result: {
accessToken: newAccessToken,
},
});
} else {
res.status(401).json({
message: "登录状态无效,请重新登录",
code: "401",
});
}
});

// 其他已有的自定义路由...
server.get("/categories", (req, res) => {
const db = router.db;
const categories = db.get("categories").value();
res.status(200).json({ code: "200", msg: "操作成功", result: categories });
});

server.get("/home/banner", (req, res) => {
const db = router.db;
const banners = db.get("banners").value();
res.status(200).json({ code: "1", msg: "操作成功", result: banners });
});

server.use(router);

server.listen(3001, () => {
console.log("JSON Server is running on http://localhost:3001");
});

3. 代码解读与验证

  • Token 过期模拟: 我们添加了一个 Express 中间件。对于除登录和刷新外的所有请求,它会检查 Authorization 头。我们巧妙地将过期时间戳用 _ 连接在模拟 Token 的后面。中间件解析出这个时间戳,并与当前时间比较,如果超时,就返回 401 状态码。这完美地模拟了真实世界中的 Token 过期场景。
  • /login 接口: 登录成功后,会生成一个 1 分钟后过期accessToken。这便于我们快速测试无感刷新功能。
  • /token/refresh 接口: 接收 refreshToken,如果有效,就返回一个有效期为 30 分钟的 全新 accessToken

在继续前端改造之前,请务必重启 Mock Server (pnpm run mock)。我们的后端现在已经具备了完整的、企业级的双 Token 签发和刷新能力,这是构建前端无感刷新功能坚实的 后端基础


3.3 编码实现:升级 Pinia 以管理双 Token

Pinia 作为我们应用的“唯一事实来源”,必须能够准确地反映用户的完整认证状态。这不仅仅包括用户信息,更核心的是认证所需的 accessTokenrefreshToken

文件路径: src/stores/user.ts
核心目标: 重构 userStore,使其能统一存储用户信息和双 Token。我们将添加专门的 computed 属性来安全地获取 accessTokenrefreshToken,并利用持久化插件确保登录状态的稳定。

1. 设计思路

一个健壮的企业级 userStore 应该具备以下特点:

  • 状态统一管理: 将用户的基本信息与认证令牌统一存储在一个 userInfo 对象中。这样做权责清晰,当获取到新的登录数据或刷新 Token 后,可以作为一个整体进行更新。
  • 安全的访问器 (Getters): 不在组件中直接访问 store.userInfo.accessToken,而是通过 computed 属性(Pinia 中的 Getters)来获取。这层抽象让组件无需关心 userInfo 的内部结构,也便于在 Getter 中处理 nullundefined 等边界情况。
  • 原子化操作: 提供统一的 action (setUserInfo) 来一次性更新所有登录相关状态,避免状态不一致。同理,clearUserInfo 方法用于安全退出,确保所有认证信息被完整清除。

2. 完整代码实现

现在,我们将对 src/stores/user.ts 文件进行全面升级。

请打开 src/stores/user.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
51
52
53
54
55
56
57
58
59
60
// src/stores/user.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { loginApi } from "@/apis/user";
import type { UserInfo, LoginForm } from "@/types/user";

export const useUserStore = defineStore(
"user",
() => {
// 1. state: 统一存储用户所有信息
const userInfo = ref<UserInfo | object>({});

// 2. action: 登录并设置用户信息
const getUserInfo = async (form: LoginForm) => {
const res = await loginApi(form);
userInfo.value = res.result;
};

// 3. action: 统一设置用户信息的方法,用于登录和刷新 Token
const setUserInfo = (newUserInfo: UserInfo) => {
userInfo.value = newUserInfo;
};

// 4. action: 清除用户信息,用于退出登录
const clearUserInfo = () => {
userInfo.value = {};
};

// 5. getter (computed): 派生状态,判断是否登录
const isLoggedIn = computed(() => {
const info = userInfo.value as UserInfo;
return !!info.accessToken;
});

// 6. getter (computed): 安全地获取 accessToken
const getAccessToken = computed(() => {
const info = userInfo.value as UserInfo;
return info.accessToken || null;
});

// 7. getter (computed): 安全地获取 refreshToken
const getRefreshToken = computed(() => {
const info = userInfo.value as UserInfo;
return info.refreshToken || null;
});

return {
userInfo,
getUserInfo,
setUserInfo,
clearUserInfo,
isLoggedIn,
getAccessToken,
getRefreshToken,
};
},
{
persist: true, // 开启持久化
}
);

3. 验证持久化效果

虽然我们还未集成登录页面,但可以手动进行验证。

  1. 在任意组件(例如 App.vue)的 <script setup> 中,临时添加以下测试代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import { useUserStore } from '@/stores/user'

    const userStore = useUserStore()
    userStore.setUserInfo({
    id: 'test-id',
    account: 'test-user',
    nickname: 'Tester',
    avatar: '',
    accessToken: 'dummy-access-token-123',
    refreshToken: 'dummy-refresh-token-456'
    })
  2. 运行项目,并打开浏览器的开发者工具。
  3. 在 “Application” (应用) -> “Local Storage” (本地存储空间) 中,你应该能看到一个名为 user 的条目,其内容是一个包含了我们刚刚设置的所有信息的 JSON 字符串。
  4. 现在,注释掉或删除 刚刚添加的测试代码并 刷新页面。你会发现 Pinia 状态依然存在,因为它已成功从 Local Storage 中恢复。

我们的 Pinia store 现已升级为一个能够妥善管理企业级认证状态的“保险箱”。它不仅结构清晰、类型安全,而且借助持久化插件,确保了用户登录状态的稳定可靠。


3.4 【核心】编码实现:深度封装 Axios 拦截器

本章目标: 对我们全局的 Axios 实例进行深度封装。我们将实现一个 请求拦截器 来自动为请求注入 accessToken,以及一个更复杂的 响应拦截器。这个响应拦截器将能自动捕获 401 (未授权) 错误,并在后台使用 refreshToken 无感地刷新 accessToken,然后 自动重试 之前失败的请求,为用户提供无缝的登录体验。

1. 设计思路:打造“自愈式”的请求流水线

在开始编码前,让我们先进行一次技术对话,明确我们的目标。

封装前的思考
准备封装网络层

架构师,现在后端接口和 Pinia Store 都好了。我是不是可以在需要 Token 的 API 请求函数里,手动从 Pinia 取 Token 然后加到 Header 里?

架构师

技术上可以,但这是企业级项目的大忌。你的每个 API 请求函数都要重复写一遍这个逻辑。更糟糕的是,如果 accessToken 过期了怎么办?在每个发起请求的组件里都写一遍 if (error.status === 401) 的判断吗?

那会是一场维护灾难!

架构师

完全正确。所以,我们要打造一个“中央处理中心”——也就是深度封装的 axios 实例。我们将用它的 拦截器 (Interceptors) 功能,打造一条自动化流水线。

怎么个自动化法?

架构师

请求拦截器 负责在每个请求发出前,自动检查并带上 accessToken响应拦截器 负责在收到响应后,自动检查是不是 401 错误。如果是,它会 暂停 当前失败的请求,在后台悄悄用 refreshToken 换一个新的 accessToken,成功后再用新 Token 自动重试 刚才失败的请求。

哇!这样一来,我的业务组件就只需要关心“发请求”和“拿数据”,完全不用知道 Token 的存在和过期问题了!

架构师

这就是我们要做的——把认证的复杂性,彻底封装在网络层。

2. 准备工作:创建 Token 刷新 API

在封装拦截器之前,我们需要先在 src/apis/user.ts 文件中创建一个专门用于刷新 Token 的 API 函数。

请打开 src/apis/user.ts 并添加以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/apis/user.ts
import httpInstance from "@/utils/http";
import type { LoginForm, UserInfo } from "@/types/user";

// ... loginApi 函数保持不变 ...

/**
* 刷新 Access Token 的 API
* @param refreshToken - 刷新令牌
* @returns Promise <{ result: { accessToken: string } }>
*/
export const refreshTokenAPI = (refreshToken: string): Promise<{ result: { accessToken: string } }> => {
return httpInstance({
method: "POST",
url: "/token/refresh",
data: {
refreshToken,
},
});
};

3. 完整代码实现:封装 http.ts

现在,我们准备好对 src/utils/http.ts 进行终极改造了。

请打开 src/utils/http.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
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
// src/utils/http.ts
import axios from "axios";
import { useUserStore } from "@/stores/user";
import router from "@/router"; // 引入 router 实例
import { refreshTokenAPI } from "@/apis/user"; // 引入刷新 token 的 API
import type { UserInfo } from "@/types/user";

const httpInstance = axios.create({
baseURL: "/api",
timeout: 5000,
});

// axios请求拦截器
httpInstance.interceptors.request.use(
(config) => {
const userStore = useUserStore();
const token = userStore.getAccessToken;
if (token) {
// 按照后端的要求,将 token 放入请求头
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(e) => Promise.reject(e)
);

// 防止重复刷新token的标识
let isRefreshing = false;

// axios响应式拦截器
httpInstance.interceptors.response.use(
// 剥离了一层 data,后续直接拿到的就是响应 data
(res) => res.data,
async (error) => {
// 检查是否是 401 未授权错误,且不是刷新token的请求,且没有重试过
if (
error.response?.status === 401 &&
error.config.url !== "/token/refresh" &&
!error.config._retry &&
!isRefreshing
) {
error.config._retry = true; // 标记已重试
isRefreshing = true; // 标记正在刷新

const userStore = useUserStore();
const refreshToken = userStore.getRefreshToken;

// 如果没有 refreshToken,说明用户从未登录或登录已彻底失效
if (!refreshToken) {
isRefreshing = false;
userStore.clearUserInfo();
router.push("/login");
return Promise.reject(error);
}

try {
// 尝试使用 refreshToken 获取新的 accessToken
const refreshRes = await refreshTokenAPI(refreshToken);
const newAccessToken = refreshRes.result.accessToken;

// 更新 Pinia 中的 accessToken
const currentUserInfo = userStore.userInfo as UserInfo;
userStore.setUserInfo({
...currentUserInfo,
accessToken: newAccessToken,
});

// 重试刚才失败的请求
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
isRefreshing = false; // 重置刷新标识
return await httpInstance(error.config);
} catch (refreshError) {
// 如果刷新 token 也失败了,则清除所有用户信息并跳转到登录页
isRefreshing = false; // 重置刷新标识
userStore.clearUserInfo();
router.push("/login");
return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);

export default httpInstance;

4. 深度代码解读

这段代码是整个认证系统的“心脏”,让我们逐一解析它的工作机制:

  • 请求拦截器 (interceptors.request):

    • 职责: 自动化注入 Token。
    • 流程: 在每个请求被发送到服务器之前,这个拦截器都会启动。它会从 userStore 中获取 accessToken。如果存在,就将其以 Bearer 格式添加到请求的 Authorization 头中。
  • 响应拦截器 (interceptors.response):

    • 职责: 剥离数据和处理核心错误,尤其是 401
    • 成功分支 (res => res.data): 对于成功的请求 (2xx 状态码),我们直接返回 res.data。这是一种常见的优化,让业务代码在 .then() 中直接拿到后端 result 数据,无需再写 .data.result
    • 失败分支 (async (error) => { ... }): 这是我们的核心逻辑。
      1. 精确打击: if (error.response?.status === 401) 确保我们只处理 401 (未授权) 错误。
      2. try...catch 保护: 刷新 Token 的过程本身也可能失败,所以我们用 try...catch 块来包裹这个“修复”操作。
      3. try 块 (尝试修复):
        • 调用我们刚创建的 refreshTokenAPI
        • 成功后,用返回的 newAccessToken 更新 Pinia 状态。
        • 最关键一步: error.config 保存了上一次失败请求的所有配置。我们修改它的 headers,换上新 Token,然后 return await httpInstance(error.config)。这就相当于 用新令牌重新发送了一次刚才失败的请求。这个 return 会将 重试成功后的结果 返回给最初调用 API 的业务组件,实现无缝衔接。
      4. catch 块 (修复失败): 如果 refreshTokenAPI 本身就返回了 401 或其他错误,说明 refreshToken 也失效了。此时,认证状态彻底无效,我们清空 Pinia 并强制跳转到登录页。

关键陷阱: 在 Piniastore 文件之外使用 useUserStore(),必须在实际需要它的函数 内部 调用,而不能在文件的顶层作用域调用。这是因为 Pinia 实例的挂载是在 main.ts 中,顶层作用域的代码执行时 Pinia 可能尚未准备好。

我们已经成功地为我们的应用构建了一个高度智能、具备“自愈”能力的“神经网络”。现在,任何组件发起的 API 请求都自带了‘无感认证’的超能力。


3.5 编码实现:构建登录 UI 与表单校验

后端、状态管理和网络层这三大“基础设施”已经就绪。现在,我们回到用户能直接感知的层面——构建一个美观且功能强大的登录页面。

本节,我们将采用分步走的策略,清晰地分离 视图逻辑 的构建过程。

第一步:搭建 UI 骨架 (视图层)

本节目标: 专注于 视觉实现。我们将准备好所有静态资源(图片、样式),并编写 Login.vue<template> 部分,搭建一个包含动画、导航和第三方登录入口的完整静态视图。

1. 准备静态资源
这个页面的样式和动画比较复杂。为了让我们可以专注于逻辑,我们将其作为一个“资源包”直接引入。

  • 图片资源: 请在 src/assets/ 目录下创建一个 login 文件夹,并放入 img1.png, img2.png, QQ.png, WeChat.png 四张图片。
  • 样式文件: 请在 src/styles/ 目录下创建 login.scss 文件,并 直接复制粘贴 以下所有样式代码。
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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
// src/styles/login.scss
@use "sass:color";
@use "abstracts/variables" as *;

:root {
font-size: 15px;
}

.login-page {
margin: 0;
min-height: 100vh;
background: linear-gradient(
135deg,
color.adjust($GLColor, $lightness: 45%) 0%,
color.adjust($GLColor, $lightness: 50%) 25%,
color.adjust($sucColor, $lightness: 40%) 50%,
color.adjust($warnColor, $lightness: 35%) 75%,
color.adjust($GLColor, $lightness: 48%) 100%
);
position: relative;
overflow: hidden;
}

.login-page::after {
content: "";
display: block;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}

.login-content {
width: 90vw;
height: 90vh;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
border-radius: 30px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.18);
display: flex;
max-width: 1200px;
max-height: 800px;

// 左侧装饰区域
.left-section {
flex: 1;
position: relative;

// 装饰图形动画
.sphere {
position: absolute;
left: 30%;
width: 90%;
z-index: 1;
animation: sphereAnimation 2s;
animation-fill-mode: forwards;
animation-timing-function: ease;
}

.people {
position: absolute;
left: -50%;
top: 20%;
width: 70%;
z-index: 2;
}

// 动画类
.p-animation {
animation: peopleAnimation 2s;
animation-fill-mode: forwards;
animation-timing-function: ease;
}

.p-other-animation {
animation-name: pOtherAnimation;
animation-direction: alternate;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 3s;
}

.s-animation {
animation: sphereAnimation 2s;
animation-fill-mode: forwards;
animation-timing-function: ease;
}

.s-other-animation {
animation-name: sOtherAnimation;
animation-direction: alternate;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 3s;
}
}

// 右侧表单区域
.right-section {
flex: 1;
position: relative;
z-index: 12;

// 顶部导航
.top-navigation {
width: 80%;
margin-left: 38px;
color: $GLColor;
font-size: 20px;
font-weight: 600;
position: absolute;
left: 50%;
top: 5%;
transform: translate(-50%, 0);

.nav-item {
float: left;
width: 150px;
height: 40px;
line-height: 40px;
text-align: center;
margin-right: 10px;
transition: $transition-duration;
cursor: pointer;

&:hover {
border: 0;
background-color: #fff;
border-radius: 50px;
box-shadow: -20px 10px 32px 1px rgba(182, 183, 185, 0.37);
}
}
}

// 表单容器
.form-wrapper {
width: 60%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: left;

.form-title {
font-family: "Century Gothic", Times, serif;
margin: 30px 0;
color: $GLColor;
font-size: 28px;
font-weight: 600;
}

// Element Plus 表单样式覆盖
.el-form {
.el-form-item {
margin-bottom: 30px;

.el-input {
.el-input__wrapper {
height: 70px;
border-radius: $borderRadius * 2.5;
border: 0;
background-color: color.adjust($borderColor, $lightness: 5%);
box-shadow: none;
padding: 0 20px;

.el-input__inner {
color: $textColor;
font-family: "Century Gothic", Times, serif;
font-size: 20px;

&::placeholder {
color: $textColor-secondary;
}
}
}

&.is-focus .el-input__wrapper {
box-shadow: 0 0 0 1px $GLColor inset;
}
}

.el-checkbox {
.el-checkbox__label {
color: $textColor-secondary;
font-family: "Century Gothic", Times, serif;
}
}
}
}

// 忘记密码链接
.forgot-password {
display: block;
margin-top: -15px;
margin-bottom: 20px;
color: $textColor-secondary;
cursor: pointer;
text-decoration: none;
font-family: "Century Gothic", Times, serif;
transition: color $transition-duration;

&:hover {
color: $GLColor;
}
}

// 登录按钮
.login-button {
width: 100%;
height: 50px;
background-color: $GLColor;
border-radius: $borderRadius * 2.5;
font-size: 15px;
color: #fff;
border: 0;
font-weight: 600;
margin: 30px 0;
cursor: pointer;
box-shadow: -20px 28px 42px 0 rgba(1, 85, 178, 0.37);
font-family: "Century Gothic", Times, serif;
transition: all 0.3s ease;

&:hover {
background-color: color.adjust($GLColor, $lightness: -8%);
transform: translateY(-2px);
}

&:active {
transform: translateY(0);
}
}

// 其他登录方式
.other-login {
.divider {
width: 100%;
margin: 20px 0;
text-align: center;
display: flex;
align-items: center;
justify-content: space-between;

.line {
display: inline-block;
max-width: 35%;
width: 35%;
flex: 1;
height: 1px;
background-color: $borderColor;
}

.divider-text {
vertical-align: middle;
margin: 0px 20px;
display: inline-block;
width: 150px;
color: $textColor-secondary;
white-space: normal;
font-family: "Century Gothic", Times, serif;
}
}

.other-login-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;

.other-login-item {
width: 70px;
padding: 10px;
text-align: center;
border-radius: $borderRadius * 2.5;
cursor: pointer;
font-weight: 600;
color: $GLColor;
margin: 0 10px;
transition: 0.4s;

img {
width: 40px;
height: 40px;
vertical-align: middle;
}

span {
vertical-align: middle;
}

&:hover {
width: 80px;
height: 50%;
background-color: #fff;
border: 0;
box-shadow: -20px 10px 32px 1px rgba(182, 183, 185, 0.37);
}
}
}
}
}
}
}

// 背景动画已移除,改为静态渐变背景

// 元素动画
@keyframes sphereAnimation {
0% {
width: 10%;
}
100% {
width: 90%;
transform: translate(-30%, 5%);
}
}

@keyframes peopleAnimation {
0% {
width: 40%;
}
100% {
width: 70%;
transform: translate(90%, -10%);
}
}

@keyframes pOtherAnimation {
0% {
transform: translate(90%, -10%);
}
100% {
transform: translate(90%, -15%);
}
}

@keyframes sOtherAnimation {
0% {
transform: translate(-30%, 5%);
}
100% {
transform: translate(-30%, 10%);
}
}

// 响应式设计
@media (max-width: 768px) {
.login-content {
width: 95vw;
height: 95vh;
flex-direction: column;

.left-section {
flex: 0 0 40%;
}

.right-section {
flex: 1;

.top-navigation {
display: none;
}

.form-wrapper {
width: 80%;

.nav-item {
width: 120px;
font-size: 16px;
}
}
}
}
}

2. 编写模板与样式 (Login/index.vue)
现在,我们来编写 src/views/Login/index.vue 的模板和样式。

请打开 src/views/Login/index.vue 并用以下代码替换其 <template><style> 部分:

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
<template>
<div class="login-page">
<div class="login-content">
<!-- 左侧动画区域 -->
<div class="left-section">
<img ref="peopleRef" src="@/assets/login/img2.png" class="people p-animation" alt="people" />
<img ref="sphereRef" src="@/assets/login/img1.png" class="sphere s-animation" alt="sphere" />
</div>

<!-- 右侧表单区域 -->
<div class="right-section">
<!-- 顶部导航 -->
<div class="top-navigation">
<div class="nav-item" @click="handleNavClick('首页')">
<span class="nav-text">首页</span>
</div>
<div class="nav-item" @click="handleNavClick('注册')">
<span class="nav-text">注册</span>
</div>
</div>

<!-- 表单区域 -->
<div class="form-wrapper">
<h1 class="form-title">欢迎登录我们的平台</h1>

<el-form ref="formRef" :model="formModel" :rules="rules" @submit.prevent="handleLogin">
<el-form-item prop="account">
<el-input v-model="formModel.account" placeholder="请输入邮箱或手机号" size="large" clearable />
</el-form-item>

<el-form-item prop="password">
<el-input v-model="formModel.password" type="password" placeholder="请输入密码" size="large" show-password clearable />
</el-form-item>

<el-form-item prop="agree">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<el-checkbox v-model="formModel.agree">
我同意用户协议和隐私政策
</el-checkbox>
<a href="#" class="forgot-password" @click.prevent="handleForgotPassword">
忘记密码?
</a>
</div>
</el-form-item>

<button type="submit" class="login-button" @click="handleLogin" :disabled="isPending">
{{ isPending ? '登录中...' : '登录' }}
</button>
</el-form>

<!-- 第三方登录 -->
<div class="other-login">
<div class="divider">
<span class="line"></span>
<span class="divider-text">或使用以下方式登录</span>
<span class="line"></span>
</div>
<div class="other-login-wrapper">
<div class="other-login-item" @click="handleThirdPartyLogin('QQ')">
<img src="@/assets/login/QQ.png" alt="QQ" />
</div>
<div class="other-login-item" @click="handleThirdPartyLogin('微信')">
<img src="@/assets/login/WeChat.png" alt="WeChat" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
@use '@/styles/login.scss';
</style>

此时,Login.vue<script setup> 部分还是空的。但如果你运行项目并访问 /login 页面,应该已经能看到一个完整的静态登录界面了。

第二步:注入灵魂 (逻辑层)

本节目标: 专注于 逻辑实现。我们将编写 <script setup> 部分,定义表单的响应式数据模型、声明式校验规则,为后续的登录请求做好万全准备。

请打开 src/views/Login/index.vue 并用以下代码填充其 <script setup> 部分 (这将暂时替换为空的 <script>):

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
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useEventListener } from '@vueuse/core'

// 1. 获取 ElForm 组件实例,用于后续的统一校验
const formRef = ref<FormInstance>()

// 2. 表单数据模型
const formModel = reactive({
account: '3381292732@qq.com', // 预填入测试账号
password: '123456', // 预填入测试密码
agree: true,
})

// 3. 表单验证规则
const rules: FormRules = {
account: [
{ required: true, message: '请输入邮箱或手机号', trigger: 'blur' },
{
pattern: /^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|1[3-9]\d{9})$/,
message: '请输入正确的邮箱或手机号',
trigger: 'blur'
}
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20位', trigger: 'blur' }
],
agree: [
{
validator: (_rule: unknown, value: boolean, callback: (error?: string) => void) => {
if (!value) {
callback('请同意用户协议')
} else {
callback()
}
},
trigger: 'change'
}
]
}

// 4. 动画元素引用
const peopleRef = ref<HTMLImageElement>()
const sphereRef = ref<HTMLImageElement>()

// (此处将插入登录逻辑)
// ...

// 5. 交互占位函数
const handleForgotPassword = () => ElMessage.info('忘记密码功能待开发')
const handleThirdPartyLogin = (type: string) => ElMessage.info(`${type}登录功能待开发`)
const handleNavClick = (item: string) => ElMessage.info(`${item}功能待开发`)

// 6. 动画处理
const handlePeopleAnimationEnd = () => {
if (peopleRef.value) {
peopleRef.value.classList.remove('p-animation')
peopleRef.value.classList.add('p-other-animation')
}
}
const handleSphereAnimationEnd = () => {
if (sphereRef.value) {
sphereRef.value.classList.remove('s-animation')
sphereRef.value.classList.add('s-other-animation')
}
}
useEventListener(peopleRef, 'animationend', handlePeopleAnimationEnd)
useEventListener(sphereRef, 'animationend', handleSphereAnimationEnd)
</script>

逻辑解读:

  • DOM 引用: 我们使用 ref() 创建了对模板中 ElForm 元素的引用,这对于后续调用其 validate() 方法至关重要。
  • 数据与规则: formModelrules 的定义是 ElForm 声明式校验的核心。我们将数据和校验规则分离,使代码清晰易懂。
  • 事件处理占位: 我们为页面上的所有可交互元素都绑定了 @click 事件,并链接到 <script> 中对应的 handle... 函数。目前这些函数只做提示,为下一步集成真实逻辑预留了清晰的入口。
  • useEventListener: 对于动画结束事件,我们使用了 VueUse 的 useEventListener。这是一个更优的实践,因为它会自动在组件卸载时销毁事件监听器,避免内存泄漏。

至此,我们已经拥有了一个外观精美、且具备完整表单校验逻辑的登录页面。


3.6 编码实现:useMutation 与登录逻辑闭环

本章目标: 我们将对 Login.vue<script setup> 部分进行最终改造。您将学习如何引入并使用 TanStack QueryuseMutation 来处理表单提交,并通过其强大的 onSuccessonError 回调,将 API 请求、Pinia 状态更新、消息提示和路由跳转等一系列操作优雅地串联起来。

1. 设计思路:为何使用 useMutation

技术选型
实现登录请求前

架构师,现在我要实现点击登录按钮后的逻辑了。最直接的想法就是在 handleLogin 函数里 await loginApi(),然后用 try/catch 分别处理成功和失败的逻辑,对吗?

完全正确,这是 Vue 开发的“标准答案”,它能解决问题。但专业的开发者会选择更合适的工具来处理这类“数据变更”操作。

有什么更优雅的办法吗?

这就是我们引入 TanStack QueryuseMutation 的原因。它能完美地将 “触发动作”“处理动作的副作用” 清晰地分离开来。

怎么个分离法?

组件的职责 变得极其简单:只负责在点击按钮后,调用 mutate 函数来 触发 登录动作。而 useMutation 的职责,是负责在后台 执行 异步请求,并根据请求结果(成功/失败),在 onSuccess / onError 回调中 处理所有后续的副作用——比如弹窗提示、更新 Store、页面跳转等。

我明白了!这样我的组件逻辑就非常干净,所有跟登录成功/失败相关的后续操作都内聚在了一起,维护起来一目了然!

2. 完整代码实现 (Login.vue)

我们将对 Login.vue<script setup> 部分进行最终的整合。

请打开 src/views/Login/index.vue 并用以下完整代码替换整个 <script setup> 部分:

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
<script setup lang="ts">
import { ref, reactive } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { useEventListener } from "@vueuse/core";
// 1. 引入所需的核心工具
import { useMutation } from "@tanstack/vue-query";
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
import { loginApi } from "@/apis/user"; // 确保 loginAPI 已在 user.ts 中创建
const userStore = useUserStore();
const router = useRouter();

// 获取 ElForm 组件实例,用于后续的统一校验
const formRef = ref<FormInstance>();

// 表单数据模型
const formModel = reactive({
account: "",
password: "",
agree: true, // 同意协议,默认为 true
});

// 表单验证规则
const rules: FormRules = {
account: [
{ required: true, message: "请输入邮箱或手机号", trigger: "blur" },
{
pattern: /^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|1[3-9]\d{9})$/,
message: "请输入正确的邮箱或手机号",
trigger: "blur",
},
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 6, max: 20, message: "密码长度应为6-20位", trigger: "blur" },
],
agree: [
{
validator: (
_rule: unknown,
value: boolean,
callback: (error?: string) => void,
) => {
if (!value) {
callback("请同意用户协议");
} else {
callback();
}
},
trigger: "change",
},
],
};

// 动画元素引用
const peopleRef = ref<HTMLImageElement>();
const sphereRef = ref<HTMLImageElement>();

// 处理登录
// 2. 使用 useMutation 封装登录逻辑
const { mutate, isPending } = useMutation({
mutationFn: loginApi, // 指定实际执行的异步函数
onSuccess: (data) => {
// 登录成功后的回调
// 2.1 使用 ElMessage 显示成功提示
ElMessage({
message: "登录成功!",
type: "success",
showClose: true,
});

// 2.2 将用户信息和 Token 存入 Pinia
userStore.setUserInfo(data.result);

// 2.3 跳转到首页
router.push("/");
},
onError: (error) => {
// 登录失败的回调
ElMessage({
message: "登录失败,请检查账号密码",
type: "error",
showClose: true,
});
console.error("登录错误:", error);
},
});

// 处理登录表单提交
const handleLogin = async () => {
if (!formRef.value) return;

try {
// 验证表单
await formRef.value.validate();
// 调用 mutation 进行登录
mutate({
account: formModel.account,
password: formModel.password,
});
} catch {
ElMessage({
message: "请检查输入信息",
type: "error",
showClose: true,
});
}
};

// 处理忘记密码
const handleForgotPassword = () => {
ElMessage({
message: "忘记密码功能待开发",
type: "info",
showClose: true,
});
};

// 处理第三方登录
const handleThirdPartyLogin = (type: string) => {
ElMessage({
message: `${type}登录功能待开发`,
type: "info",
showClose: true,
});
};

// 处理导航点击
const handleNavClick = (item: string) => {
ElMessage({
message: `${item}功能待开发`,
type: "info",
showClose: true,
});
};

// 动画处理 - 使用 VueUse 优化
const handlePeopleAnimationEnd = () => {
if (peopleRef.value) {
peopleRef.value.classList.remove("p-animation");
peopleRef.value.classList.add("p-other-animation");
}
};

const handleSphereAnimationEnd = () => {
if (sphereRef.value) {
sphereRef.value.classList.remove("s-animation");
sphereRef.value.classList.add("s-other-animation");
}
};

// 使用 VueUse 的 useEventListener 自动处理事件监听和清理
useEventListener(peopleRef, "animationend", handlePeopleAnimationEnd);
useEventListener(sphereRef, "animationend", handleSphereAnimationEnd);
</script>

<template>
<div class="login-page">
<div class="login-content">
<!-- 左侧动画区域 -->
<div class="left-section">
<img
ref="peopleRef"
src="@/assets/login/img2.png"
class="people p-animation"
alt="people"
/>
<img
ref="sphereRef"
src="@/assets/login/img1.png"
class="sphere s-animation"
alt="sphere"
/>
</div>

<!-- 右侧表单区域 -->
<div class="right-section">
<!-- 顶部导航 -->
<div class="top-navigation">
<div class="nav-item" @click="handleNavClick('首页')">
<span class="nav-text">首页</span>
</div>
<div class="nav-item" @click="handleNavClick('注册')">
<span class="nav-text">注册</span>
</div>
</div>

<!-- 表单区域 -->
<div class="form-wrapper">
<h1 class="form-title">欢迎登录我们的平台</h1>

<el-form
ref="formRef"
:model="formModel"
:rules="rules"
@submit.prevent="handleLogin"
>
<el-form-item prop="account">
<el-input
v-model="formModel.account"
placeholder="请输入邮箱或手机号"
size="large"
clearable
/>
</el-form-item>

<el-form-item prop="password">
<el-input
v-model="formModel.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
clearable
/>
</el-form-item>

<el-form-item>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
"
>
<el-form-item prop="agree" style="margin: 0">
<el-checkbox v-model="formModel.agree">
我同意用户协议和隐私政策
</el-checkbox>
</el-form-item>
<a
href="#"
class="forgot-password"
@click.prevent="handleForgotPassword"
>
忘记密码?
</a>
</div>
</el-form-item>

<button
type="submit"
class="login-button"
:disabled="isPending"
@click="handleLogin"
>
{{ isPending ? "登录中..." : "登录" }}
</button>
</el-form>

<!-- 第三方登录 -->
<div class="other-login">
<div class="divider">
<span class="line"></span>
<span class="divider-text">或使用以下方式登录</span>
<span class="line"></span>
</div>
<div class="other-login-wrapper">
<div
class="other-login-item"
@click="handleThirdPartyLogin('QQ')"
>
<img src="@/assets/login/QQ.png" alt="QQ" />
</div>
<div
class="other-login-item"
@click="handleThirdPartyLogin('微信')"
>
<img src="@/assets/login/WeChat.png" alt="WeChat" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
@use "@/styles/login.scss" as *;
</style>

3. 代码解读

  • 引入依赖: 我们引入了 useMutation, useUserStore, useRouterloginApi,这是完成登录闭环所需的所有“零件”。
  • useMutation 配置:
    • mutationFn: loginApi: 我们将实际的 API 请求函数 loginApi 告诉 useMutation
    • onSuccess(data): 当 loginApi 成功返回时(Promise resolved),这个回调会被执行。参数 data 就是 loginApi 返回的完整响应。我们在这里 按顺序执行了三个核心的副作用操作:弹窗提示、存入 Pinia、跳转页面。
    • onError(): 当 loginApi 失败时(Promise rejected),这个回调会被执行,我们在这里只做错误提示。
  • mutate 函数: 这是 useMutation 返回的用于 触发 异步操作的函数。我们在 handleLogin 中,当表单校验通过后,调用 mutateTanStack Query 就会自动用表单数据作为参数去执行 loginApi
  • isPending 状态: 这是 useMutation 返回的布尔值 ref。当 loginApi 正在执行时,isPendingtrue。我们已将其绑定到登录按钮的 :disabled 和文本上,提供了非常优秀的用户体验。

4. 端到端完整流程验证

现在,我们已经完成了整个登录流程的闭环。是时候进行一次完整的测试了。

  1. 启动服务: 确保您的前端 (pnpm run dev) 和 Mock 后端 (pnpm run mock) 都在运行。
  2. 访问页面: 打开浏览器,访问 http://localhost:5173/login
  3. 校验失败测试: 清空输入框,直接点击登录按钮,确认 ElForm 的校验提示正常出现。
  4. 登录失败测试: 输入错误的密码(例如 wrong_password),点击登录。确认按钮显示“登录中…”,然后 ElMessage 弹出“登录失败,请检查账号密码”的提示。
  5. 登录成功测试:
    • 使用预填的正确账号和密码 (3381292732@qq.com123456)。
    • 点击登录。
    • 观察: 按钮应显示“登录中…”。
    • 验证: 您应该会看到“登录成功!”的提示,页面应自动跳转到首页 (/)。
    • 验证 (持久化): 刷新首页。如果页面右上角依然显示用户名和“退出登录”按钮,证明我们的 Pinia 持久化已成功工作!
  6. Token 过期与无感刷新测试:
    • 登录成功后,等待 1 分钟(我们在后端设置的 accessToken 过期时间)。
    • 在首页,尝试 刷新页面
    • 观察: 页面可能会有极短暂的加载过程,但最终你会发现你 依然保持登录状态!打开开发者工具的“网络”面板,你会看到一次对 /token/refresh 的请求和一次对 /categories (或其他首页 API) 的请求。这证明我们的 Axios 拦截器成功实现了无感刷新!

3.7 模块提交与总结

至此,我们已经成功构建了一套完整的、企业级的用户认证与授权体系。从支持双 Token 的后端接口,到具备无感刷新能力的 Axios 拦截器,再到与 useMutation 结合的健壮登录流程,我们的应用现在拥有了坚实的安全基石。

现在,是时候将本模块的成果作为一个重要的里程碑,提交到我们的版本库了。

当前任务: 3.7 - 模块成果提交
任务目标: 将模块三中完成的完整用户认证流程,作为一个核心功能节点提交到 Git 仓库。

命令行操作

请打开终端,并执行以下命令:

  1. 将所有已修改和新建的文件添加到 Git 暂存区:

    1
    git add .
  2. 提交代码,并附上符合“约定式提交”规范的 message:

    1
    git commit -m "feat(auth): implement full user authentication with dual-token refresh"

    Commit Message 解读:

    • feat: 表示这是一个新功能 (feature) 的提交。
    • (auth): 指明了本次提交的核心范围是“认证” (authentication) 模块。
    • implement full user authentication with dual-token refresh: 简明扼要地描述了我们完成的具体工作:实现了完整的用户认证及双 Token 无感刷新机制。

提交成功后,您的项目就有了一个清晰的、代表“用户认证功能开发完成”的历史节点。我们已经准备好带着一个安全的“身份”,去探索后续更复杂的业务模块了。