Skip to content

登录验证和退出登录与使用 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 机制的问题,不是本节添加广告逻辑本身;但调试时要分清是前端校验没通过,还是后端接口拒绝。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

代码一:添加广告 —— 图片实时预览 + 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;修法是在成功后统一隐藏预览图,并复用顶层保存的预览元素引用。

练习 ​

  1. 把退出登录逻辑整理为统一函数:清空 localStorage 中的 adminName、token 后跳转 /login,并说明为什么不能只清 adminName。
  2. 将 app.js 的未登录判断从 adminName 改为 token,并写出“为什么这仍然只是体验优化,不是安全边界”的说明。
  3. 在 axios 请求拦截器中补全 token 注入逻辑,故意漏写 return config 观察现象,再修复并记录原因。
  4. 实现图片选择后的实时预览:覆盖“取消选择文件”“选择新图片替换旧图”“提交成功后隐藏预览图”三种情况。
  5. 为添加广告表单写提交前校验:普通字段不能为空,图片必须判断 size,校验通过后再 postAdv(fd)。

原视频对照 ​

#视频时长
1token的运行原理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、选择器和后端字段名要保持一致,否则容易出现“代码看似正确但拿不到元素/数据”的问题。