Skip to content

删除管理员与使用 sweetalert2 优化确认框(本地存储 2/3) ​

本页对应 课堂视频,共 5 段视频,约 53 分钟。

学习目标 ​

  • 能使用 setItem、getItem、removeItem、clear 完成 localStorage 的存取、删除与清空,并在同源页面中读取验证结果。
  • 能区分 localStorage 与 sessionStorage 的作用域和生命周期,并说明二者与 cookie 在存储大小、设置方式、过期时间、请求携带和安全性上的差异。
  • 能通过表单 submit 事件阻止默认提交,获取账号密码并完成非空校验,调用登录 API 后将账号写入 localStorage 并跳转到管理员列表页。
  • 能在页面组件中读取 localStorage 中的账号信息,并在欢迎区域正确显示登录账号名。
  • 能实现退出登录:清空本地存储并跳转登录页;同时在入口处进行全局登录验证,未登录访问受保护页面时自动跳转到登录页。

知识点 ​

删除功能要解决什么问题 ​

这一节紧接管理员列表:列表已经能通过 getAdminExec 请求数据并渲染到 #amdinListBox,接下来要让每一行的“删除”按钮真正可用。为什么需要单独处理?因为管理员列表不是写死的 HTML,而是每次请求后把组件渲染结果整体塞进容器:

js
document.querySelector('#amdinListBox').innerHTML = AdminTabelComponent({adminList: res.data})

这意味着列表里的删除按钮会随着刷新不断重建。怎么想?如果每次渲染后逐个给删除按钮绑定 click,代码会重复,而且列表一刷新,旧按钮被替换,绑定也要重做。更稳的做法是把事件监听挂在一直存在的父容器 #amdinListBox 上,利用事件冒泡,在父容器里判断“这次点击是不是删除按钮”。怎么做?在默认导出里给列表容器监听 click,回调统一交给 deleteAdminExec。易错点是父容器必须和模板中的 id 一致,笔记代码里是 #amdinListBox,注意不是 adminListBox,拼写要和模板保持一致。

用事件委托判断删除按钮 ​

deleteAdminExec 的第一件事不是直接删除,而是判断事件目标:

js
if (event.target.classList.contains('btn-danger')) {

为什么需要判断?因为 #amdinListBox 里不只有删除按钮,可能还有表格、文本、其他按钮;父容器监听到的点击不一定来自删除按钮。怎么想?删除按钮带有 btn-danger 类,点击它时 event.target 就是按钮本身,于是可以用 classList.contains('btn-danger') 做入口判断。怎么做?只有判断通过,才进入确认框和接口调用。易错点:如果按钮内部还包了 span 或图标,event.target 可能变成子元素,此时 classList 不含 btn-danger,判断会失效;更稳妥的写法是用 event.target.closest('.btn-danger'),但本节笔记代码没有展开这一层,先按现有代码理解。另一个易错点是 dataset.id 对应模板里的 data-id,如果模板写成 data-id,这里才能取到;写成 dataId 或 id 都会取不到。

确认后调用删除接口并刷新 ​

判断是删除按钮后,代码先弹确认框:

js
if (confirm('确定删除?')) {
    deleteAdmin(event.target.dataset.id)
    .then(res => {
        alert('删除成功!');
        getAdminExec();
    })
}

为什么需要确认?删除是不可逆操作,误点会造成数据丢失。怎么想?用户确认后才把该行按钮上的 data-id 传给后端删除接口;删除成功后提示,并重新请求列表,让页面数据与后端一致。怎么做?deleteAdmin 从 ../api/admin 导入,参数是 event.target.dataset.id;成功回调里执行 alert 和 getAdminExec()。易错点:删除后一定要刷新列表,否则页面还显示已删除数据;confirm 返回布尔值,取消时不能继续调用接口;本节代码只写了成功回调,失败分支没有展开,实际项目中要依赖已有请求封装或拦截器处理错误,不能误以为删除一定成功。

完整控制器代码 ​

下面保留本节笔记中的控制器代码,删除逻辑集中在 deleteAdminExec 和默认导出中的列表监听:

js
// 导入 toastr
import toastr from 'toastr';
import '../../node_modules/toastr/build/toastr.css';

// 导入发送请求的函数
import {postAdmin, getAdmin, deleteAdmin} from '../api/admin';

// 导入管理员列表表格的组件
import AdminTabelComponent from '../components/AdminTable';
// 导入模板页
import adminV from  '@/views/admin';

// 执行添加管理员账号
const addAdminExec = () => {
    // 获取表单数据
    const adminName = document.adminForm.adminName.value.trim();
    const passWord = document.adminForm.passWord.value.trim();
    const rePassWord = document.adminForm.rePassWord.value.trim();

    // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$
    if (adminName.search(/^[a-zA-Z]+$/) === -1) {
        toastr.error('管理员账号只能由英文字母组成!');
        return;
    }

    // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$
    if (passWord.search(/^\w{6,18}$/) === -1) {
        toastr.error('密码必须由6到18位数字、字母、下划线组成');
        return;
    }

    // 确认密码
    if (passWord !== rePassWord) {
        toastr.error('两次密码不一致');
        return;
    }

    // 调用后端API 添加管理员
    postAdmin({
        adminName,
        passWord
    })
    .then( res => {
        toastr.success('管理员添加成功');
        
        // 关闭模态框
        $('#addAdminModal').modal('hide');
        // 清空表单
        document.adminForm.reset();
        // 更新列表
        getAdminExec();
    });

}

// 获取管理员信息 并渲染页面
const getAdminExec = () => {
    // 向后端API请求
    getAdmin()
    .then(res => {
        // 将数据发送到管理员表格组件中 将组件设置到模板的指定位置
        document.querySelector('#amdinListBox').innerHTML = AdminTabelComponent({adminList: res.data})
    });
}

// 执行管理员的删除
const deleteAdminExec = event => {
    // 判断目标元素是否是删除按钮
    if (event.target.classList.contains('btn-danger')) {
        // 弹出确认框
        if (confirm('确定删除?')) {
            // 调用后端API执行删除
            deleteAdmin(event.target.dataset.id)
            .then(res => {
                alert('删除成功!');
                // 更新列表
                getAdminExec();
            })
        } 
    }
};

export default (req, res) => {
    // 加载模板内容
    res.render(adminV());

    // 获取管理员信息
    getAdminExec();

    // 给添加按钮监听click事件
    document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec)
  
    // 给管理员列表中的删除按钮监听单击事件 事件位置
    document.querySelector('#amdinListBox').addEventListener('click', deleteAdminExec)
}

易错点清单 ​

第一,#amdinListBox 的拼写要和模板完全一致,笔记里是 amdin,不要擅自改成 admin。第二,删除按钮必须有 btn-danger 类和 data-id,否则判断或取 id 会失败。第三,列表是动态渲染的,删除监听要挂在父容器上,不要写成给每个按钮单独绑定。第四,删除成功后要调用 getAdminExec() 刷新列表。第五,注释里的“事件位置”实际指把事件监听放在列表容器上,不是给按钮写 onclick。本节转写稿随后转向登录与本地存储,删除管理员这一节的重点仍是:事件委托、确认、调用 deleteAdmin、刷新列表。

本节要解决的问题与依赖安装 ​

为什么需要:删除管理员是不可逆操作,直接点按钮就发请求风险很高;原生 confirm 样式固定、交互生硬。用 sweetalert2 可以把“确认删除”和“删除成功”做成统一、可配置的弹框。

怎么想:sweetalert2 是运行时产品依赖,不是构建工具依赖,所以安装时不要加 -D。安装后以 swal 名字导入,调用 swal.fire({...}),它返回 Promise,确认结果在 result.isConfirmed 中。

怎么做:

shell
npm install sweetalert2
js
import swal from 'sweetalert2';

易错点:不要误装成开发依赖;导入名要与调用名一致;本节笔记没有额外导入 sweetalert2 的 CSS,按笔记保留即可,不要擅自添加不存在的路径。

删除管理员前先弹确认框 ​

为什么需要:列表里的删除按钮是事件委托绑定在 #amdinListBox 上的,点击后不能立刻调用删除 API,否则用户误点就没有挽回机会。

怎么想:先判断 event.target.classList.contains('btn-danger'),确认点的是删除按钮;再取 event.target.dataset.id 作为要删除的 ID;然后 swal.fire 弹出“确定删除?”,只有 result.isConfirmed 为真才执行 deleteAdmin。

怎么做:核心代码如下,注意确认框的 showCancelButton、确认/取消文案。

js
const deleteAdminExec = event => {
    if (event.target.classList.contains('btn-danger')) {
        swal.fire({
            title: '确定删除?',
            icon: 'warning',
            showCancelButton: true,
            confirmButtonText: '确定',
            cancelButtonText: '取消'
        }).then((result) => {
            if (result.isConfirmed) {
                deleteAdmin(event.target.dataset.id)
                .then(res => {
                    getAdminExec();
                    swal.fire({
                        title: '删除成功!',
                        icon: 'success'
                    })
                })
            }
        })
    }
};

易错点:不要在 if (event.target.classList.contains('btn-danger')) 里直接删除;要等 isConfirmed;dataset.id 对应模板里的 data-id;#amdinListBox 是笔记原拼写,必须和模板 ID 一致,不要想当然改成 adminListBox。转写稿里调试表单提交时强调“先看控制台、再检查拼写和函数是否真正调用”,这里排查“确认框不弹”也可按同样顺序:看控制台报错、看事件目标、看 swal.fire 是否执行。

删除成功后的反馈与列表刷新 ​

为什么需要:删除成功后如果只弹提示不刷新列表,页面数据会与后端不一致;如果只刷新不提示,用户不确定操作是否成功。

怎么想:在 deleteAdmin(...).then 中先调用 getAdminExec() 重新拉取列表并渲染,再 swal.fire 提示成功。成功提示不需要取消按钮。

怎么做:

js
deleteAdmin(event.target.dataset.id)
.then(res => {
    getAdminExec();
    swal.fire({
        title: '删除成功!',
        icon: 'success'
    })
})

易错点:不要忘记 getAdminExec();成功提示不要复用删除确认框的 showCancelButton;deleteAdmin 的失败分支笔记未展示,实际项目可按已有 API 封装和拦截器机制处理,不要在本节代码里凭空编造。

与 toastr 的分工:轻提示和强确认 ​

为什么需要:添加管理员时的账号、密码、确认密码校验属于轻量错误提示,用 toastr 更合适;删除确认和删除成功属于需要用户注意的强反馈,用 swal 更合适。

怎么想:toastr.error 用于校验失败,toastr.success 用于添加成功;swal.fire 用于删除确认和删除成功。两者可以共存。

怎么做:导入 toastr 和样式,校验处继续用 toastr.error:

js
import toastr from 'toastr';
import '../../node_modules/toastr/build/toastr.css';
js
if (adminName.search(/^[a-zA-Z]+$/) === -1) {
    toastr.error('管理员账号只能由英文字母组成!');
    return;
}

易错点:toastr 的 CSS 只导入一次;路径按笔记保留;不要把校验失败也做成 swal 确认框,交互会过重。转写稿里提到的“蓝金器/显应蓝电器”应理解为响应拦截器,“库ook/酷比”是 cookie,“三信”是 session,这些是其他知识点的同音转写,本节不展开,避免混淆。

完整代码与检查清单 ​

js
// 导入 toastr
import toastr from 'toastr';
import '../../node_modules/toastr/build/toastr.css';
// 导入 sweetalert2
import swal from 'sweetalert2';

// 导入发送请求的函数
import { postAdmin, getAdmin, deleteAdmin } from '../api/admin';

// 导入管理员列表表格的组件
import AdminTabelComponent from '../components/AdminTable';
// 导入模板页
import adminV from '@/views/admin';

// 执行添加管理员账号
const addAdminExec = () => {
    // 获取表单数据
    const adminName = document.adminForm.adminName.value.trim();
    const passWord = document.adminForm.passWord.value.trim();
    const rePassWord = document.adminForm.rePassWord.value.trim();

    // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$
    if (adminName.search(/^[a-zA-Z]+$/) === -1) {
        toastr.error('管理员账号只能由英文字母组成!');
        return;
    }

    // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$
    if (passWord.search(/^\w{6,18}$/) === -1) {
        toastr.error('密码必须由6到18位数字、字母、下划线组成');
        return;
    }

    // 确认密码
    if (passWord !== rePassWord) {
        toastr.error('两次密码不一致');
        return;
    }

    // 调用后端API 添加管理员
    postAdmin({
        adminName,
        passWord
    })
        .then(res => {
            toastr.success('管理员添加成功');

            // 关闭模态框
            $('#addAdminModal').modal('hide');
            // 清空表单
            document.adminForm.reset();
            // 更新列表
            getAdminExec();
        });

}

// 获取管理员信息 并渲染页面
const getAdminExec = () => {
    // 向后端API请求
    getAdmin()
        .then(res => {
            // 将数据发送到管理员表格组件中 将组件设置到模板的指定位置
            document.querySelector('#amdinListBox').innerHTML = AdminTabelComponent({ adminList: res.data })
        });
}

// 执行管理员的删除
const deleteAdminExec = event => {
    // 判断目标元素是否是删除按钮
    if (event.target.classList.contains('btn-danger')) {
        // 弹出确认框
        swal.fire({
            title: '确定删除?',
            icon: 'warning',
            showCancelButton: true,
            confirmButtonText: '确定',
            cancelButtonText: '取消'
        }).then((result) => {
            if (result.isConfirmed) {
                // 调用后端API执行删除
                deleteAdmin(event.target.dataset.id)
                .then(res => {
                    // 更新列表
                    getAdminExec();
                    // 弹框提示删除成功
                    swal.fire({
                        title: '删除成功!',
                        icon: 'success'
                    })
                })
            }
        })
    }
};

export default (req, res) => {
    // 加载模板内容
    res.render(adminV());

    // 获取管理员信息
    getAdminExec();

    // 给添加按钮监听click事件
    document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec)

    // 给管理员列表中的删除按钮监听单击事件 事件位置
    document.querySelector('#amdinListBox').addEventListener('click', deleteAdminExec)
}

检查清单:依赖是否装成产品依赖;swal 是否导入;删除是否先确认再请求;result.isConfirmed 是否判断;getAdminExec() 是否在删除成功后调用;#amdinListBox、data-id、btn-danger 是否与模板一致。

从控制器串起登录流程 ​

为什么需要:登录要把“表单输入、接口请求、登录后跳转”串起来,不能只写模板。
怎么想:控制器负责渲染登录模板、监听表单提交;提交时先阻止默认行为,再取表单值、做非空校验、调用 API,成功后保存账号名并跳转。
怎么做:src/controllers/login.js:

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 => {
        // 将用户信息记录到 localStorage
        localStorage.setItem('adminName', adminName);
        // 跳转管理员列表页
        router.go('/index/admin');
    });
}

export default (req, res) => {
    // 渲染模板
    res.render(loginV());

    // 给表单监听提交事件
    document.loginForm.addEventListener('submit', postLoginExec)
}

易错点:preventDefault 拼写和大小写必须准确;document.loginForm 依赖模板里 form name="loginForm";adminName、passWord 的字段名要与输入框 name 一致;toastr 要先导入。

模板里给表单和输入框起名 ​

为什么需要:控制器要通过 document.loginForm.adminName、document.loginForm.passWord 取值,模板必须提供对应的 name。
怎么想:表单用 name="loginForm",两个输入框分别用 name="adminName"、name="passWord";提交按钮保持 type="submit",但提交事件交给 JS 处理。
怎么做:src/views/login.ejs:

ejs
<div class="wrapper">
    <div class="login-page">
        <div class="login-box">
            <!-- /.login-logo -->
            <div class="card">
                <div class="card-body login-card-body">
                    <p class="login-box-msg">尚硅谷广告后台管理系统</p>
                    <form name="loginForm">
                        <div class="input-group mb-3">
                            <input name="adminName" type="text" class="form-control" placeholder="管理员账号">
                            <div class="input-group-append">
                                <div class="input-group-text">
                                    <span class="fas fa-user"></span>
                                </div>
                            </div>
                        </div>
                        <div class="input-group mb-3">
                            <input name="passWord" type="password" class="form-control" placeholder="请输入密码">
                            <div class="input-group-append">
                                <div class="input-group-text">
                                    <span class="fas fa-lock"></span>
                                </div>
                            </div>
                        </div>
                        <div class="row">
                            <!-- /.col -->
                            <div class="col-12">
                                <button type="submit" id="loginBtn" class="btn btn-primary btn-block">登陆</button>
                            </div>
                            <!-- /.col -->
                        </div>
                    </form>
                </div>
                <!-- /.login-card-body -->
            </div>
        </div>
    </div>

</div>

易错点:name 写错会导致取值 undefined;给按钮监听 click 仍可能触发表单默认提交,所以这里监听 form 的 submit 并阻止默认行为。

API 只做薄封装 ​

为什么需要:把请求地址和请求实例集中到 API 层,控制器只关心“传什么、成功后做什么”。
怎么想:登录是 POST,请求体包含 adminName、passWord,路径为 /login。
怎么做:src/api/login.js:

js
// 导入实例
import advServer from '../request/advserver';

export const postLogin = body => {
    return advServer.post('/login', body);
}

易错点:路径、请求方法、字段名要与后端一致;不要把 adminName 写成 username。

登录成功后存账号名并显示 ​

为什么需要:登录成功后跳到后台页面,侧边栏要显示“欢迎您:账号名”,所以要把账号名保存到浏览器本地。
怎么想:项目用 localStorage 存 adminName,后续页面可直接读取;侧边栏组件无需再层层传参。
怎么做:成功回调里执行 localStorage.setItem('adminName', adminName),再 router.go('/index/admin')。侧边栏 src/components/Sidebar.ejs:

ejs
<aside class="main-sidebar sidebar-dark-primary elevation-4">
    <!-- Brand Logo -->
    <a href="index3.html" class="brand-link">
        <img src="/img/logo.png" alt="广告管理LOGO" class="brand-image img-circle elevation-3" style="opacity: .8">
        <span class="brand-text font-weight-light">硅谷后台管理系统</span>
    </a>

    <!-- Sidebar -->
    <div class="sidebar">
        <!-- Sidebar user panel (optional) -->
        <div class="user-panel mt-3 pb-3 mb-3 d-flex">
            <div class="info">
                <a href="#" class="d-block">欢迎您:<%= localStorage.getItem('adminName') %></a>
            </div>
        </div>

        <!-- Sidebar Menu -->
        <nav class="mt-2">
            <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
                <!-- 菜单列表 -->
                <% data.routes.filter(item=>item.isNav).forEach(item => { %> 

                <li class="nav-item menu-open">
                    <a href="javascript:;" class="nav-link <%= data.url === item.path ? 'active' : '' %>" onclick="router.go('<%= item.path %>')">
                        <i class="nav-icon fas <%= item.icon %>"></i>
                        <p><%= item.title %></p>
                    </a>
                </li>
                <% }) %>
            </ul>
        </nav>
        <!-- /.sidebar-menu -->
    </div>
    <!-- /.sidebar -->
</aside>

易错点:模板里直接读 localStorage 时,未登录会是 null;本节只完成“登录并显示账号名”,登录拦截应另做。存的是输入账号名,不是 token。

调试过程:表单为什么还在提交 ​

转写稿里先给表单监听 submit,点击后页面仍然提交。排查时发现阻止默认行为写成了类似 event.PreventDefault() 的错误形式,Default 被当成函数调用,实际没有调用到 preventDefault。改成 event.preventDefault() 后,表单不再刷新。另一个调试点是错误账号:请求 HTTP 状态仍是 200,但响应体里 msg 是账号或密码错误,被响应拦截器拦截,因此 .then 不会执行;只有正确账号才进入成功回调。易错点:不要只看 200,要看响应体;preventDefault 必须小写开头并加括号。

退出登录:按钮在组件里,逻辑在控制器里接 ​

为什么需要:登录成功后,用户信息被写进 localStorage,页面能显示“欢迎您 xxx”。但用户点“退出登陆”时,如果只跳回登录页而不清本地状态,下一次进入系统仍可能被当成已登录,所以退出必须同时做两件事:清空本地登录信息、跳转登录页。

怎么想:退出按钮写在 Header.ejs 组件里,而组件本身不直接处理业务。转写稿里明确说,这个按钮被 index 控制器导入并渲染,因此监听逻辑放在 src/controllers/index 中。先给按钮一个稳定 id,再在控制器里查询并绑定 click。转写稿还提到,按钮外层套了 <a>,为了避免 <a> 抢行为,可把它的 href 处理成 javascript:;;笔记代码保留原样,重点是按钮的 id="logoutBtn"。

怎么做:组件代码如下。

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>

控制器中导入 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');
    });
}

易错点:id 必须和查询选择器一致;router 要在入口文件暴露到 window.router 后才能直接使用;localStorage.clear() 会清空本站 localStorage,本项目当前只存登录名,所以可以这样用;如果只想删登录名,也可用 removeItem('adminName')。转写稿里调试时出现过“点了还有用”的情况,本质是监听没绑上或默认行为没处理,先检查 id、查询结果和事件是否真正执行。

全局登录验证:在入口处拦住未登录访问 ​

为什么需要:只做退出登录还不够。转写稿里演示过:退出后手动访问 index 仍能进来,只是拿不到数据。不能靠用户自觉登录,必须在应用入口做全局验证:没有登录信息就跳登录页。

怎么想:验证要放在“最开始的地方”,也就是 src/app.js 创建路由、暴露 window.router 之后、遍历 routes 注册路由之前。判断依据是登录成功时写入的 localStorage 键,转写稿中使用的是 adminName。键名必须和登录写入时一致。

怎么做:入口文件代码如下。

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);
})

易错点:验证代码不要放到路由注册之后,否则可能先进入页面再跳转;getItem('adminName') 的键名要和登录成功时 setItem 的键名完全一致;router.go('/login') 依赖全局 router,所以先执行 window.router = router。转写稿中反复纠正的同音词也要注意:Ajax 请求、cookie、session、localStorage、sessionStorage、会话、响应拦截器。本节的核心就是:退出时清状态并回登录页,入口处统一验证,未登录不得进入业务页面。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例一:管理员删除 + sweetalert2 确认框(src/controllers/admin.js) ​

js
// src/controllers/admin.js
// 导入 toastr 及其样式
import toastr from 'toastr';
import '../../node_modules/toastr/build/toastr.css';
// 导入 sweetalert2
import swal from 'sweetalert2';

// 导入发送请求的函数
import { postAdmin, getAdmin, deleteAdmin } from '../api/admin';

// 导入管理员列表表格的组件
import AdminTabelComponent from '../components/AdminTable';
// 导入模板页
import adminV from '@/views/admin';

// 执行添加管理员账号
const addAdminExec = () => {
    // 获取表单数据
    const adminName = document.adminForm.adminName.value.trim();
    const passWord = document.adminForm.passWord.value.trim();
    const rePassWord = document.adminForm.rePassWord.value.trim();

    // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$
    if (adminName.search(/^[a-zA-Z]+$/) === -1) {
        toastr.error('管理员账号只能由英文字母组成!');
        return;
    }

    // 密码是 6-18 位的数字、字母、下划线 ^\w{6,18}$
    if (passWord.search(/^\w{6,18}$/) === -1) {
        toastr.error('密码必须由6到18位数字、字母、下划线组成');
        return;
    }

    // 确认密码
    if (passWord !== rePassWord) {
        toastr.error('两次密码不一致');
        return;
    }

    // 调用后端 API 添加管理员
    postAdmin({
        adminName,
        passWord
    })
        .then(res => {
            toastr.success('管理员添加成功');

            // 关闭模态框
            $('#addAdminModal').modal('hide');
            // 清空表单
            document.adminForm.reset();
            // 更新列表
            getAdminExec();
        });
};

// 获取管理员信息 并渲染页面
const getAdminExec = () => {
    // 向后端 API 请求
    getAdmin()
        .then(res => {
            // 将数据发送到管理员表格组件中,将组件设置到模板的指定位置
            document.querySelector('#amdinListBox').innerHTML =
                AdminTabelComponent({ adminList: res.data });
        });
};

// 执行管理员的删除
const deleteAdminExec = event => {
    // 判断目标元素是否是删除按钮
    if (event.target.classList.contains('btn-danger')) {
        // 弹出确认框
        swal.fire({
            title: '确定删除?',
            icon: 'warning',
            showCancelButton: true,
            confirmButtonText: '确定',
            cancelButtonText: '取消'
        }).then((result) => {
            if (result.isConfirmed) {
                // 调用后端 API 执行删除
                deleteAdmin(event.target.dataset.id)
                    .then(res => {
                        // 更新列表
                        getAdminExec();
                        // 弹框提示删除成功
                        swal.fire({
                            title: '删除成功!',
                            icon: 'success'
                        });
                    });
            }
        });
    }
};

export default (req, res) => {
    // 加载模板内容
    res.render(adminV());

    // 获取管理员信息
    getAdminExec();

    // 给添加按钮监听 click 事件
    document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec);

    // 给管理员列表中的删除按钮监听单击事件(事件委托,事件位置在外层容器)
    document.querySelector('#amdinListBox').addEventListener('click', deleteAdminExec);
};

关键行与易错点

  • 删除按钮是动态渲染出来的,所以事件监听挂在外层 #amdinListBox 上做事件委托,不能在 getAdminExec 之前直接给 .btn-danger 绑定。
  • event.target.classList.contains('btn-danger') 判断目标元素,若删除按钮内部还有 <i> 图标,event.target 会变成图标而匹配失败——这是最常见的坑。
  • deleteAdmin(event.target.dataset.id):id 必须提前写在按钮的 data-id 属性上。
  • swal.fire({...}) 返回 Promise,必须 .then(result => { if (result.isConfirmed) {...} }),只有点“确定”才真正发请求,点“取消”什么都不做。
  • 删除成功后要先 getAdminExec() 刷新列表,再弹成功提示,否则列表数据与界面不同步。

示例二:登录并显示账号名(src/api/login.js + src/views/login.ejs + src/controllers/login.js) ​

js
// src/api/login.js
// 导入实例
import advServer from '../request/advserver';

export const postLogin = body => {
    return advServer.post('/login', body);
};
ejs
<!-- src/views/login.ejs -->
<div class="wrapper">
    <div class="login-page">
        <div class="login-box">
            <div class="card">
                <div class="card-body login-card-body">
                    <p class="login-box-msg">尚硅谷广告后台管理系统</p>
                    <form name="loginForm">
                        <div class="input-group mb-3">
                            <input name="adminName" type="text" class="form-control" placeholder="管理员账号">
                            <div class="input-group-append">
                                <div class="input-group-text">
                                    <span class="fas fa-user"></span>
                                </div>
                            </div>
                        </div>
                        <div class="input-group mb-3">
                            <input name="passWord" type="password" class="form-control" placeholder="请输入密码">
                            <div class="input-group-append">
                                <div class="input-group-text">
                                    <span class="fas fa-lock"></span>
                                </div>
                            </div>
                        </div>
                        <div class="row">
                            <div class="col-12">
                                <button type="submit" id="loginBtn" class="btn btn-primary btn-block">登陆</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
js
// src/controllers/login.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 => {
            // 将用户信息记录到 localStorage
            localStorage.setItem('adminName', adminName);
            // 跳转管理员列表页
            router.go('/index/admin');
        });
};

export default (req, res) => {
    // 渲染模板
    res.render(loginV());

    // 给表单监听提交事件
    document.loginForm.addEventListener('submit', postLoginExec);
};
ejs
<!-- src/components/Sidebar.ejs(只展示显示账号名的关键片段) -->
<div class="user-panel mt-3 pb-3 mb-3 d-flex">
    <div class="info">
        <a href="#" class="d-block">欢迎您:<%= localStorage.getItem('adminName') %></a>
    </div>
</div>

关键行与易错点

  • 登录按钮在 <form> 内部且 type="submit",所以监听 submit 事件而不是 click,并且必须 event.preventDefault()(注意要加括号调用,写成 event.preventDefault 不调用是无效的,表单照样刷新跳转)。
  • document.loginForm 依赖 <form name="loginForm">,document.adminForm 同理——name 写错就取不到表单,后面全部报 undefined。
  • 空值校验用 .length === 0,字符串长度不可能小于 0,写 <= 0 没有意义。
  • 登录成功后用 localStorage.setItem('adminName', adminName) 保存状态;localStorage 是 window 的属性,天生是全局变量,模板和组件里可直接用,不需要层层传参。
  • 错误提示(账号密码错误)由响应拦截器统一拦截弹出,then 里只会走成功分支,所以不用写 catch。
  • 跳转用全局暴露的 router.go('/index/admin'),注意路径要和路由配置一致。

示例三:登录验证与退出登录(src/app.js + src/controllers/index.js + src/components/Header.ejs) ​

ejs
<!-- src/components/Header.ejs(关键片段:给退出按钮加 id) -->
<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>
js
// src/controllers/index.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');
    });
};
js
// src/app.js
import SMERouter from 'sme-router';
import './assets/less/app.less';
import routes from './routes';

// 创建实例(html5 模式)
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);
});

关键行与易错点

  • 退出按钮在 Header 组件里,但 Header 是被 controllers/index.js 导入并渲染的,所以监听代码要写在 index 控制器里;组件文件本身只负责结构,不负责绑定。
  • <a href="javascript:;"> 用来去掉 <a> 的默认跳转,否则点按钮会顺带触发链接行为。
  • 退出时 localStorage.clear() 是清空全部键值;如果只删一个账号名应使用 localStorage.removeItem('adminName')。
  • 登录验证必须放在入口文件 app.js 的路由注册之前,这是全局拦截,不能只写在某个控制器里,否则用户手动改 hash 依然能进页面。
  • if (!localStorage.getItem('adminName')):getItem 取不到时返回 null,必须取反判断;直接比较 === undefined 是永远不成立的。
  • router.go('/login') 与 router.route(...) 的区别:前者是“跳转”,后者是“注册路由”,写反了页面不会显示。

常见错误 ​

  • 删除按钮点图标没反应:现象是点击删除按钮内的图标时不触发删除;原因是事件委托中只判断 event.target.classList.contains('btn-danger'),实际目标可能是子元素;修法是使用 event.target.closest('.btn-danger') 并判断非空,再取 dataset.id。
  • sweetalert2 确认后流程乱序:现象是未确认也执行删除,或成功提示早于删除完成;原因是把异步 swal.fire().then() 当同步代码写,未把删除逻辑放进确认分支;修法是把删除请求放进 if (result.isConfirmed),并在删除请求完成后再提示成功、刷新列表。
  • 登录表单仍刷新页面:现象是点击登录后页面跳走/参数丢失;原因是监听了按钮点击但未正确阻止表单默认提交,或 preventDefault 写成属性未调用;修法是监听 form 的 submit,首行调用 event.preventDefault()。
  • 未登录仍能进入后台页面:现象是清空登录态后直接访问 /index/admin 仍可进入;原因是登录校验放在路由注册之后且未中断后续流程;修法是在入口最早处判断登录态,未登录时 router.go('/login') 后 return,避免继续注册/渲染目标页。
  • 退出登录误清全部缓存:现象是退出后其他页面临时数据也丢失;原因是直接 localStorage.clear();修法是只删除登录标识,如 localStorage.removeItem('adminName'),确需全清时再明确说明。
  • 动态列表刷新后删除失效:现象是首次删除正常,刷新列表后新按钮无事件;原因是给每个删除按钮单独绑定事件;修法是继续使用列表容器的委托监听,并在每次渲染后无需重新绑定。

练习 ​

  1. 将管理员删除改为事件委托:要求点击按钮内任意子元素都能取到 data-id,并避免误触页面其他 .btn-danger。
  2. 用 sweetalert2 实现删除流程:确认框选择取消时不发请求;确认后删除成功再刷新列表并提示成功。
  3. 完成登录表单提交:阻止默认提交,校验账号/密码非空,成功后保存登录标识并跳转管理员列表页。
  4. 实现退出登录与全局登录守卫:退出只清理登录标识;未登录访问任意后台路由时跳转登录页,并确保不继续渲染目标页面。
  5. 设计“写文章草稿”临时保存:用 sessionStorage 定时保存文本域内容,刷新/重新打开当前会话时可恢复,并说明为何不选 localStorage 或 cookie。

原视频对照 ​

#视频时长
1localStorage浏览器本地存储13:42
2sessionStorage浏览器会话本地存储04:58
3localStorage、sessionStorage与cookie的区别05:25
4登录并显示账号名19:42
5登录验证和退出登录09:48

小结 ​

  • 动态列表的交互优先用事件委托,删除类操作要用 closest 定位真实按钮,避免子元素干扰。
  • sweetalert2 的核心是 Promise 链,确认、请求、提示、刷新必须按异步顺序组织。
  • 表单登录应监听 submit 而非只监听按钮点击,preventDefault() 必须作为函数调用。
  • 登录态存储只是前端状态管理,不等于安全认证;敏感凭证不应长期明文放在本地存储。
  • 全局登录守卫必须放在入口最早执行,并在跳转后中断后续流程。
  • 退出登录应最小化清理,只移除当前业务需要的登录标识,避免误伤其他缓存。
  • localStorage、sessionStorage、cookie 的选择取决于作用域、生命周期和是否随请求发送,不要混用。
  • 删除、登录、退出这类流程要同时验证成功、失败、取消和刷新后的状态一致性。