Java 项目实战: 外卖平台-后台退出功能与首页iframe架构

后台退出功能、Session 生命周期与首页 iframe 架构

纲要

本篇完成登录的配套功能 ------ 退出,并解析管理后台首页的构成原理。

  • 退出接口POST /employee/logout,清理 Session 中的员工 ID
  • 双端状态同步 :服务端清 Session,浏览器清 localStorage
  • 调试技巧 :前端 axios 超时时间调整、浏览器缓存清理
  • 首页构成 :左侧 ElementUI 菜单 + 右侧 iframe 内容区
  • menuList 数据驱动 :菜单由 JSON 数组渲染,非硬编码
  • iframeUrl 切换机制 :点击菜单即切换 iframesrc,默认页由初始值决定

退出功能需求分析

前端行为观察

登录成功后跳转到 backend/page/index.html,右上角显示当前登录员工姓名,旁边有一个电源图标 ------ 那就是退出按钮。

F12 打开网络面板,点击退出按钮,观察到请求:

text 复制代码
POST http://localhost:8080/employee/logout
Response: 404   (后端尚未实现)

退出按钮与 logout 方法

index.html 中退出按钮的定义:

html 复制代码
<div class="right-menu">
  <div class="avatar-wrapper">{{ userInfo.name }}</div>
  <img src="images/icons/btn_close@2x.png" class="outLogin" @click="logout" />
</div>

Vuemethods 中:

javascript 复制代码
async logout() {
  const res = await logoutApi()
  if (String(res.code) === '1') {
    // 清除浏览器端保存的用户信息
    localStorage.removeItem('userInfo')
    // 跳转回登录页
    window.location.href = '/backend/page/login.html'
  }
}

backend/api/login.js 中的 logoutApi

javascript 复制代码
function logoutApi() {
  return $axios({
    'url': '/employee/logout',
    'method': 'post'
  })
}

当前登录用户名是怎么显示的

首页右上角显示的「管理员」不是写死的,而是从 localStorage 读出后动态渲染:

javascript 复制代码
new Vue({
  el: '#index-app',
  data() {
    return {
      userInfo: {},
      menuList: [ /* 菜单数据 */ ],
      iframeUrl: 'page/member/list.html'
    }
  },
  created() {
    // Vue 实例创建时自动执行的钩子函数
    const userInfo = window.localStorage.getItem('userInfo')
    if (userInfo) {
      this.userInfo = JSON.parse(userInfo)
    }
  }
})

这就形成了一条完整的数据链路:
index.html 浏览器 localStorage EmployeeController login.html index.html 浏览器 localStorage EmployeeController login.html #mermaid-svg-mHAI7e9W4L86PXRN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mHAI7e9W4L86PXRN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mHAI7e9W4L86PXRN .error-icon{fill:#552222;}#mermaid-svg-mHAI7e9W4L86PXRN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mHAI7e9W4L86PXRN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mHAI7e9W4L86PXRN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mHAI7e9W4L86PXRN .marker.cross{stroke:#333333;}#mermaid-svg-mHAI7e9W4L86PXRN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mHAI7e9W4L86PXRN p{margin:0;}#mermaid-svg-mHAI7e9W4L86PXRN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-mHAI7e9W4L86PXRN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-mHAI7e9W4L86PXRN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-mHAI7e9W4L86PXRN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-mHAI7e9W4L86PXRN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-mHAI7e9W4L86PXRN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-mHAI7e9W4L86PXRN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-mHAI7e9W4L86PXRN .sequenceNumber{fill:white;}#mermaid-svg-mHAI7e9W4L86PXRN #sequencenumber{fill:#333;}#mermaid-svg-mHAI7e9W4L86PXRN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-mHAI7e9W4L86PXRN .messageText{fill:#333;stroke:none;}#mermaid-svg-mHAI7e9W4L86PXRN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-mHAI7e9W4L86PXRN .labelText,#mermaid-svg-mHAI7e9W4L86PXRN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-mHAI7e9W4L86PXRN .loopText,#mermaid-svg-mHAI7e9W4L86PXRN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-mHAI7e9W4L86PXRN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-mHAI7e9W4L86PXRN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-mHAI7e9W4L86PXRN .noteText,#mermaid-svg-mHAI7e9W4L86PXRN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-mHAI7e9W4L86PXRN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-mHAI7e9W4L86PXRN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-mHAI7e9W4L86PXRN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-mHAI7e9W4L86PXRN .actorPopupMenu{position:absolute;}#mermaid-svg-mHAI7e9W4L86PXRN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-mHAI7e9W4L86PXRN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-mHAI7e9W4L86PXRN .actor-man circle,#mermaid-svg-mHAI7e9W4L86PXRN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-mHAI7e9W4L86PXRN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /employee/login R.success(employee) localStorage.setItem('userInfo', JSON.stringify(res.data)) 跳转 /backend/page/index.html localStorage.getItem('userInfo') 用户 JSON 渲染 {{ userInfo.name }}

登录时 setItem 写入,首页 created 钩子里 getItem 读出,退出时 removeItem 清除 ------ 三者使用同一个 keyuserInfo

退出功能实现

核心逻辑

退出只需要做一件事:清理 Session 中保存的当前登录员工 ID
#mermaid-svg-WtSoKpPdxmLNxl9W{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WtSoKpPdxmLNxl9W .error-icon{fill:#552222;}#mermaid-svg-WtSoKpPdxmLNxl9W .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WtSoKpPdxmLNxl9W .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WtSoKpPdxmLNxl9W .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WtSoKpPdxmLNxl9W .marker.cross{stroke:#333333;}#mermaid-svg-WtSoKpPdxmLNxl9W svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WtSoKpPdxmLNxl9W p{margin:0;}#mermaid-svg-WtSoKpPdxmLNxl9W .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W .cluster-label text{fill:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W .cluster-label span{color:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W .cluster-label span p{background-color:transparent;}#mermaid-svg-WtSoKpPdxmLNxl9W .label text,#mermaid-svg-WtSoKpPdxmLNxl9W span{fill:#333;color:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W .node rect,#mermaid-svg-WtSoKpPdxmLNxl9W .node circle,#mermaid-svg-WtSoKpPdxmLNxl9W .node ellipse,#mermaid-svg-WtSoKpPdxmLNxl9W .node polygon,#mermaid-svg-WtSoKpPdxmLNxl9W .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WtSoKpPdxmLNxl9W .rough-node .label text,#mermaid-svg-WtSoKpPdxmLNxl9W .node .label text,#mermaid-svg-WtSoKpPdxmLNxl9W .image-shape .label,#mermaid-svg-WtSoKpPdxmLNxl9W .icon-shape .label{text-anchor:middle;}#mermaid-svg-WtSoKpPdxmLNxl9W .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WtSoKpPdxmLNxl9W .rough-node .label,#mermaid-svg-WtSoKpPdxmLNxl9W .node .label,#mermaid-svg-WtSoKpPdxmLNxl9W .image-shape .label,#mermaid-svg-WtSoKpPdxmLNxl9W .icon-shape .label{text-align:center;}#mermaid-svg-WtSoKpPdxmLNxl9W .node.clickable{cursor:pointer;}#mermaid-svg-WtSoKpPdxmLNxl9W .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WtSoKpPdxmLNxl9W .arrowheadPath{fill:#333333;}#mermaid-svg-WtSoKpPdxmLNxl9W .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WtSoKpPdxmLNxl9W .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WtSoKpPdxmLNxl9W .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WtSoKpPdxmLNxl9W .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WtSoKpPdxmLNxl9W .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WtSoKpPdxmLNxl9W .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WtSoKpPdxmLNxl9W .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WtSoKpPdxmLNxl9W .cluster text{fill:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W .cluster span{color:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WtSoKpPdxmLNxl9W .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WtSoKpPdxmLNxl9W rect.text{fill:none;stroke-width:0;}#mermaid-svg-WtSoKpPdxmLNxl9W .icon-shape,#mermaid-svg-WtSoKpPdxmLNxl9W .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WtSoKpPdxmLNxl9W .icon-shape p,#mermaid-svg-WtSoKpPdxmLNxl9W .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WtSoKpPdxmLNxl9W .icon-shape .label rect,#mermaid-svg-WtSoKpPdxmLNxl9W .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WtSoKpPdxmLNxl9W .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WtSoKpPdxmLNxl9W .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WtSoKpPdxmLNxl9W :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击退出按钮
POST /employee/logout
session.removeAttribute('employee')
返回 R.success('退出成功')
前端 localStorage.removeItem('userInfo')
跳转 login.html

注意这里存在两套状态,必须都清理干净:

状态 存储位置 清理方式
服务端登录态 HttpSessionTomcat 内存) session.removeAttribute("employee")
浏览器用户信息 localStorage(浏览器本地存储) localStorage.removeItem('userInfo')

只清 Session 不清 localStorage,页面还会显示上一个用户的名字;只清 localStorage 不清 Session,服务端仍认为用户在线 ------ 后续登录校验过滤器会放行。

方法实现

java 复制代码
/**
 * 员工退出
 * @param request
 * @return
 */
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request) {
    // 清理 Session 中保存的当前登录员工的 id
    request.getSession().removeAttribute("employee");
    return R.success("退出成功");
}

三点设计说明:

  • 返回类型是 R<String> 而非 R<Employee> ------ 退出不需要携带数据,泛型用 String 承载一句提示即可
  • 参数只有 HttpServletRequest ------ 退出不需要任何业务入参,只要能拿到 Session
  • removeAttributekey 必须与登录时 setAttributekey 完全一致 (都是 "employee"),写错不会报错,但退出会静默失效

完整 EmployeeController(登录 + 退出)

java 复制代码
package com.itheima.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.service.EmployeeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;

    /**
     * 员工登录
     * @param request
     * @param employee
     * @return
     */
    @PostMapping("/login")
    public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {

        //1、将页面提交的密码password进行md5加密处理
        String password = employee.getPassword();
        password = DigestUtils.md5DigestAsHex(password.getBytes());

        //2、根据页面提交的用户名username查询数据库
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Employee::getUsername, employee.getUsername());
        Employee emp = employeeService.getOne(queryWrapper);

        //3、如果没有查询到则返回登录失败结果
        if (emp == null) {
            return R.error("登录失败");
        }

        //4、密码比对,如果不一致则返回登录失败结果
        if (!emp.getPassword().equals(password)) {
            return R.error("登录失败");
        }

        //5、查看员工状态,如果为已禁用状态,则返回员工已禁用结果
        if (emp.getStatus() == 0) {
            return R.error("账号已禁用");
        }

        //6、登录成功,将员工id存入Session并返回登录成功结果
        request.getSession().setAttribute("employee", emp.getId());
        return R.success(emp);
    }

    /**
     * 员工退出
     * @param request
     * @return
     */
    @PostMapping("/logout")
    public R<String> logout(HttpServletRequest request) {
        // 清理 Session 中保存的当前登录员工的 id
        request.getSession().removeAttribute("employee");
        return R.success("退出成功");
    }
}

功能测试

测试用例

场景 操作步骤 预期结果
密码错误 输入 admin / 111111 页面提示「登录失败」
用户名不存在 输入 notexist / 123456 页面提示「登录失败」
账号禁用 employee.status 改为 0 后登录 页面提示「账号已禁用」
正常登录 admin / 123456 跳转首页,右上角显示姓名
正常退出 点击电源图标 跳回登录页,localStorageuserInfo 消失

测试「账号已禁用」分支后务必把 status 改回 1,否则后续所有登录都会失败。

调试阶段的两个必备调整

第一,调大前端 axios 超时时间。

默认超时是 10000 毫秒。断点调试时,程序停在断点上不给响应,前端 10 秒后就抛 timeout 错误,看不到真实响应。

修改 backend/js/request.js

javascript 复制代码
// 创建 axios 实例
const service = axios.create({
  baseURL: '/',
  // 断点调试时把超时时间调大,例如 1000000(约 16 分钟)
  timeout: 1000000
})

第二,清理浏览器缓存。

修改 JS 文件后刷新页面仍然不生效,是浏览器缓存了旧的 JS。需要在「设置 → 清除浏览数据 → 缓存的图片和文件」中清理,或使用 Ctrl + Shift + R 强制刷新。

验证响应格式

登录成功的响应体:

json 复制代码
{
  "code": 1,
  "msg": null,
  "data": {
    "id": "1",
    "username": "admin",
    "name": "管理员",
    "password": "e10adc3949ba59abbe56e057f20f883e",
    "phone": "13812345678",
    "sex": "1",
    "idNumber": "110101199001010047",
    "status": 1,
    "createTime": "2021-05-06 17:20:07",
    "updateTime": "2021-05-10 02:24:09",
    "createUser": "1",
    "updateUser": "1"
  },
  "map": {}
}

这正是由 R 对象序列化而来,四个属性一一对应。注意 idcreateUserupdateUser 都是字符串createTimeyyyy-MM-dd HH:mm:ss 格式 ------ JacksonObjectMapper 的两个作用在此同时可见。

后台首页构成分析

页面分区

#mermaid-svg-dkeT7EGtxBDDsxhU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dkeT7EGtxBDDsxhU .error-icon{fill:#552222;}#mermaid-svg-dkeT7EGtxBDDsxhU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dkeT7EGtxBDDsxhU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dkeT7EGtxBDDsxhU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dkeT7EGtxBDDsxhU .marker.cross{stroke:#333333;}#mermaid-svg-dkeT7EGtxBDDsxhU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dkeT7EGtxBDDsxhU p{margin:0;}#mermaid-svg-dkeT7EGtxBDDsxhU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU .cluster-label text{fill:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU .cluster-label span{color:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU .cluster-label span p{background-color:transparent;}#mermaid-svg-dkeT7EGtxBDDsxhU .label text,#mermaid-svg-dkeT7EGtxBDDsxhU span{fill:#333;color:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU .node rect,#mermaid-svg-dkeT7EGtxBDDsxhU .node circle,#mermaid-svg-dkeT7EGtxBDDsxhU .node ellipse,#mermaid-svg-dkeT7EGtxBDDsxhU .node polygon,#mermaid-svg-dkeT7EGtxBDDsxhU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dkeT7EGtxBDDsxhU .rough-node .label text,#mermaid-svg-dkeT7EGtxBDDsxhU .node .label text,#mermaid-svg-dkeT7EGtxBDDsxhU .image-shape .label,#mermaid-svg-dkeT7EGtxBDDsxhU .icon-shape .label{text-anchor:middle;}#mermaid-svg-dkeT7EGtxBDDsxhU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dkeT7EGtxBDDsxhU .rough-node .label,#mermaid-svg-dkeT7EGtxBDDsxhU .node .label,#mermaid-svg-dkeT7EGtxBDDsxhU .image-shape .label,#mermaid-svg-dkeT7EGtxBDDsxhU .icon-shape .label{text-align:center;}#mermaid-svg-dkeT7EGtxBDDsxhU .node.clickable{cursor:pointer;}#mermaid-svg-dkeT7EGtxBDDsxhU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dkeT7EGtxBDDsxhU .arrowheadPath{fill:#333333;}#mermaid-svg-dkeT7EGtxBDDsxhU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dkeT7EGtxBDDsxhU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dkeT7EGtxBDDsxhU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dkeT7EGtxBDDsxhU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dkeT7EGtxBDDsxhU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dkeT7EGtxBDDsxhU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dkeT7EGtxBDDsxhU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dkeT7EGtxBDDsxhU .cluster text{fill:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU .cluster span{color:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dkeT7EGtxBDDsxhU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dkeT7EGtxBDDsxhU rect.text{fill:none;stroke-width:0;}#mermaid-svg-dkeT7EGtxBDDsxhU .icon-shape,#mermaid-svg-dkeT7EGtxBDDsxhU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dkeT7EGtxBDDsxhU .icon-shape p,#mermaid-svg-dkeT7EGtxBDDsxhU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dkeT7EGtxBDDsxhU .icon-shape .label rect,#mermaid-svg-dkeT7EGtxBDDsxhU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dkeT7EGtxBDDsxhU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dkeT7EGtxBDDsxhU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dkeT7EGtxBDDsxhU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} backend/page/index.html
右侧:内容区
左侧:菜单区
@click menuHandle(item)
iframe

:src = iframeUrl
element-ui el-menu

v-for 遍历 menuList

页面被切成两块:左侧是 ElementUIel-menu 组件,右侧是一个 iframe

菜单数据驱动

菜单不是硬编码在 HTML 里的,而是由一个 JSON 数组渲染:

javascript 复制代码
data() {
  return {
    menuList: [
      {
        id: '2',
        name: '员工管理',
        url: 'page/member/list.html',
        icon: 'icon-member'
      },
      {
        id: '3',
        name: '分类管理',
        url: 'page/category/list.html',
        icon: 'icon-category'
      },
      {
        id: '4',
        name: '菜品管理',
        url: 'page/food/list.html',
        icon: 'icon-food'
      },
      {
        id: '5',
        name: '套餐管理',
        url: 'page/combo/list.html',
        icon: 'icon-combo'
      },
      {
        id: '6',
        name: '订单明细',
        url: 'page/order/list.html',
        icon: 'icon-order'
      }
    ]
  }
}

模板中的渲染逻辑:

html 复制代码
<el-menu>
  <div v-for="item in menuList" :key="item.id" @click="menuHandle(item, false)">
    <el-menu-item v-if="item.children && item.children.length > 0">
      <!-- 二级菜单分支,本项目菜单只有一层,此分支不生效 -->
    </el-menu-item>
    <el-menu-item v-else>
      <i :class="item.icon"></i>
      <span>{{ item.name }}</span>
    </el-menu-item>
  </div>
</el-menu>

要点:

  • v-for="item in menuList" ------ item 是任意变量名,只要模板内引用一致
  • v-if 分支判断是否有子菜单;本项目菜单只有一层,所以始终走 v-else
  • {``{ item.name }} 决定显示文字。若改成 {``{ item.id }},页面上会显示 2 3 4 5 6

生产环境中,menuList 通常由后端根据用户权限动态返回;本项目直接在前端写死,是因为权限体系不是重点。

iframe 切换机制

右侧内容区的定义:

html 复制代码
<iframe
  :src="iframeUrl"
  id="cIframe"
  class="c_iframe"
  frameborder="0"
></iframe>

iframeUrl 的初始值:

javascript 复制代码
data() {
  return {
    // 登录后默认展示员工管理页面
    iframeUrl: 'page/member/list.html'
  }
}

点击菜单时触发的方法:

javascript 复制代码
menuHandle(item, goBackFlag) {
  // 切换 iframe 的 src,从而加载不同的页面
  this.iframeUrl = item.url
  this.goBackFlag = goBackFlag
  this.headTitle = item.name
}

iframe Vue 实例 el-menu 菜单项 用户 iframe Vue 实例 el-menu 菜单项 用户 #mermaid-svg-OCQeX6aynYlidYmu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OCQeX6aynYlidYmu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OCQeX6aynYlidYmu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OCQeX6aynYlidYmu .error-icon{fill:#552222;}#mermaid-svg-OCQeX6aynYlidYmu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OCQeX6aynYlidYmu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OCQeX6aynYlidYmu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OCQeX6aynYlidYmu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OCQeX6aynYlidYmu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OCQeX6aynYlidYmu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OCQeX6aynYlidYmu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OCQeX6aynYlidYmu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OCQeX6aynYlidYmu .marker.cross{stroke:#333333;}#mermaid-svg-OCQeX6aynYlidYmu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OCQeX6aynYlidYmu p{margin:0;}#mermaid-svg-OCQeX6aynYlidYmu .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OCQeX6aynYlidYmu text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-OCQeX6aynYlidYmu .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OCQeX6aynYlidYmu .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-OCQeX6aynYlidYmu .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-OCQeX6aynYlidYmu .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-OCQeX6aynYlidYmu #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-OCQeX6aynYlidYmu .sequenceNumber{fill:white;}#mermaid-svg-OCQeX6aynYlidYmu #sequencenumber{fill:#333;}#mermaid-svg-OCQeX6aynYlidYmu #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-OCQeX6aynYlidYmu .messageText{fill:#333;stroke:none;}#mermaid-svg-OCQeX6aynYlidYmu .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OCQeX6aynYlidYmu .labelText,#mermaid-svg-OCQeX6aynYlidYmu .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-OCQeX6aynYlidYmu .loopText,#mermaid-svg-OCQeX6aynYlidYmu .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-OCQeX6aynYlidYmu .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OCQeX6aynYlidYmu .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-OCQeX6aynYlidYmu .noteText,#mermaid-svg-OCQeX6aynYlidYmu .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-OCQeX6aynYlidYmu .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OCQeX6aynYlidYmu .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OCQeX6aynYlidYmu .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OCQeX6aynYlidYmu .actorPopupMenu{position:absolute;}#mermaid-svg-OCQeX6aynYlidYmu .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-OCQeX6aynYlidYmu .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OCQeX6aynYlidYmu .actor-man circle,#mermaid-svg-OCQeX6aynYlidYmu line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-OCQeX6aynYlidYmu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击「订单明细」 menuHandle(item, false) this.iframeUrl = 'page/order/list.html' :src 绑定变化,重新加载 请求 page/order/list.html 并渲染

iframe 相当于在主页面上「挖了一个坑」,坑里展示什么页面完全由 iframeUrl 决定。这也是为什么登录后默认看到的是员工管理页 ------ 因为 iframeUrl 的初始值就是 page/member/list.html

这种架构的优缺点:

优点 缺点
主框架与业务页面完全解耦,业务页可独立开发测试 每次切换都要重新加载页面资源
不需要前端路由框架 iframe 内的页面与主页面通信需借助 postMessage
适合后台管理系统这类页面数量有限的场景 浏览器前进后退需要额外处理

菜单 url 与目录的对应关系

text 复制代码
src/main/resources/backend/page/
├── member/
│   └── list.html        # 员工管理  → page/member/list.html
├── category/
│   └── list.html        # 分类管理  → page/category/list.html
├── food/
│   ├── list.html        # 菜品管理  → page/food/list.html
│   └── add.html         # 新增菜品
├── combo/
│   ├── list.html        # 套餐管理  → page/combo/list.html
│   └── add.html         # 新增套餐
└── order/
    └── list.html        # 订单明细  → page/order/list.html

iframeUrl 的路径是相对于 backend/ 目录的,不是相对于 page/。这一点在自定义菜单时极易写错。

完整可运行代码

目录结构

text 复制代码
src/main/java/com/itheima/reggie/
├── ReggieApplication.java
├── common/
│   └── R.java
├── config/
│   └── WebMvcConfig.java
├── controller/
│   └── EmployeeController.java     # login + logout
├── entity/
│   └── Employee.java
├── mapper/
│   └── EmployeeMapper.java
└── service/
    ├── EmployeeService.java
    └── impl/
        └── EmployeeServiceImpl.java

EmployeeController.java

java 复制代码
package com.itheima.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.service.EmployeeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;

    /**
     * 员工登录
     * @param request
     * @param employee
     * @return
     */
    @PostMapping("/login")
    public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {

        //1、将页面提交的密码password进行md5加密处理
        String password = employee.getPassword();
        password = DigestUtils.md5DigestAsHex(password.getBytes());

        //2、根据页面提交的用户名username查询数据库
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Employee::getUsername, employee.getUsername());
        Employee emp = employeeService.getOne(queryWrapper);

        //3、如果没有查询到则返回登录失败结果
        if (emp == null) {
            return R.error("登录失败");
        }

        //4、密码比对,如果不一致则返回登录失败结果
        if (!emp.getPassword().equals(password)) {
            return R.error("登录失败");
        }

        //5、查看员工状态,如果为已禁用状态,则返回员工已禁用结果
        if (emp.getStatus() == 0) {
            return R.error("账号已禁用");
        }

        //6、登录成功,将员工id存入Session并返回登录成功结果
        request.getSession().setAttribute("employee", emp.getId());
        return R.success(emp);
    }

    /**
     * 员工退出
     * @param request
     * @return
     */
    @PostMapping("/logout")
    public R<String> logout(HttpServletRequest request) {
        //清理Session中保存的当前登录员工的id
        request.getSession().removeAttribute("employee");
        return R.success("退出成功");
    }
}

R.java

java 复制代码
package com.itheima.reggie.common;

import lombok.Data;
import java.util.HashMap;
import java.util.Map;

/**
 * 通用返回结果,服务端响应的数据最终都会封装成此对象
 * @param <T>
 */
@Data
public class R<T> {

    private Integer code; //编码:1成功,0和其它数字为失败

    private String msg; //错误信息

    private T data; //数据

    private Map map = new HashMap(); //动态数据

    public static <T> R<T> success(T object) {
        R<T> r = new R<T>();
        r.data = object;
        r.code = 1;
        return r;
    }

    public static <T> R<T> error(String msg) {
        R r = new R();
        r.msg = msg;
        r.code = 0;
        return r;
    }

    public R<T> add(String key, Object value) {
        this.map.put(key, value);
        return this;
    }
}

前端 login.js(接口封装参考)

javascript 复制代码
// backend/api/login.js
function loginApi(data) {
  return $axios({
    'url': '/employee/login',
    'method': 'post',
    data
  })
}

function logoutApi() {
  return $axios({
    'url': '/employee/logout',
    'method': 'post'
  })
}

前端 index.html 核心 Vue 逻辑(参考)

javascript 复制代码
new Vue({
  el: '#index-app',
  data() {
    return {
      userInfo: {},
      // 登录后默认展示的页面
      iframeUrl: 'page/member/list.html',
      menuList: [
        { id: '2', name: '员工管理', url: 'page/member/list.html', icon: 'icon-member' },
        { id: '3', name: '分类管理', url: 'page/category/list.html', icon: 'icon-category' },
        { id: '4', name: '菜品管理', url: 'page/food/list.html', icon: 'icon-food' },
        { id: '5', name: '套餐管理', url: 'page/combo/list.html', icon: 'icon-combo' },
        { id: '6', name: '订单明细', url: 'page/order/list.html', icon: 'icon-order' }
      ]
    }
  },
  created() {
    // Vue 实例创建完成即执行,从浏览器存储中恢复用户信息
    const userInfo = window.localStorage.getItem('userInfo')
    if (userInfo) {
      this.userInfo = JSON.parse(userInfo)
    }
  },
  methods: {
    // 点击菜单:切换 iframe 的 src
    menuHandle(item, goBackFlag) {
      this.iframeUrl = item.url
      this.headTitle = item.name
    },
    // 退出:清理浏览器存储并跳回登录页
    async logout() {
      const res = await logoutApi()
      if (String(res.code) === '1') {
        localStorage.removeItem('userInfo')
        window.location.href = '/backend/page/login.html'
      }
    }
  }
})

API 速览

API / 注解 所属 作用
@PostMapping Spring MVC 映射 POST 请求
HttpServletRequest.getSession() Servlet 获取会话对象,不存在则创建
HttpSession.setAttribute Servlet 向会话写入属性
HttpSession.removeAttribute Servlet 从会话移除属性
localStorage.setItem 浏览器 API 以键值对形式持久化存储
localStorage.getItem 浏览器 API 按键读取存储值
localStorage.removeItem 浏览器 API 按键删除存储值
JSON.stringify / JSON.parse JavaScript 对象与 JSON 字符串互转
created() Vue 实例创建后自动执行的钩子函数
v-for Vue 列表渲染指令
v-if / v-else Vue 条件渲染指令
@click Vue 点击事件绑定
:src Vue 属性动态绑定
el-menu ElementUI 菜单组件

官方文档

总结

本篇围绕「退出功能」与「后台首页架构」两条主线展开,核心要点如下:

退出功能的本质是双端状态同步清理。 服务端通过 request.getSession().removeAttribute("employee") 清除 HttpSession 中的登录态,浏览器端通过 localStorage.removeItem('userInfo') 清除本地用户信息,两者缺一不可。只清一端都会留下隐患:只清 Session 页面仍显示旧用户名,只清 localStorage 服务端仍认为用户在线,后续登录校验过滤器会直接放行。

key 的一致性至关重要。 removeAttributekey 必须与登录时 setAttributekey 完全一致(都是 "employee"),localStoragekey 也必须与写入时一致(都是 "userInfo")。写错不会报错,但功能会静默失效,这是排查问题时最容易忽略的点。

后台首页采用「左侧菜单 + 右侧 iframe」的经典架构。 菜单由 menuList 数组数据驱动渲染,点击菜单项通过 menuHandle 方法切换 iframeUrl,从而改变右侧内容区加载的页面。这种架构将主框架与业务页面解耦,无需前端路由框架,适合后台管理系统这类页面数量有限的场景;代价是每次切换都要重新加载页面资源,且 iframe 内页面与主页面通信需要借助 postMessage

调试技巧同样值得记录。 断点调试时把前端 axios 超时时间调大(如 1000000 毫秒),避免程序停在断点时前端抛 timeout 错误;修改 JS 文件后若刷新不生效,需清理浏览器缓存或使用 Ctrl + Shift + R 强制刷新。

至此,登录、退出、首页框架三条链路已全部打通,后续可在此基础上继续扩展员工管理、分类管理等业务模块。

相关推荐
海天一色y1 小时前
生产级医疗AI Agent:Multi-Agent RAG架构解析
redis·milvus·multi-agent
Wang's Blog1 小时前
Java 项目实战: 外卖平台-软件开发流程与项目整体介绍
java·服务器·redis
她说..1 小时前
常见设计模式-模板方法模式
java·spring·设计模式·springboot
步行cgn1 小时前
BeanFactory 与 FactoryBean 的区别:面试深度解析
java·后端·spring
X7766X1 小时前
暴雨重构国产大模型底层加速体系
服务器
IT 行者1 小时前
JDK 27 正式发布:内存、安全、并发三路齐发
java
伍一514 小时前
hiprint-spring-boot打印组件介绍
java·hiprint
Wang's Blog9 小时前
Java 接入Redis: 服务启动停止与后台运行配置
java·服务器·redis
此时不提桶,更待何时10 小时前
01-04-B-垃圾回收面试与生产事故实战
java·jvm·面试