后台退出功能、Session 生命周期与首页 iframe 架构
纲要
本篇完成登录的配套功能 ------ 退出,并解析管理后台首页的构成原理。
- 退出接口 :
POST /employee/logout,清理Session中的员工ID - 双端状态同步 :服务端清
Session,浏览器清localStorage - 调试技巧 :前端
axios超时时间调整、浏览器缓存清理 - 首页构成 :左侧
ElementUI菜单 + 右侧iframe内容区 menuList数据驱动 :菜单由JSON数组渲染,非硬编码iframeUrl切换机制 :点击菜单即切换iframe的src,默认页由初始值决定
退出功能需求分析
前端行为观察
登录成功后跳转到 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>
Vue 的 methods 中:
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 清除 ------ 三者使用同一个 key:userInfo。
退出功能实现
核心逻辑
退出只需要做一件事:清理 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
注意这里存在两套状态,必须都清理干净:
| 状态 | 存储位置 | 清理方式 |
|---|---|---|
| 服务端登录态 | HttpSession(Tomcat 内存) |
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 removeAttribute的key必须与登录时setAttribute的key完全一致 (都是"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 |
跳转首页,右上角显示姓名 |
| 正常退出 | 点击电源图标 | 跳回登录页,localStorage 中 userInfo 消失 |
测试「账号已禁用」分支后务必把 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 对象序列化而来,四个属性一一对应。注意 id、createUser、updateUser 都是字符串 ,createTime 是 yyyy-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
页面被切成两块:左侧是 ElementUI 的 el-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 |
菜单组件 |
官方文档
ServletHttpSession:https://jakarta.ee/specifications/servlet/4.0/apidocs/javax/servlet/http/httpsessionVue 2生命周期钩子:https://v2.cn.vuejs.org/v2/guide/instance.htmlVue 2列表渲染:https://v2.cn.vuejs.org/v2/guide/list.htmlElement UIMenu组件:https://element.eleme.cn/#/zh-CN/component/menuMDNWindow.localStorage:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/localStorage
总结
本篇围绕「退出功能」与「后台首页架构」两条主线展开,核心要点如下:
退出功能的本质是双端状态同步清理。 服务端通过 request.getSession().removeAttribute("employee") 清除 HttpSession 中的登录态,浏览器端通过 localStorage.removeItem('userInfo') 清除本地用户信息,两者缺一不可。只清一端都会留下隐患:只清 Session 页面仍显示旧用户名,只清 localStorage 服务端仍认为用户在线,后续登录校验过滤器会直接放行。
key 的一致性至关重要。 removeAttribute 的 key 必须与登录时 setAttribute 的 key 完全一致(都是 "employee"),localStorage 的 key 也必须与写入时一致(都是 "userInfo")。写错不会报错,但功能会静默失效,这是排查问题时最容易忽略的点。
后台首页采用「左侧菜单 + 右侧 iframe」的经典架构。 菜单由 menuList 数组数据驱动渲染,点击菜单项通过 menuHandle 方法切换 iframeUrl,从而改变右侧内容区加载的页面。这种架构将主框架与业务页面解耦,无需前端路由框架,适合后台管理系统这类页面数量有限的场景;代价是每次切换都要重新加载页面资源,且 iframe 内页面与主页面通信需要借助 postMessage。
调试技巧同样值得记录。 断点调试时把前端 axios 超时时间调大(如 1000000 毫秒),避免程序停在断点时前端抛 timeout 错误;修改 JS 文件后若刷新不生效,需清理浏览器缓存或使用 Ctrl + Shift + R 强制刷新。
至此,登录、退出、首页框架三条链路已全部打通,后续可在此基础上继续扩展员工管理、分类管理等业务模块。