主题
登录验证和退出登录与使用 token 完善登录机制(本地存储 3/3)
本页对应 课堂视频,共 5 段视频,约 1 小时 55 分。
学习目标
- 能够解释 localStorage、cookie、session 与 token 在登录验证中的差异,并说明为什么仅依赖前端本地存储的登录标记不安全。
- 能够在前端登录成功后保存 token,并通过请求拦截器为每次请求统一携带 token 请求头。
- 能够处理 token 失效响应:识别后端返回的特定状态码,清空本地登录信息并跳转到登录页。
- 能够分析广告模块的添加、修改、删除、分页及图片预览需求,并说明广告图片路径与后端接口字段的对应关系。
- 能够使用 File API 与 FileReader 实现选择广告图片后的本地预览,并校验表单完整性和图片是否已选择。
- 能够使用 FormData 封装广告表单数据,调用后端添加广告 API,并在成功后关闭弹窗、清空表单、隐藏预览图。
知识点
退出按钮为什么放在 Header 组件
为什么需要:退出登录是全局导航行为,放在页头组件 Header.ejs 里,所有进入后台的页面都能看到同一个入口,不用每个页面重复写按钮。
怎么想:按钮只负责“被点击”,真正清登录状态、跳登录页的逻辑放到控制器里统一处理。按钮给一个稳定 id,方便选择器绑定。
怎么做:在 src/components/Header.ejs 的右侧导航区放按钮,保留笔记代码:
ejs
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<!-- Left navbar links -->
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
</li>
</ul>
<!-- Right navbar links -->
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" data-widget="control-sidebar" data-slide="true" href="javascript:;" role="button">
<button type="button" id="logoutBtn" class="btn btn-block btn-danger btn-sm">退出登陆</button>
</a>
</li>
</ul>
</nav>易错点:id="logoutBtn" 要和后面选择器 #logoutBtn 完全一致;按钮放在 <a href="javascript:;"> 里时,注意不要触发额外跳转;文字“退出登陆”按笔记保留,不必强行改。
在 index 控制器里绑定退出事件
为什么需要:点击按钮后要清掉本地登录标记并回到登录页,否则用户仍停留在后台页面。
怎么想:转写稿里演示了登录后 localStorage 有 adminName,点退出后清空;因此退出逻辑就是“清本地存储 → 跳登录页”。index 控制器已经导入并渲染 Header,适合在这里绑定。
怎么做:保留笔记中的导入和绑定代码:
js
// 导入组件
import HeaderComponent from '@/components/Header';
import SidebarComponent from '@/components/Sidebar';
import FooterComponent from '@/components/Footer';
import ContentComponent from '@/components/Content';
// 导入模板
import indexV from '@/views/index';
// 导入路由配置对象
import routes from '../routes';
export default ({url}, res, next) => {
// next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部
next(indexV({
// 页头组件
Header: HeaderComponent(),
// 侧边栏组件
Sidebar: SidebarComponent({
url,
routes
}),
// 页脚组件
Footer: FooterComponent(),
// 主要内容组件
Content: ContentComponent({
title: routes.find(item=>item.path===url).title,
subRouteContent: res.subRoute()
}),
}));
// 点击退出登录按钮
document.querySelector('#logoutBtn').addEventListener('click', () => {
// 清空 localStorage
localStorage.clear();
// 跳转到登录页面
router.go('/login');
});
}易错点:document.querySelector('#logoutBtn') 要能在 Header 渲染后找到元素,找不到会报 null;router 依赖全局暴露,app.js 里要执行 window.router = router;;localStorage.clear() 会清空全部本地存储,后续如果存 token 也会一起清掉,按笔记当前逻辑可接受。
在 app.js 做全局登录拦截
为什么需要:只在退出时清状态不够,用户直接访问后台地址时也要判断有没有登录标记,没有就跳登录页。
怎么想:转写稿里手动往 localStorage 塞 adminName 也能绕过前端判断,说明前端判断只能做“体验拦截”,不能当真正安全边界;但本节笔记仍是基础登录验证,按 adminName 判断。
怎么做:保留 src/app.js 代码:
js
// 导入
import SMERouter from 'sme-router'
// 导入样式
import './assets/less/app.less'
// 导入路由配置
import routes from './routes';
// 创建实例
// 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist
// 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app', 'html5');
// 将 router 暴露到全局
window.router = router;
// 如果没有登录 跳转到登录页
if (!localStorage.getItem('adminName')) {
router.go('/login');
}
// 遍历路由配置 设置路由
routes.forEach(routeItem => {
router.route(routeItem.path, routeItem.element);
})易错点:adminName 的拼写要和登录页写入时一致;判断要放在路由注册前后按笔记顺序执行;router.go('/login') 依赖 router 已创建;转写稿里提到“手动改 localStorage 能绕过”,所以不要把这段当安全边界,真正接口安全要靠后端验证。
从“能改 localStorage”说起:为什么需要 token
为什么需要:只把 adminName 存到 localStorage,再让前端判断“有没有这个值”来决定是否登录,是不可靠的。课堂演示中,退出登录后手动在 localStorage 里加一个 adminName,前端页面仍可能被绕过,甚至显示“欢迎你 2B”。这说明:凡是放在浏览器上的数据,用户都能改。
怎么想:session 相对安全,是因为关键数据在服务器,浏览器只存一个 sessionId;用户改了 ID,后端对不上号,就拿不到数据。token 可以理解为前后端分离场景下类似的方案:后端生成 token 并校验,前端负责存储和携带。前端限制可以被绕过,但后端对 token 的验证绕不过去。
怎么做:登录成功后,后端创建 token 并响应给前端;前端存储 token;以后每次请求都在请求头里携带 token;退出登录删除本地 token。注意:token 有时效,超时会变成无效 token,后端不会做出正确响应,常见返回是:
json
{
"ok": -2,
"msg": "登录异常,请求重新登录"
}易错点:不要把“前端能判断”当成“安全”;不要以为换了 sessionStorage 或 cookie 就绝对安全;真正关键是后端是否校验 token。
前端 token 的使用步骤
笔记中的步骤要完整保留:
1. 登录之后,后端会创建 token 并响应给前端
2. 前端需要将 token 进行存储,localStorage或者sessionStorage或cookie
3. 以后,前端的每次请求,都在请求头里携带token, 如果没有token或者是无效的token,后端不会做出正确的响应
4. 退出登录,删除本地存储的 token
注意:token有时效,超时会变无效token怎么想:存储位置由前端决定;请求携带要统一做,避免每个接口手写;失效处理也要统一做,避免每个接口重复判断。
易错点:只存 token 不携带,等于没登录;请求头字段名必须和后端读取的字段一致,课堂中后端读取的就是请求头里的 token。
登录成功后保存 token
为什么需要:登录接口返回 token 后,如果不保存,后续请求无法证明登录状态。
怎么做:在登录控制器拿到 res.token 后写入 localStorage,同时保留用户名用于页面展示。
js
import toastr from 'toastr';
// 导入模板
import loginV from '@/views/login';
// 导入api函数
import {postLogin} from '../api/login';
// 执行登录的函数
const postLoginExec = event => {
// 阻止默认行为
event.preventDefault();
// 获取表单数据
const adminName = document.loginForm.adminName.value.trim();
const passWord = document.loginForm.passWord.value.trim();
// 账号不能为空
if (adminName.length === 0) {
toastr.error('请填写管理员账号!');
return;
}
// 密码不能为空
if (passWord.length === 0) {
toastr.error('密码不能为空!');
return;
}
// 使用后端API 发送数据执行登录
postLogin({
adminName,
passWord
})
.then(res => {
console.log(res);
// 将用户信息和用户名记录到 localStorage
localStorage.setItem('adminName', adminName);
localStorage.setItem('token', res.token);
// 跳转管理员列表页
router.go('/index/admin');
});
}
export default (req, res) => {
// 渲染模板
res.render(loginV());
// 给表单监听提交事件
document.loginForm.addEventListener('submit', postLoginExec)
}易错点:res.token 的字段名要和后端响应一致;登录成功后先存 token 再跳转;调试时可在 Application 面板确认 adminName 和 token 是否都写入。
请求拦截器统一携带 token
为什么需要:每次请求都手写请求头容易漏。课堂演示中,未携带 token 请求管理员列表,后端直接返回 ok: -2,页面提示“登录信息异常”。
怎么想:axios 实例的请求拦截器会在请求发出前执行,适合统一加请求头。
怎么做:判断 localStorage 是否有 token,有就放到 config.headers.token,最后必须 return config。
js
// 导入 axios
import axios from 'axios';
import toastr from 'toastr';
// 创建 axios 实例
const advServer = axios.create({
baseURL: '/api',
timeout: 5000
});
// 给实例添加请求拦截器
advServer.interceptors.request.use(config => {
// 如果localStorage中有token,就添加到请求头
if (localStorage.getItem('token')) {
config.headers.token = localStorage.getItem('token');
}
return config;
});易错点:忘记 return config 会导致请求配置丢失;请求头字段名 token 要与后端中间件读取字段对应;不要只判断有 token,还要处理后端返回的无效 token。
响应拦截器识别 token 失效
为什么需要:token 过期或伪造时,后端返回 ok: -2。如果每个接口都自己判断,代码重复且容易漏。
怎么想:普通业务错误 ok: -1 只提示;ok: -2 表示登录异常,要提示、跳登录页、清空本地存储,并中断后续执行链。
怎么做:在响应拦截器中分别判断 -1 和 -2,返回一个不改变状态的 Promise 来结束执行链。
js
// 给 实例设置响应拦截器
advServer.interceptors.response.use(res => {
if (res.data.ok === -1) {
toastr.error(res.data.msg);
// 返回不改变状态的 Promise,执行链结束了
return new Promise(()=>{});
}
if (res.data.ok === -2) {
toastr.error(res.data.msg);
// 跳转到 登录页
router.go('/login');
// 清空localStorage
localStorage.clear();
// 返回不改变状态的 Promise,执行链结束了
return new Promise(()=>{});
}
return res.data;
}, error => {
toastr.error('请求错误!');
// 返回不改变状态的 Promise,执行链结束了
return new Promise(()=>{});
});
// 暴露实例
export default advServer;课堂调试时先把后端 token 有效期设为 30 秒,登录后请求能带上 token,但超时后返回 -2,响应拦截器提示并跳回登录页;演示完再把有效期改长,避免正常操作被频繁打断。
易错点:-2 处理中忘记清空 localStorage 或跳转;只提示不中断,后续 .then 仍可能执行;退出登录要删除本地 token,否则残留 token 仍可能被携带。
为什么需要图片预览
添加广告时,用户要先选择一张图片。如果选完没有任何反馈,用户无法确认自己选的是不是目标图片,容易上传错图;等到提交后才发现问题,体验很差。因此需要在 input[type=file] 选择文件后,立刻在表单里显示预览图。这样做的本质是:用户还没有真正把图片上传到服务器,前端先读取本地文件并展示出来。
怎么想:监听 change,拿到 File,再转成可显示的地址
图片预览的触发点是“用户选择了文件”。文件输入框适合监听 change 事件:只要选择结果发生变化,就会触发。事件触发后,通过 this.files[0] 拿到用户选择的文件对象。注意这里要用普通函数而不是箭头函数,因为箭头函数里的 this 不是当前 input,拿不到 files。拿到 File 后,不能直接把文件对象塞给 img.src,因为 src 需要图片地址。浏览器提供了 FileReader,用 readAsDataURL(file) 把文件读成 base64 编码,再把结果赋给预览图的 src。
转写稿里的调试过程也说明了两个关键点:第一,选择器少写 # 会找不到元素,导致报错;第二,FileReader 读取是异步的,不能在调用 readAsDataURL 后立刻取 reader.result,必须监听 onload,读取完成后再设置 src。另外,用户点开文件选择框后如果取消,change 也可能触发,所以要先判断 file 是否存在,没有文件就直接 return。
怎么做:控制器中绑定 change 并执行预览
在 src/controllers/adv.js 中,渲染页面后给 #advPic 绑定 change,回调里执行图片预览。核心代码如下,保留本节笔记代码,并把注释中的 FilerReader 纠正为 FileReader:
js
// 导入模板页面
import advV from '@/views/adv';
// 执行图片预览
const prevImgExec = function() {
// 获取 file 对象
const file = this.files[0];
// 判断:用户可能没有选择文件
if (!file) {
return;
}
// 实例化 FileReader
const reader = new FileReader();
// 将文件读取成 base64 编码的形式 异步
reader.readAsDataURL(file);
// 获取预览图元素
const preImgEle = document.querySelector('#preImg');
// 监听读取完毕
reader.onload = () => {
// 将 base64 编码的图片作为 src 属性的值
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
};
};
export default (req, res) => {
// 渲染页面
res.render(advV());
// 给图片上传 input 监听 change 事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
};模板中要有上传框和预览图
src/views/adv.ejs 里需要有 #advPic 文件输入框,以及默认隐藏的 #preImg 预览图。用户选择图片后,预览图通过 style.display = 'block' 显示。保留本节模板代码,图片 alt 改为中文:
ejs
<div id="__sub-route-view">
<div class="card">
<div class="card-header">
<button type="button" id="addAdvBtn" class="btn btn-primary" data-toggle="modal"
data-target="#advModal">添加广告</button>
<div class="card-tools">
<div class="input-group input-group-md">
<input type="text" class="form-control" id="keyword" placeholder="搜索关键字">
<div class="input-group-append">
<div class="btn btn-primary" id="searchAdv">
<i class="fas fa-search"></i>
</div>
</div>
</div>
</div>
</div>
<!-- /.card-header -->
<div id="advTable" class="card-body table-responsive">
<table id="" class="table table-bordered">
<thead>
<tr>
<!-- <th>id</th> -->
<th>标题</th>
<th>图片</th>
<th>类别</th>
<th>链接</th>
<th>排序</th>
<th>添加时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<!-- <td>648d1fcd73eabdb6ee308067</td> -->
<td>小米13广告</td>
<td><img height="80" src="http://api.fuming.site:54254/2023/06/17/db0e9ef8b55471c83cbcbcc01.jpg"
alt="广告图片"></td>
<td>轮播图</td>
<td>http://learn.fuming.site</td>
<td>1</td>
<td>2023-06-17 10:51:57</td>
<td>2023-06-17 10:51:57</td>
<td>
<button type="button" class="btn btn-danger btn-sm">删除</button>
<button type="button" data-id="648d1fcd73eabdb6ee308067"
class="btn btn-success btn-sm btn-edit">修改</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- /.card-body -->
<div class="card-footer clearfix" id="pageDiv">
<ul class="pagination pagination-sm m-0 float-right">
<li class="page-item"><a href="javascript:;" data-i="0" class="page-link">«</a></li>
<li class="page-item active">
<a class="page-link" data-i="1" href="javascript:;">1</a>
</li>
<li class="page-item"><a href="javascript:;" data-i="2" class="page-link">»</a></li>
</ul>
</div>
</div>
<div class="modal fade show" id="advModal" style="padding-right: 17px;" aria-modal="true" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">添加广告</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<form name="addAdvForm" class="form-horizontal" id="addAdvForm">
<div class="card-body">
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advTitle">标题:</label>
<div class="col-sm-10">
<input id="advTitle" name="advTitle" type="text" class="form-control" placeholder="请输入广告标题">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advType">类别:</label>
<div class="col-sm-10">
<select name="advType" id="advType" class="form-control">
<option value="1">轮播图广告</option>
<option value="2">轮播图底部广告</option>
<option value="3">热门回收广告</option>
<option value="4">优品精选广告</option>
</select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advHref">链接:</label>
<div class="col-sm-10">
<input id="advHref" name="advHref" type="text" class="form-control" placeholder="请输入广告链接">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="orderNum">排序:</label>
<div class="col-sm-10">
<input id="orderNum" name="orderNum" type="text" class="form-control"
placeholder="请输入广告排序,数字越大越靠前">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advPic">图片:</label>
<div class="col-sm-10 custom-file">
<!-- 可以选择多个文件上传 -->
<!-- <input multiple type="file" class="form-control" name="advPic" id="advPic">-->
<!-- 可以选择一个文件上传 -->
<input type="file" class="form-control" name="advPic" id="advPic">
</div>
</div>
<div>
<img style="display: none;" id="preImg" height="100" alt="广告预览图">
</div>
</div>
</form>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" id="adv-cancel" data-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary" id="adv-save" form="addAdvForm">提交</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
</div>易错点
第一,this.files[0] 要在普通函数中取,箭头函数会丢失 input 的 this。第二,必须判断 file 是否为空,取消选择时不要继续读取。第三,readAsDataURL 是异步的,reader.result 要放在 onload 里使用。第四,document.querySelector('#preImg') 的 # 不能漏。第五,预览图默认隐藏,读取成功后要设置 display: block。后续在提交成功、关闭弹窗或清空表单时,还要把预览图隐藏起来,避免下次打开弹窗时残留上一张图;转写稿中也正是通过把预览图 display 设为 none 来处理这个细节。
先明确本节要做什么
这一节的目标是:在广告列表页点击“添加广告”后,把表单数据提交到后端接口,并完成图片预览、表单校验、提交成功后的界面收尾。它不是一个孤立的“点按钮发请求”,而是把前面学过的控制器、模板、API 封装、请求实例、toastr 提示串起来。转写稿里反复强调:页面渲染后先拿到元素,再绑定事件;提交时先阻止默认行为,再收集数据、校验、调用接口。这里要特别注意同音字纠正:FilerReader 应为 FileReader,FomrData 应为 FormData,preImgEle 是预览图元素。
为什么先封装 API 函数
为什么需要:控制器里如果直接写 advServer.post('/adv', formdata),接口地址和请求实例会散落在业务代码里,后面改路径、加统一处理都不方便。
怎么想:一个后端接口对应一个 API 函数,控制器只关心“调用哪个业务函数”。
怎么做:在 src/api/adv.js 中导入广告请求实例,暴露 postAdv。
易错点:导入路径要正确;post 的第二个参数是 formdata,不要误传普通对象;函数要 return 请求结果,否则控制器 await 不到。
js
// 导入实例
import advServer from '../request/advserver';
export const postAdv = formdata => {
return advServer.post('/adv', formdata)
}图片预览为什么用 change 和 FileReader
为什么需要:用户选择图片后,如果只能等提交后才知道图片对不对,体验很差。预览可以让用户马上确认。
怎么想:input[type=file] 的值变化会触发 change;通过 this.files[0] 拿到用户选择的文件;但文件是二进制对象,不能直接塞给 img.src,要读成 base64。
怎么做:监听 #advPic 的 change,用 FileReader 的 readAsDataURL 异步读取,在 onload 里把 reader.result 赋给预览图并显示。
易错点:FileReader 是异步的,不能在上边直接写 reader.result;用户取消选择时 file 可能为空,要先 return;预览图默认隐藏,读取成功后要 display = 'block'。转写稿里调试时还出现过选择器漏写 # 导致找不到元素,这类错误要优先检查选择器。
控制器里的顶层变量与事件绑定
为什么需要:预览函数和提交函数都要用到预览图元素,如果各自 querySelector 会重复;但元素必须在页面渲染后才能拿到。
怎么想:在模块顶层声明 let preImgEle;,在默认导出的渲染函数中,res.render(advV()) 之后统一获取元素并绑定事件。
怎么做:先渲染页面,再获取 #preImg,给 #advPic 绑定 change,给 document.addAdvForm 绑定 submit。
易错点:不能在渲染前获取元素;preImgEle 要声明在模块作用域,两个函数才能共用;表单要能通过 document.addAdvForm 访问,说明模板中表单的 name 必须是 addAdvForm。
js
import toastr from 'toastr';
// 导入模板页面
import advV from '@/views/adv';
// 导入api函数
import {postAdv} from '../api/adv';
// 创建变量 记录预览图元素
let preImgEle;
// 执行图片预览
const prevImgExec = function() {
// 获取file对象
const file = this.files[0];
// 判断
if (!file) {
return;
}
// 实例化 FileReader
const reader = new FileReader()
// 将文件读取成 base64 编码的形式 异步
reader.readAsDataURL(file);
// 监听读取完毕
reader.onload = () => {
// 将 base64编码的图片作为 src 属性的值
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
}
}提交时为什么用 FormData 和 every 校验
为什么需要:添加广告包含文本字段和图片文件,普通对象不方便直接提交文件;同时空表单提交会给后端增加无效请求。
怎么想:用 new FormData(document.addAdvForm) 一次性收集表单;把 FormData 转成数组,用 every 判断每一项的第二个值是否都有内容;图片还要单独判断 size,因为没选图片时它仍可能是 File 对象,但 size 为 0。
怎么做:先 event.preventDefault(),再创建 fd,Array.from(fd) 后判断;不完整就 toastr.error 并 return;图片 fd.get('advPic').size === 0 也提示并返回;通过后 await postAdv(fd)。
易错点:Array.from(fd) 得到的是 [name, value] 形式;every 中写 item[1] 而不是 item[0];图片字段名要和模板中 name="advPic" 一致;校验失败必须 return,否则还会继续提交。
提交成功后的收尾
为什么需要:提交成功后如果模态框不关、表单不清、预览图不隐藏,用户会以为没成功或看到旧数据。
怎么做:$('#advModal').modal('hide') 关闭模态框,document.addAdvForm.reset() 清空表单,preImgEle.style.display = 'none' 隐藏预览图;列表更新可在后续列表功能中完成。
易错点:模态框 ID 要写对;reset() 后图片预览不会自动隐藏,所以要手动隐藏;preImgEle 必须已经获取到。转写稿中还提到把回调改成 async/await 时,await 必须写在 async 函数里,否则会报语法错误。
js
// 执行广告添加
const addAdvExec = async event => {
// 阻止默认行为
event.preventDefault();
// 创建 FormData 对象,以 form元素作为参数
const fd = new FormData(document.addAdvForm);
// 利用纯数组进行判断
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
// 使用后端API添加广告
const res = await postAdv(fd)
console.log(res);
// 模态框关闭
$('#advModal').modal('hide');
// 表单清空
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
// 更新列表
}
export default (req, res) => {
// 渲染页面
res.render(advV());
// 获取预览图元素
preImgEle = document.querySelector('#preImg');
// 给图片上传input监听change事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
// 给表单监听提交事件
document.addAdvForm.addEventListener('submit', addAdvExec);
}调试顺序建议
先确认模板中表单 name、图片 id、预览图 id、模态框 id 是否和控制器一致;再测图片预览是否能显示;然后测空表单和未选图片是否被拦截;最后测接口返回和成功后的关闭、清空、隐藏。转写稿里出现“登录信息异常”时,是因为接口需要 token 而请求未携带或已过期,这属于请求拦截器与 token 机制的问题,不是本节添加广告逻辑本身;但调试时要分清是前端校验没通过,还是后端接口拒绝。
课堂画面





代码示例
代码一:添加广告 —— 图片实时预览 + FormData 提交校验
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>添加广告:图片预览 + 提交</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/build/toastr.min.css">
<style>
body { font-family: sans-serif; padding: 20px; }
.form-group { margin-bottom: 12px; }
.form-group label { display: inline-block; width: 70px; }
#preImg { display: none; height: 100px; margin-top: 8px; border: 1px solid #ddd; }
</style>
</head>
<body>
<form name="addAdvForm" id="addAdvForm">
<div class="form-group">
<label for="advTitle">标题:</label>
<input id="advTitle" name="advTitle" type="text" placeholder="请输入广告标题">
</div>
<div class="form-group">
<label for="advType">类别:</label>
<select name="advType" id="advType">
<option value="1">轮播图广告</option>
<option value="2">轮播图底部广告</option>
<option value="3">热门回收广告</option>
<option value="4">优品精选广告</option>
</select>
</div>
<div class="form-group">
<label for="advHref">链接:</label>
<input id="advHref" name="advHref" type="text" placeholder="请输入广告链接">
</div>
<div class="form-group">
<label for="orderNum">排序:</label>
<input id="orderNum" name="orderNum" type="text" placeholder="数字越大越靠前">
</div>
<div class="form-group">
<label for="advPic">图片:</label>
<!-- 只上传一张图,所以不加 multiple 属性 -->
<input type="file" name="advPic" id="advPic">
</div>
<div>
<img id="preImg" alt="预览图">
</div>
<button type="submit" id="adv-save">提交</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/toastr.min.js"></script>
<script>
// ===== 模拟 src/request/advserver.js 中的实例 =====
const advServer = axios.create({ baseURL: '/api', timeout: 5000 });
// ===== 模拟 src/api/adv.js =====
const postAdv = formdata => advServer.post('/adv', formdata);
// 模块级变量:记录预览图元素,供下面两个函数共用
let preImgEle;
// ===== 执行图片预览 =====
const prevImgExec = function () {
// 普通函数,this 才指向 input 元素本身
const file = this.files[0];
// 用户点了"取消"没选文件时也会触发 change,必须判断
if (!file) return;
// 实例化 FileReader
const reader = new FileReader();
// 将文件读取成 base64 编码的形式(异步)
reader.readAsDataURL(file);
// 监听读取完毕,此时才能拿到 reader.result
reader.onload = () => {
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
};
};
// ===== 执行广告添加 =====
const addAdvExec = async event => {
// 阻止表单默认提交
event.preventDefault();
// 以 form 元素为参数,一步拿到所有表单数据
const fd = new FormData(document.addAdvForm);
// FormData → 纯数组,利用数组的 every 做整体非空判断
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
// 文件对象永远不是"空值",必须单独判断 size
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
// 使用后端 API 添加广告
const res = await postAdv(fd);
console.log(res);
// 表单清空
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
toastr.success('添加成功!');
};
// 渲染完页面后再统一获取预览图元素
preImgEle = document.querySelector('#preImg');
// 给图片上传 input 监听 change 事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
// 给表单监听提交事件
document.addAdvForm.addEventListener('submit', addAdvExec);
</script>
</body>
</html>关键行与易错点
const prevImgExec = function () { ... this.files[0] ... }:必须用普通函数,this才指向 input;写成箭头函数this是外层作用域,this.files直接报错。if (!file) return;:change事件只要值变了就触发,用户点了"取消"把文件清空同样会触发,不判断会读到undefined而报错。reader.readAsDataURL(file)是异步的,紧接着写reader.result一定是null;必须放到reader.onload里读。preImgEle声明在模块顶层而不是某个函数内部:预览函数和提交函数都要用它,写在函数里就各自是局部变量,互相取不到。Array.from(fd)得到的是[['advTitle','xx'], ['advType','1'], ...],所以item[1]才是用户填的值;fd.every不存在,必须转成数组再用every。fd.get('advPic').size === 0:文件字段即使没选也返回一个 File 对象,用!value判断永远为假,只能用size判断是否真的选了图。
代码二:token 请求拦截 / 响应拦截 + 登录存 token + 退出登录
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>token 拦截器 + 登录 / 退出</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/build/toastr.min.css">
</head>
<body>
<form name="loginForm">
<input name="adminName" placeholder="管理员账号">
<input name="passWord" type="password" placeholder="密码">
<button type="submit">登录</button>
</form>
<button type="button" id="logoutBtn">退出登陆</button>
<button type="button" id="loadAdminBtn">获取管理员列表</button>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/toastr.min.js"></script>
<script>
// 真实项目中由 sme-router 创建,这里做占位
window.router = { go: path => console.log('router.go ->', path) };
// ===== src/request/advserver.js =====
const advServer = axios.create({
baseURL: '/api',
timeout: 5000
});
// 请求拦截器:每次请求前都执行
advServer.interceptors.request.use(config => {
// 有 token 就塞进请求头,字段名必须和后端约定一致
if (localStorage.getItem('token')) {
config.headers.token = localStorage.getItem('token');
}
return config; // 一定要 return,否则请求发不出去
});
// 响应拦截器
advServer.interceptors.response.use(res => {
if (res.data.ok === -1) {
toastr.error(res.data.msg);
// 返回永远 pending 的 Promise,执行链到此中断
return new Promise(() => {});
}
if (res.data.ok === -2) {
// -2 专门表示 token 无效/过期
toastr.error(res.data.msg);
router.go('/login');
localStorage.clear();
return new Promise(() => {});
}
return res.data; // 成功时把 data 直接抛给业务层
}, error => {
toastr.error('请求错误!');
return new Promise(() => {});
});
// ===== src/api/login.js =====
const postLogin = data => advServer.post('/login', data);
// ===== src/controllers/login.js =====
const postLoginExec = event => {
event.preventDefault();
const adminName = document.loginForm.adminName.value.trim();
const passWord = document.loginForm.passWord.value.trim();
if (adminName.length === 0) {
toastr.error('请填写管理员账号!');
return;
}
if (passWord.length === 0) {
toastr.error('密码不能为空!');
return;
}
postLogin({ adminName, passWord }).then(res => {
console.log(res);
// 将用户信息和 token 记录到 localStorage
localStorage.setItem('adminName', adminName);
localStorage.setItem('token', res.token);
router.go('/index/admin');
});
};
document.loginForm.addEventListener('submit', postLoginExec);
// ===== 退出登录(Header.ejs 中的 #logoutBtn) =====
document.querySelector('#logoutBtn').addEventListener('click', () => {
// 清空 localStorage,token 一并清掉
localStorage.clear();
router.go('/login');
});
// 演示:请求头里会自动带上 token
document.querySelector('#loadAdminBtn').addEventListener('click', () => {
advServer.get('/admin').then(res => console.log(res));
});
</script>
</body>
</html>关键行与易错点
- 请求拦截器里
return config千万不能漏,漏了这次请求会卡死、什么都不发。 config.headers.token的字段名必须和后端中间件取的字段一致(这里是token),写错后端就认为你没登录。return new Promise(() => {})是"中断执行链"的常用写法:返回一个永远 pending 的 Promise,后续.then都不再执行,业务层拿不到数据也就不会误渲染。-1是普通业务错误,-2是 token 失效:两者要分开处理,-2除了提示还要router.go('/login')+localStorage.clear()。- 响应拦截器
return res.data之后,业务层的res就是后端返回的{ ok, msg, token, data },所以登录里写的是res.token而不是res.data.token。 - 登录成功必须把
token存下来,只存adminName的话,后面的请求头是空的,后端一律返回ok: -2。
代码三:全局登录校验(src/app.js)
js
// src/app.js
import SMERouter from 'sme-router'
// 导入样式
import './assets/less/app.less'
// 导入路由配置
import routes from './routes';
// 创建实例
// 默认是 hash 模式:http://adv.manage.fuming.site/#/index/advlist
// 第二个参数设为 html5 改为 history 模式:http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app', 'html5');
// 将 router 暴露到全局,控制器里可以直接 router.go(...)
window.router = router;
// 如果没有登录,跳转到登录页
// 笔记原文判断 adminName,也可以换成 token:if (!localStorage.getItem('token'))
if (!localStorage.getItem('adminName')) {
router.go('/login');
}
// 遍历路由配置,设置路由
routes.forEach(routeItem => {
router.route(routeItem.path, routeItem.element);
});关键行与易错点
window.router = router必须写:src/controllers/index.js里的退出登录、登录控制器里的router.go('/index/admin')都依赖这个全局变量,不挂上去会报router is not defined。- 这层判断只是前端体验层的限制,用户改一下 localStorage 就能绕过;真正的安全靠后端校验 token,两边都不能少。
- 判断用的键(
adminName/token)要和登录、退出时setItem/clear的键保持一致,否则会出现"登录了还一直被踢回登录页"。 localStorage.clear()是整站清空,登录状态、用户名、token 一起没了;如果项目里还存了别的配置,要改用removeItem精确删除。- 校验必须写在
routes.forEach注册路由之前,否则先匹配到页面再跳转,会出现页面闪一下的现象。
常见错误
- 退出登录点击无反应:现象是按钮存在但无跳转;原因是控制器中
document.querySelector('#logoutBtn')执行时元素未渲染或选择器写错;修法是把绑定放到 Header 渲染后/事件委托,并先判空。 - 手动写入
adminName就能进后台:现象是未登录也能绕过首页限制;原因是app.js只校验localStorage.adminName,前端存储可改;修法是前端改为校验token,真正权限必须由后端 token 验证兜底。 - 请求已带 token 仍提示登录异常:现象是 Network 里请求头字段与后端读取字段不一致;原因是前端写的 header 名、大小写或位置不对;修法是按后端约定写
config.headers.token,并确保拦截器return config。 ok === -2后页面还继续执行后续逻辑:现象是 toastr 提示后仍走.then;原因是响应拦截器未中断 Promise 链;修法是提示后清空localStorage、router.go('/login'),并返回 pending Promise。- 图片预览首次不显示或报错:现象是选图后
reader.result为空;原因是FileReader异步读取,同步取结果;修法是把赋值放到reader.onload中,并判断files[0]是否存在。 - FormData 校验把未选图片当成已填:现象是普通字段空能拦住,但图片未选仍提交;原因是
File对象本身为 truthy;修法是单独判断fd.get('advPic').size === 0。 - 添加成功后预览图仍显示:现象是表单已 reset、模态框已关,旧预览图还在;原因是只清表单未隐藏
preImg;修法是在成功后统一隐藏预览图,并复用顶层保存的预览元素引用。
练习
- 把退出登录逻辑整理为统一函数:清空
localStorage中的adminName、token后跳转/login,并说明为什么不能只清adminName。 - 将
app.js的未登录判断从adminName改为token,并写出“为什么这仍然只是体验优化,不是安全边界”的说明。 - 在 axios 请求拦截器中补全 token 注入逻辑,故意漏写
return config观察现象,再修复并记录原因。 - 实现图片选择后的实时预览:覆盖“取消选择文件”“选择新图片替换旧图”“提交成功后隐藏预览图”三种情况。
- 为添加广告表单写提交前校验:普通字段不能为空,图片必须判断
size,校验通过后再postAdv(fd)。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | token的运行原理 | 28:54 |
| 2 | 使用token完善登录机制 | 18:27 |
| 3 | 广告模块功能分析 | 07:08 |
| 4 | 添加广告图片预览 | 26:20 |
| 5 | 使用后端API添加广告 | 35:00 |
小结
- token 是登录后的验证凭证,前端负责存储并在每次请求头中携带,后端负责校验有效性。
- 前端本地存储和前端登录判断都可被用户修改,不能作为最终安全边界。
- 请求拦截器适合统一注入 token;响应拦截器适合统一处理
ok === -1和ok === -2。 - token 失效时要同时做三件事:提示、清本地登录信息、跳转登录页,并中断后续 Promise 链。
- 图片预览依赖
FileReader的异步onload,不能在readAsDataURL后同步读取result。 FormData适合文件上传,字段名必须与后端接口一致;文件字段要单独判断size。- 表单
name、元素id、选择器和后端字段名要保持一致,否则容易出现“代码看似正确但拿不到元素/数据”的问题。