覆盖文件:
app/static/login.html(164 行)、app/static/js/auth-utils.js(33 行)、app/static/js/main.js中的认证相关函数下一篇(02)继续讲后端:JWT 怎么签发、密码怎么校验、数据库怎么查。
本篇结束后,你将完整理解"你在登录框输入密码、点登录、看到主页"这 3 秒里,浏览器做了什么。
一、登录的完整旅程(先看地图,再走一步看一步)
你打开 http://localhost:9000/login
→ 服务器返回 login.html(main.py 的 /login 路由,见 02 篇)
→ 页面渲染出一个登录表单
→ 你输入 admin / admin@123,点【登录】按钮
→ JS 拦截 submit 事件(不刷新页面)
→ fetch 发送 POST /api/auth/token(用户名密码)
→ 服务器返回 {access_token: "eyJhbGci...", token_type: "bearer", must_change_password: false}
→ JS 把 token 存进 localStorage 和 Cookie
→ window.location.href = '/' 跳转到主页面
→ 主页面 main.js 启动:isLoggedIn() 检查 → getCurrentUser() 拿用户信息
→ 显示侧边栏菜单(admin 多几个管理员专属菜单)
二、app/static/login.html 逐行讲解
这是"登录页面"本体。它不是用 JS 动态生成的,是服务器直接返回的独立 HTML 文件(
app/main.py的/login路由负责返回它,见 02 篇)。
2.1 文档头部(第 1~12 行)
html
1: <!DOCTYPE html>
2: <html lang="zh-CN">
▶ 第 1 行:声明这是 HTML5 文档。浏览器看到这行就进入标准模式。
▶ 第 2 行:根标签,lang="zh-CN" 告诉浏览器页面语言是简体中文(影响字体渲染和翻译功能)。
html
3: <head>
4: <meta charset="UTF-8">
▶ 第 3 行:<head> 是"头部区",放页面的配置信息,不直接显示。
▶ 第 4 行:声明字符编码为 UTF-8。⚠️ 如果漏了这行,中文会乱码。
html
5: <meta name="viewport" content="width=device-width, initial-scale=1.0">
▶ 第 5 行:响应式布局声明------让页面在手机/平板上也按屏幕宽度显示。width=device-width = 视口宽度等于设备宽度。
html
6: <title>登录 - TrainEye系统</title>
▶ 第 6 行:浏览器标签页上显示的文字。
html
7: <script>
8: (function(){var t=localStorage.getItem('tblr-theme')||'light';document.documentElement.setAttribute('data-bs-theme',t);})();
9: </script>
▶ 第 7~9 行:一段"立即执行函数"((function(){...})() 定义完马上调用)。
▶ 第 8 行拆解:
localStorage.getItem('tblr-theme'):读取用户之前选择的主题(亮/暗),没有就默认'light'document.documentElement.setAttribute('data-bs-theme', t):把主题值写到<html>标签的data-bs-theme属性上- 目的:在 CSS 加载之前就确定主题,避免页面闪烁(先白后黑)。
html
10: <link href="static/libs/tabler.min.css" rel="stylesheet">
11: <link href="static/libs/bootstrap-icons.css" rel="stylesheet">
▶ 第 10 行:引入 Tabler 的 CSS 文件(第三方样式库,负责让页面变好看)。
▶ 第 11 行:引入 Bootstrap Icons 图标库(<i class="bi bi-person"> 这类图标全靠它)。
html
12: </head>
2.2 页面主体(第 13~60 行)
html
13: <body class="d-flex flex-column">
▶ 第 13 行:<body> 是页面可见内容区。class="d-flex flex-column" 是 Bootstrap 的类:d-flex = 弹性布局,flex-column = 垂直排列子元素。这里让页面垂直居中排版。
html
14: <div class="page page-center">
15: <div class="container container-tight py-4">
▶ 第 14 行:page(Tabler 页面容器)、page-center(内容垂直水平居中)。
▶ 第 15 行:container(Bootstrap 居中容器)、container-tight(Tabler:收窄容器宽度,登录框居中好看)、py-4(上下内边距 4 档)。
html
16: <div class="text-center mb-4">
17: <a href="." class="navbar-brand navbar-brand-autodark">
18: <i class="bi bi-camera-video me-2"></i>TrainEye 系统登录
19: </a>
20: </div>
▶ 第 16 行:text-center = 文字居中;mb-4 = 下边距。
▶ 第 17 行:<a href="."> 链接(href="." = 点击跳回首页根路径);navbar-brand = Tabler 的品牌样式。
▶ 第 18 行:<i class="bi bi-camera-video"> 渲染一个摄像头图标(bootstrap-icons 的字体图标),me-2 = 右侧小间距;后面是文字。
html
21: <div class="card card-md">
22: <div class="card-body">
23: <h2 class="h2 text-center mb-4">登录到您的账户</h2>
▶ 第 21 行:card = Bootstrap 卡片(白底圆角带边框的容器),card-md = Tabler 的中等宽度。
▶ 第 22 行:card-body = 卡片内部留白区域。
▶ 第 23 行:标题文字。
html
24: <form id="login-form" autocomplete="off">
▶ 第 24 行:登录表单 。id="login-form" 是关键------第 71 行的 JS 用这个 id 找到表单并绑定提交事件。autocomplete="off" = 关闭浏览器自动填充(本项目默认密码框已填值,自动填充反而碍事)。
html
25: <div class="mb-3">
26: <label class="form-label" for="username">用户名</label>
27: <div class="input-icon">
28: <span class="input-icon-addon">
29: <i class="bi bi-person"></i>
30: </span>
31: <input type="text" class="form-control" id="username" placeholder="请输入用户名" required value="admin">
32: </div>
33: </div>
▶ 第 25 行:mb-3 = 下边距 3 档。
▶ 第 26 行:<label for="username">------for 指向下面输入框的 id,作用:点击"用户名"文字,光标自动跳进输入框 。
▶ 第 27~30 行:input-icon + input-icon-addon = Tabler 的"输入框带图标"样式;bi-person 是个人图标。
▶ 第 31 行:用户名输入框。拆解:
type="text":文本输入id="username":JS 靠它取值(第 74 行document.getElementById('username').value)placeholder:输入框内灰字提示required:必填,不填点提交会被浏览器拦截并提示value="admin":预填了 admin(开发环境方便,生产环境应删掉)
html
34: <div class="mb-2">
35: <label class="form-label" for="password">密码</label>
36: <div class="input-icon">
37: <span class="input-icon-addon">
38: <i class="bi bi-lock"></i>
39: </span>
40: <input type="password" class="form-control" id="password" placeholder="请输入密码" value="admin@123" required>
41: </div>
42: </div>
▶ 第 34~42 行:与用户名框结构相同,区别:
type="password":输入内容显示为圆点(这是浏览器内置行为,不传明文给任何人看,但请求体里还是明文------所以必须用 HTTPS,见 💡)id="password"、value="admin@123"同样预填默认密码
html
43: <div id="login-error" class="alert alert-danger mt-3" style="display: none;"></div>
▶ 第 43 行:错误提示区。alert alert-danger = Bootstrap 红色警告框样式;display: none = 初始隐藏;JS 登录失败时 style.display = 'block' 显示并填文字。
html
44: <div class="form-footer">
45: <button type="submit" class="btn btn-primary w-100">
46: <i class="bi bi-box-arrow-in-right me-1"></i>登录
47: </button>
48: </div>
49: </form>
▶ 第 45 行:type="submit" = 提交按钮(点击会触发表单的 submit 事件);btn btn-primary = 蓝色主按钮;w-100 = 占满整行。
▶ 第 49 行:</form> 表单结束。点提交按钮时,浏览器默认行为是"刷新页面并发送表单"------第 71~72 行用 e.preventDefault() 拦截了它,改为 JS 异步发送。
html
50: </div>
51: </div>
52: <!--<div class="text-center text-muted mt-3"> ... 初始账号提示 ... </div>-->
59: </div>
60: </div>
▶ 第 52~58 行:被 <!-- --> 注释掉的提示区块(显示初始账号),代码保留但不生效。
2.3 脚本区(第 62~162 行)
html
62: <script src="static/libs/tabler.min.js"></script>
▶ 第 62 行:引入 Tabler 的 JS(管理模态框、下拉等组件)。
html
63: <script>
64: document.addEventListener('DOMContentLoaded', function() {
▶ 第 64 行:页面 HTML 全部解析完成后 才执行里面的代码。原因:第 65 行要 getElementById('login-form'),如果 HTML 还没解析完,就找不到元素(返回 null)。
html
65: const loginForm = document.getElementById('login-form');
66: const loginError = document.getElementById('login-error');
▶ 第 65~66 行:用 document.getElementById('xxx')(JS 里最常见的查找元素方法)拿到表单和错误提示元素,存进 const 常量。⚠️ 如果页面结构改了 id,这两行就会拿到 null,后续 addEventListener 会报错。
html
68: // 检查是否已登录,如果已登录则直接跳转到主页面
69: checkLoginStatus();
▶ 第 69 行:调用页面底部定义的 checkLoginStatus()(第 132 行)------如果 localStorage 里已有有效令牌,直接跳转主页,不让你重复登录。
html
71: loginForm.addEventListener('submit', function(e) {
72: e.preventDefault();
▶ 第 71 行:addEventListener('submit', 回调) = 监听表单的"提交"事件 ,用户点登录按钮(或密码框按回车)时触发回调。
▶ 第 72 行:e.preventDefault() = 阻止浏览器默认行为(整页刷新 + 按表单方式提交)。没有这行,整个 SPA 的异步交互就不成立。
html
74: const username = document.getElementById('username').value;
75: const password = document.getElementById('password').value;
▶ 第 74~75 行:读取两个输入框的当前值 (元素.value)。注意是"点按钮那一刻"的值,不是页面加载时的值。
html
77: // 清空之前的错误信息
78: loginError.style.display = 'none';
▶ 第 78 行:隐藏错误框(防止上一次登录失败的红色提示残留)。
html
80: // 发送登录请求
81: fetch('/api/auth/token', {
82: method: 'POST',
83: headers: {
84: 'Content-Type': 'application/x-www-form-urlencoded'
85: },
86: body: new URLSearchParams({
87: 'username': username,
88: 'password': password
89: })
90: })
▶ 第 81 行:fetch('/api/auth/token', {...}) 发出请求。这是登录的核心动作 。
▶ 第 82 行:method: 'POST' 提交数据(不能用 GET,密码不能出现在 URL 里)。
▶ 第 83~85 行:声明请求体格式为 x-www-form-urlencoded(表单编码,username=admin&password=xxx 的样子)。⚠️ 这个 Content-Type 必须和后端期望一致(后端用 OAuth2PasswordRequestForm 接收,见 02 篇;如果这里写 application/json,后端会报 422)。
▶ 第 86~89 行:new URLSearchParams({...}) = 把对象转成 username=admin&password=admin@123 这种 URL 编码字符串作为请求体。
html
91: .then(response => {
92: if (!response.ok) {
93: return response.json().then(err => {
94: throw new Error(err.detail || '登录失败,请检查用户名和密码');
95: });
96: }
97: return response.json();
98: })
▶ 第 91 行:.then(response => {...}) 服务器响应后进入。
▶ 第 92 行:response.ok = 状态码是否 200~299(成功)。网络请求成功 ≠ 登录成功 ------登录失败时服务器返回的是 401 状态码,请求本身是成功的。
▶ 第 93~95 行:登录失败时,读取错误 JSON(FastAPI 的错误格式是 {"detail": "错误信息"}),取出 err.detail 作为错误信息抛出。throw new Error(...) 会跳过后面的 .then,直接跳到 .catch(第 114 行)。
▶ 第 97 行:成功时解析响应 JSON(含 access_token)。
html
99: .then(data => {
100: // 保存token到localStorage
101: localStorage.setItem('access_token', data.access_token);
102: localStorage.setItem('token_type', data.token_type);
▶ 第 99 行:登录成功,拿到 data({access_token: "...", token_type: "bearer", must_change_password: false})。
▶ 第 101~102 行:把令牌存进 localStorage(永久存储,关浏览器还在)。这就是"登录状态"的载体------之后所有请求都靠它证明身份。
html
104: // 同时将token保存到Cookie中,有效期30分钟
105: const tokenExpiry = new Date();
106: tokenExpiry.setTime(tokenExpiry.getTime() + 30 * 60 * 1000);
107: document.cookie = `access_token=${data.access_token}; expires=${tokenExpiry.toUTCString()}; path=/; SameSite=Lax`;
108: document.cookie = `token_type=${data.token_type}; expires=${tokenExpiry.toUTCString()}; path=/; SameSite=Lax`;
▶ 第 105~106 行:算 30 分钟后的时间(30 * 60 * 1000 毫秒)。
▶ 第 107~108 行:把令牌同时写入 Cookie(30 分钟有效)。为什么存两份?------app/main.py 的 / 路由会检查 Cookie 里的令牌作为备用认证方式 (后端第 236~246 行逻辑,见 02 篇)。SameSite=Lax = 防止跨站请求伪造的安全属性。
html
110: // 简化登录流程,直接跳转到主页
111: // 避免因为/api/auth/users/me请求失败导致登录问题
112: window.location.href = '/';
▶ 第 112 行:跳转到主页面 /。window.location.href = 让浏览器导航到新地址(整页跳转,不是 SPA 内部切换)。主页面加载时会校验 Cookie/令牌并显示系统界面。
html
114: .catch(error => {
115: // 优化错误处理,只显示友好的错误信息
116: let errorMessage = '登录失败,请检查用户名和密码';
117:
118: // 尝试解析错误对象,获取更具体的错误信息
119: if (error.message && (error.message.includes('Incorrect username or password') || error.message.includes('用户名或密码错误'))) {
120: errorMessage = '用户名或密码错误';
121: } else if (error.message && error.message.includes('Could not validate credentials')) {
122: errorMessage = '无法验证凭证,请重新登录';
123: }
124:
125: loginError.textContent = errorMessage;
126: loginError.style.display = 'block';
127: });
▶ 第 114 行:.catch 捕获第 94 行 throw 的错误。
▶ 第 116 行:默认错误文案(不把后端原始错误直接显示给用户 ,避免泄露细节)。
▶ 第 119~123 行:根据后端错误关键词(英文,FastAPI 默认)匹配出中文友好提示。includes() = 字符串包含判断。
▶ 第 125~126 行:把错误文字写进红色提示框并显示。
html
128: });
129: });
▶ 第 128 行:addEventListener 回调结束;第 129 行:DOMContentLoaded 回调结束。
html
131: // 简化登录状态检查逻辑,避免因为/api/auth/users/me请求失败导致问题
132: function checkLoginStatus() {
133: const accessToken = localStorage.getItem('access_token');
134: if (accessToken) {
▶ 第 132~134 行:定义函数 checkLoginStatus();读取 localStorage 里的令牌,有令牌才继续(没有就什么都不做,正常显示登录页)。
html
135: // 验证令牌有效性后再跳转
136: // 避免无效令牌导致的页面循环刷新
137: fetch('/api/auth/validate-token', {
138: method: 'POST',
139: headers: {
140: 'Authorization': `Bearer ${accessToken}`,
141: 'Content-Type': 'application/json'
142: }
143: })
▶ 第 137~143 行:调用 /api/auth/validate-token 接口验证令牌是否还有效 。注意第 140 行:请求头 Authorization: Bearer <令牌>------这是本项目所有认证请求的统一凭证格式(后端 oauth2_scheme 读取它,见 02 篇)。
html
144: .then(response => {
145: if (response.ok) {
146: // 令牌有效,跳转到主页
147: window.location.href = '/';
148: } else {
149: // 令牌无效,留在登录页面
150: localStorage.removeItem('access_token');
151: localStorage.removeItem('token_type');
152: document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
153: document.cookie = 'token_type=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
154: }
155: })
156: .catch(error => {
157: // 验证请求失败,留在登录页面
158: console.error('验证令牌失败:', error);
159: });
160: }
161: }
162: </script>
163: </body>
164: </html>
▶ 第 147 行:令牌有效 → 直接跳转主页(用户已登录,不该看到登录页)。
▶ 第 148~154 行:令牌无效(过期/伪造)→ 清空所有存储 :localStorage.removeItem 删令牌;第 152~153 行用"把过期时间设为 1970 年"的技巧删除 Cookie(Cookie 没有 remove 方法,把过期时间设为过去就等于删除)。
▶ 第 156~159 行:网络错误时留在登录页,只打日志不打扰用户。
三、app/static/js/auth-utils.js 逐行讲解
这是全局认证工具库(33 行),被所有 JS 模块共用。三个函数管三件事:拼令牌、往 URL 塞令牌、带令牌发请求。
javascript
1: function getAuthToken() {
2: const accessToken = localStorage.getItem('access_token');
3: const tokenType = localStorage.getItem('token_type') || 'bearer';
4: return accessToken ? `${tokenType} ${accessToken}` : null;
5: }
▶ 第 2 行:从 localStorage 读令牌本体。
▶ 第 3 行:读令牌类型(登录时存的是 bearer),|| 'bearer' = 读不到就用默认值(防御写法)。
▶ 第 4 行:拼成 "bearer eyJhbGci..." 格式返回。${} 是模板字符串(把变量嵌进字符串)。没有令牌返回 null。
💡 注意:main.js 第 19~23 行也定义了一个同名 getAuthToken()。JS 里后加载的同名函数会覆盖前面的------两个文件都被 index.html 引入,后者生效。这是老式 JS 项目的常见"全局函数撞车",但两个实现一样,不影响。
javascript
7: function appendToken(url) {
8: const token = localStorage.getItem('access_token');
9: if (token) {
10: const separator = url.includes('?') ? '&' : '?';
11: return url + separator + 'token=' + encodeURIComponent(token);
12: }
13: return url;
14: }
▶ 第 8~11 行:把令牌以查询参数形式拼到 URL 上(如 /api/datasets/?token=xxx)。includes('?') 判断 URL 是否已有参数:有就用 & 连接,没有就用 ?。encodeURIComponent 把令牌里的特殊字符转义(令牌很长且含 . - _,必须转义才安全)。
💡 为什么有这种写法?------SSE 流式连接(EventSource)和 <img src> 标签无法自定义请求头,只能靠 URL 参数带令牌。后端的 get_current_user 专门支持了 query_token 参数(见 02 篇 deps.py)。
javascript
16: function authenticatedFetch(url, options = {}) {
17: const authToken = getAuthToken();
18: if (!authToken) {
19: window.location.href = '/login';
20: return Promise.reject(new Error('Not authenticated'));
21: }
22: options.headers = options.headers || {};
23: options.headers['Authorization'] = authToken;
24: return fetch(url, options).then(response => {
25: if (response.status === 401) {
26: localStorage.removeItem('access_token');
27: localStorage.removeItem('token_type');
28: window.location.href = '/login';
29: throw new Error('Authentication failed');
30: }
31: return response;
32: });
33: }
▶ 第 16 行:全项目最常用的请求函数 。options = {} = 参数带默认值,不传也不报错。
▶ 第 17~21 行:没有令牌 → 直接踢回登录页(window.location.href = '/login'),并返回一个"已拒绝的 Promise"(Promise.reject)------这样调用方 .then 不会执行,直接进 .catch。
▶ 第 22 行:如果调用方没传 headers,就初始化一个空对象(防止第 23 行对 undefined 赋值报错)。
▶ 第 23 行:把令牌自动塞进请求头 ------调用方不用管认证,这个函数全包了。
▶ 第 24 行:发出真正的请求。
▶ 第 25 行:response.status === 401 = 服务器说"令牌无效/过期"。
▶ 第 26~28 行:清掉本地存储的令牌并踢回登录页。
▶ 第 29 行:throw 错误,让调用方 .catch 也能感知。
▶ 第 31 行:其他情况原样返回 response,交给调用方继续处理。
四、main.js 中的认证函数(完整逐行见 03 篇)
main.js 是主页面(index.html)的脚本,其中与认证直接相关的函数在此讲解,其余留到 03 篇。
javascript
26: // 检查是否已登录
27: function isLoggedIn() {
28: const accessToken = localStorage.getItem('access_token');
29: return !!accessToken;
30: }
▶ 第 28~29 行:!! = 把值强转成布尔值。令牌存在 → true,不存在(null)→ false。页面加载时用它决定"进系统"还是"踢去登录页"。
javascript
32: // 退出登录
33: function logout() {
34: localStorage.removeItem('access_token');
35: localStorage.removeItem('token_type');
36: document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
37: document.cookie = 'token_type=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
38: window.location.href = '/login';
39: }
▶ 第 34~37 行:清空 localStorage 和 Cookie 里的令牌(与 login.html 第 150~153 行同理)。
▶ 第 38 行:跳回登录页。
💡 注意:logout() 只清本地 令牌,不通知服务器。服务器侧还有 /api/auth/logout 接口(把令牌 jti 记入"已吊销表"),本项目前端暂未调用它------这是设计上可改进的点(Token 其实还没失效,只是本地没了)。
javascript
42: // 获取当前用户信息
43: function getCurrentUser() {
44: authenticatedFetch(`${API_URL}/auth/users/me`)
45: .then(response => {
46: if (response.ok) {
47: return response.json();
48: }
49: throw new Error('获取用户信息失败');
50: })
51: .then(user => {
52: currentUser = user; // 保存当前用户信息
53: // 更新页面上的用户名显示
54: const usernameElement = document.getElementById('current-username');
55: if (usernameElement) {
56: usernameElement.textContent = user.username;
57: }
58: // 根据后端模型,role字段是字符串类型
59: let isAdmin = false;
60: if (user.role) {
61: isAdmin = user.role.toLowerCase() === 'admin';
62: }
63: // 如果是管理员,显示用户管理菜单项
64: if (isAdmin) {
65: document.querySelectorAll('.admin-only').forEach(el => {
66: el.style.display = '';
67: el.classList.remove('d-none');
68: });
69: }
70: })
71: .catch(error => {
72: console.error('获取用户信息失败:', error);
73: if (error.message === 'Not authenticated' || error.message.includes('401')) {
74: logout();
75: }
76: });
77: }
▶ 第 44 行:调 /api/auth/users/me 接口(后端返回当前用户信息,见 02 篇 auth.py)。
▶ 第 52 行:把用户信息存进全局变量 currentUser------后面 loadPage 判断"是不是管理员"全靠它。
▶ 第 54~57 行:把用户名写进侧边栏的 #current-username 元素(页面右上角显示"你好,admin")。
▶ 第 59~62 行:判断是否管理员。toLowerCase() = 转小写(后端 role 是 "admin" / "operator" 字符串,但保险起见统一小写比较)。
▶ 第 64~68 行:是管理员 → 把 HTML 里所有带 admin-only 类的菜单项显示出来(classList.remove('d-none') 去掉隐藏类)。普通用户这些菜单保持隐藏。
▶ 第 71~76 行:获取失败且错误是"未认证" → 直接登出(防死循环)。
javascript
303: // 带认证的fetch请求(main.js 版本)
304: function authenticatedFetch(url, options = {}) {
305: const headers = options.headers || {};
306: const authToken = getAuthToken();
307: if (authToken) {
308: headers['Authorization'] = authToken;
309: } else {
310: window.location.href = '/login';
311: return Promise.reject(new Error('Not authenticated'));
312: }
313: options.headers = headers;
314: return fetch(url, options).then(response => {
315: if (response.status === 401) {
316: logout();
317: throw new Error('Authentication failed');
318: }
319: return response;
320: });
321: }
▶ 与 auth-utils.js 的版本逻辑一致(拼令牌、无令牌踢登录、401 自动登出)。两个文件都定义了 authenticatedFetch,后者覆盖前者,实现相同。
javascript
473: document.addEventListener('DOMContentLoaded', function() {
474: loadDebugMode();
475: // 如果用户已登录,获取用户信息
476: if (isLoggedIn()) {
477: getCurrentUser();
478: // 绑定修改密码按钮事件
479: const changePasswordBtn = document.getElementById('change-password-btn');
480: if (changePasswordBtn) {
481: changePasswordBtn.addEventListener('click', showChangePasswordModal);
482: }
483: // 绑定退出登录按钮事件
484: const logoutBtn = document.getElementById('logout-btn');
485: if (logoutBtn) {
486: logoutBtn.addEventListener('click', logout);
487: }
488: } else {
489: // 用户未登录,重定向到登录页面
490: window.location.href = '/login';
491: }
▶ 第 476 行:页面加载时判断登录状态。
▶ 第 477 行:已登录 → 拉取用户信息(填充侧边栏用户名、显示管理员菜单)。
▶ 第 479~482 行:给"修改密码"按钮绑定点击事件。
▶ 第 484~487 行:给"退出登录"按钮绑定点击事件。
▶ 第 488~491 行:未登录 → 整页跳转到登录页。这是主页面第一道前端防线(第二道防线在后端,见 02 篇 deps.py)。
五、验证任务(动手做)
- 看登录请求 :登录页 F12 → Network → 勾选 "Preserve log" → 点登录 → 找到
/api/auth/token请求- 看 Request Headers 里的
Content-Type - 看 Request Payload 里用户名密码的编码格式
- 看 Response 里的 JSON(令牌、must_change_password)
- 看 Request Headers 里的
- 看令牌存储 :F12 → Application → Local Storage → 看
access_token - 验证 401 自动登出 :在 Application 里手动把
access_token改成abc,刷新主页 ------ 会看到前端先尝试请求、后端返回 401、前端清空并跳回登录页 - 改需求实验 :把 login.html 第 31 行的
value="admin"删掉,刷新登录页------默认值没了。这行是开发便利,生产环境应该删除。
六、本篇小结
| 知识点 | 一句话总结 |
|---|---|
| 表单提交 | e.preventDefault() 阻止刷新,改用 fetch 异步提交 |
| 登录凭证 | 服务器发令牌,前端存 localStorage + Cookie 两份 |
| 请求凭证 | 每个请求头带 Authorization: Bearer <令牌> |
| 401 处理 | 自动清令牌踢回登录页 |
| 管理员菜单 | 前端用 currentUser.role === 'admin' 控制显示(⚠️ 这层只是"藏",真正拦截在 02 篇后端 deps.py) |