TrainEye源码解读-01. 登录认证 · 前端篇

覆盖文件: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)。


五、验证任务(动手做)

  1. 看登录请求 :登录页 F12 → Network → 勾选 "Preserve log" → 点登录 → 找到 /api/auth/token 请求
    • 看 Request Headers 里的 Content-Type
    • 看 Request Payload 里用户名密码的编码格式
    • 看 Response 里的 JSON(令牌、must_change_password)
  2. 看令牌存储 :F12 → Application → Local Storage → 看 access_token
  3. 验证 401 自动登出 :在 Application 里手动把 access_token 改成 abc,刷新主页 ------ 会看到前端先尝试请求、后端返回 401、前端清空并跳回登录页
  4. 改需求实验 :把 login.html 第 31 行的 value="admin" 删掉,刷新登录页------默认值没了。这行是开发便利,生产环境应该删除。

六、本篇小结

知识点 一句话总结
表单提交 e.preventDefault() 阻止刷新,改用 fetch 异步提交
登录凭证 服务器发令牌,前端存 localStorage + Cookie 两份
请求凭证 每个请求头带 Authorization: Bearer <令牌>
401 处理 自动清令牌踢回登录页
管理员菜单 前端用 currentUser.role === 'admin' 控制显示(⚠️ 这层只是"藏",真正拦截在 02 篇后端 deps.py
相关推荐
不是光头 强1 小时前
Java 后端 AI 技术选型与学习路线
java·人工智能·学习
船厂电气自动化ai大模型1 小时前
AI大模型与数学第42课:泰勒级数完整展开(神经网络近似核心)
人工智能·python·深度学习·算法·机器学习
老郑聊AI业财智造1 小时前
Qwen技术架构与源码深度剖析
人工智能·语言模型·架构·系统架构·软件工程
甲维斯1 小时前
Opus5自主解决Qwen3.8 27B本地接入Claude Code的BUG!
人工智能
晴天161 小时前
智能体 Harness 工程指南-Day24
人工智能
格林威1 小时前
C#图像像素放大:邻域平均、双线性插值实现像素放大的C#实现代码
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
阿里云大数据AI技术1 小时前
一句话即可用好 MaxCompute:AI 全能搭子 MaxAgent 来了——会运维、能分析
人工智能·agent
野小生1 小时前
从零搭建越用越聪明的 AI 第二大脑:Claude Code + Obsidian 9 步实战
人工智能
两万五千个小时2 小时前
DeepSeek Harness 从 0 开始:09 System Prompt 模块(提示词组装)
人工智能·程序员·架构