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

本模块任务清单
任务模块 | 任务名称 | 核心目标与学习要点 |
---|
后端改造 | 升级 Mock 后端以支持双 Token | 改造 /login 接口返回双 Token,并新增 /token/refresh 接口用于无感续期。 |
状态管理 | 升级 Pinia (userStore ) | 改造 userStore ,使其能分别存储和管理用户信息、accessToken 和 refreshToken 。 |
【核心】网络层 | 深度封装 Axios 拦截器 | 实现请求拦截器自动注入 Token,以及响应拦截器处理 401 错误、无感刷新 Token 和重试请求。 |
UI 与校验 | 构建登录 UI 与表单校验 | 使用 ElForm 构建登录表单,配置声明式校验规则,并为未来的 QQ/手机号登录预留入口。 |
【核心】数据变更 | 整合 useMutation 完成登录 | 使用 useMutation 处理登录提交,并在 onSuccess 回调中,调用 userStore 的 action 将双 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,成功后再自动重试刚才失败的请求。这套机制,就是企业级认证的核心。
我们的搭建路线图将围绕这套企业级认证体系展开:
- 升级 Mock 后端: 实现双 Token 认证机制。
- 升级 Pinia (
userStore
): 使其能够管理双 Token。 - 【核心】深度封装 Axios 拦截器: 实现 Token 的自动注入与无感刷新。
- 构建登录页面 UI 与表单校验: 搭建视图并实现
ElForm
校验。 - 整合
useMutation
完成登录: 处理表单提交与回调。
3.2 编码实现:升级 Mock 后端以支持双 Token
要实现前端的无感刷新,后端必须提供相应的支持。我们需要改造 Mock Server,使其从一个简单的数据提供者,升级为一个具备签发和刷新 Token 能力的、更真实的模拟认证服务器。
涉及文件: mock/generate-data.cjs
, mock/server.cjs
任务目标:
- 让用户数据包含
refreshToken
。 - 改造
/login
接口,使其在登录成功后返回带有过期时间的 accessToken
和一个长期有效的 refreshToken
。 - 新增
/token/refresh
接口,用于根据 refreshToken
换取新的 accessToken
。 - 新增一个中间件,用于模拟
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
| 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: [], };
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
| 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);
server.use((req, res, next) => { 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();
if (currentTime > expirationTime) { return res.status(401).json({ message: "Access Token 已过期", code: "401", }); } } }
next(); });
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; const accessToken = `${faker.string.uuid()}_${Date.now() + 1 * 60 * 1000}`;
res.status(200).json({ code: "200", msg: "操作成功", result: { ...userInfo, accessToken, }, }); } else { res.status(400).json({ message: "用户名或密码错误", code: "400", }); } });
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) { const newAccessToken = `${faker.string.uuid()}_${Date.now() + 30 * 60 * 1000}`; 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
作为我们应用的“唯一事实来源”,必须能够准确地反映用户的完整认证状态。这不仅仅包括用户信息,更核心的是认证所需的 accessToken
和 refreshToken
。
文件路径: src/stores/user.ts
核心目标: 重构 userStore
,使其能统一存储用户信息和双 Token。我们将添加专门的 computed
属性来安全地获取 accessToken
和 refreshToken
,并利用持久化插件确保登录状态的稳定。
1. 设计思路
一个健壮的企业级 userStore
应该具备以下特点:
- 状态统一管理: 将用户的基本信息与认证令牌统一存储在一个
userInfo
对象中。这样做权责清晰,当获取到新的登录数据或刷新 Token 后,可以作为一个整体进行更新。 - 安全的访问器 (Getters): 不在组件中直接访问
store.userInfo.accessToken
,而是通过 computed
属性(Pinia 中的 Getters)来获取。这层抽象让组件无需关心 userInfo
的内部结构,也便于在 Getter 中处理 null
或 undefined
等边界情况。 - 原子化操作: 提供统一的
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
| 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", () => { const userInfo = ref<UserInfo | object>({});
const getUserInfo = async (form: LoginForm) => { const res = await loginApi(form); userInfo.value = res.result; }; const setUserInfo = (newUserInfo: UserInfo) => { userInfo.value = newUserInfo; };
const clearUserInfo = () => { userInfo.value = {}; };
const isLoggedIn = computed(() => { const info = userInfo.value as UserInfo; return !!info.accessToken; });
const getAccessToken = computed(() => { const info = userInfo.value as UserInfo; return info.accessToken || null; });
const getRefreshToken = computed(() => { const info = userInfo.value as UserInfo; return info.refreshToken || null; });
return { userInfo, getUserInfo, setUserInfo, clearUserInfo, isLoggedIn, getAccessToken, getRefreshToken, }; }, { persist: true, } );
|
3. 验证持久化效果
虽然我们还未集成登录页面,但可以手动进行验证。
- 在任意组件(例如
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' })
|
- 运行项目,并打开浏览器的开发者工具。
- 在 “Application” (应用) -> “Local Storage” (本地存储空间) 中,你应该能看到一个名为
user
的条目,其内容是一个包含了我们刚刚设置的所有信息的 JSON 字符串。 - 现在,注释掉或删除 刚刚添加的测试代码并 刷新页面。你会发现 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
| import httpInstance from "@/utils/http"; import type { LoginForm, UserInfo } from "@/types/user";
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
| import axios from "axios"; import { useUserStore } from "@/stores/user"; import router from "@/router"; import { refreshTokenAPI } from "@/apis/user"; import type { UserInfo } from "@/types/user";
const httpInstance = axios.create({ baseURL: "/api", timeout: 5000, });
httpInstance.interceptors.request.use( (config) => { const userStore = useUserStore(); const token = userStore.getAccessToken; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (e) => Promise.reject(e) );
let isRefreshing = false;
httpInstance.interceptors.response.use( (res) => res.data, async (error) => { 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;
if (!refreshToken) { isRefreshing = false; userStore.clearUserInfo(); router.push("/login"); return Promise.reject(error); }
try { const refreshRes = await refreshTokenAPI(refreshToken); const newAccessToken = refreshRes.result.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) { isRefreshing = false; userStore.clearUserInfo(); router.push("/login"); return Promise.reject(refreshError); } }
return Promise.reject(error); } );
export default httpInstance;
|
4. 深度代码解读
这段代码是整个认证系统的“心脏”,让我们逐一解析它的工作机制:
关键陷阱: 在 Pinia
的 store
文件之外使用 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
| @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; }
.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'
const formRef = ref<FormInstance>()
const formModel = reactive({ account: '3381292732@qq.com', password: '123456', agree: 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>()
const handleForgotPassword = () => ElMessage.info('忘记密码功能待开发') const handleThirdPartyLogin = (type: string) => ElMessage.info(`${type}登录功能待开发`) const handleNavClick = (item: string) => ElMessage.info(`${item}功能待开发`)
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()
方法至关重要。 - 数据与规则:
formModel
和 rules
的定义是 ElForm
声明式校验的核心。我们将数据和校验规则分离,使代码清晰易懂。 - 事件处理占位: 我们为页面上的所有可交互元素都绑定了
@click
事件,并链接到 <script>
中对应的 handle...
函数。目前这些函数只做提示,为下一步集成真实逻辑预留了清晰的入口。 useEventListener
: 对于动画结束事件,我们使用了 VueUse 的 useEventListener
。这是一个更优的实践,因为它会自动在组件卸载时销毁事件监听器,避免内存泄漏。
至此,我们已经拥有了一个外观精美、且具备完整表单校验逻辑的登录页面。
3.6 编码实现:useMutation
与登录逻辑闭环
本章目标: 我们将对 Login.vue
的 <script setup>
部分进行最终改造。您将学习如何引入并使用 TanStack Query
的 useMutation
来处理表单提交,并通过其强大的 onSuccess
和 onError
回调,将 API 请求、Pinia 状态更新、消息提示和路由跳转等一系列操作优雅地串联起来。
1. 设计思路:为何使用 useMutation
?
技术选型
实现登录请求前
架构师,现在我要实现点击登录按钮后的逻辑了。最直接的想法就是在 handleLogin
函数里 await loginApi()
,然后用 try/catch
分别处理成功和失败的逻辑,对吗?
你
完全正确,这是 Vue 开发的“标准答案”,它能解决问题。但专业的开发者会选择更合适的工具来处理这类“数据变更”操作。
你
这就是我们引入 TanStack Query
的 useMutation
的原因。它能完美地将 “触发动作” 与 “处理动作的副作用” 清晰地分离开来。
你
组件的职责 变得极其简单:只负责在点击按钮后,调用 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";
import { useMutation } from "@tanstack/vue-query"; import { useUserStore } from "@/stores/user"; import { useRouter } from "vue-router"; import { loginApi } from "@/apis/user"; const userStore = useUserStore(); const router = useRouter();
const formRef = ref<FormInstance>();
const formModel = reactive({ account: "", password: "", agree: 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>();
const { mutate, isPending } = useMutation({ mutationFn: loginApi, onSuccess: (data) => { ElMessage({ message: "登录成功!", type: "success", showClose: true, });
userStore.setUserInfo(data.result);
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(); 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, }); };
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>
<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
, useRouter
和 loginApi
,这是完成登录闭环所需的所有“零件”。 useMutation
配置:mutationFn: loginApi
: 我们将实际的 API 请求函数 loginApi
告诉 useMutation
。onSuccess(data)
: 当 loginApi
成功返回时(Promise resolved),这个回调会被执行。参数 data
就是 loginApi
返回的完整响应。我们在这里 按顺序执行了三个核心的副作用操作:弹窗提示、存入 Pinia、跳转页面。onError()
: 当 loginApi
失败时(Promise rejected),这个回调会被执行,我们在这里只做错误提示。
mutate
函数: 这是 useMutation
返回的用于 触发 异步操作的函数。我们在 handleLogin
中,当表单校验通过后,调用 mutate
,TanStack Query
就会自动用表单数据作为参数去执行 loginApi
。isPending
状态: 这是 useMutation
返回的布尔值 ref
。当 loginApi
正在执行时,isPending
为 true
。我们已将其绑定到登录按钮的 :disabled
和文本上,提供了非常优秀的用户体验。
4. 端到端完整流程验证
现在,我们已经完成了整个登录流程的闭环。是时候进行一次完整的测试了。
- 启动服务: 确保您的前端 (
pnpm run dev
) 和 Mock 后端 (pnpm run mock
) 都在运行。 - 访问页面: 打开浏览器,访问
http://localhost:5173/login
。 - 校验失败测试: 清空输入框,直接点击登录按钮,确认
ElForm
的校验提示正常出现。 - 登录失败测试: 输入错误的密码(例如
wrong_password
),点击登录。确认按钮显示“登录中…”,然后 ElMessage
弹出“登录失败,请检查账号密码”的提示。 - 登录成功测试:
- 使用预填的正确账号和密码 (
3381292732@qq.com
和 123456
)。 - 点击登录。
- 观察: 按钮应显示“登录中…”。
- 验证: 您应该会看到“登录成功!”的提示,页面应自动跳转到首页 (
/
)。 - 验证 (持久化): 刷新首页。如果页面右上角依然显示用户名和“退出登录”按钮,证明我们的
Pinia
持久化已成功工作!
- Token 过期与无感刷新测试:
- 登录成功后,等待 1 分钟(我们在后端设置的
accessToken
过期时间)。 - 在首页,尝试 刷新页面。
- 观察: 页面可能会有极短暂的加载过程,但最终你会发现你 依然保持登录状态!打开开发者工具的“网络”面板,你会看到一次对
/token/refresh
的请求和一次对 /categories
(或其他首页 API) 的请求。这证明我们的 Axios 拦截器成功实现了无感刷新!
3.7 模块提交与总结
至此,我们已经成功构建了一套完整的、企业级的用户认证与授权体系。从支持双 Token 的后端接口,到具备无感刷新能力的 Axios 拦截器,再到与 useMutation
结合的健壮登录流程,我们的应用现在拥有了坚实的安全基石。
现在,是时候将本模块的成果作为一个重要的里程碑,提交到我们的版本库了。
当前任务: 3.7 - 模块成果提交
任务目标: 将模块三中完成的完整用户认证流程,作为一个核心功能节点提交到 Git 仓库。
命令行操作
请打开终端,并执行以下命令:
将所有已修改和新建的文件添加到 Git 暂存区:
提交代码,并附上符合“约定式提交”规范的 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 无感刷新机制。
提交成功后,您的项目就有了一个清晰的、代表“用户认证功能开发完成”的历史节点。我们已经准备好带着一个安全的“身份”,去探索后续更复杂的业务模块了。