diff --git a/.trellis/spec/frontend/type-safety.md b/.trellis/spec/frontend/type-safety.md index 9c8e545..501e7e6 100644 --- a/.trellis/spec/frontend/type-safety.md +++ b/.trellis/spec/frontend/type-safety.md @@ -33,7 +33,12 @@ rendering. ### 2. Signatures - Login form: `POST /login`. -- Request fields: `username`, `password`, and optional `redirect`. +- Request fields consumed by `LoginServlet`: `username`, `password`, and + optional `redirect`. +- Presentation-only login controls may submit auxiliary fields such as + `loginRole` and `rememberUsername`; these must not participate in + authentication or authorization unless the Servlet/service contract is + deliberately changed. - Login JSP request attributes: `errorMessage`, `username`, and `redirect`. - Dashboard/role JSP session attributes: `authenticatedUser`, `userRole`, and `userPermissions`. @@ -47,6 +52,12 @@ rendering. attribute or session attribute. - `redirect` must be a same-application path beginning with one `/`; invalid values are ignored. +- `loginRole` is only a login-intent hint in the JSP. The authenticated role is + determined by the `users.role_code` row returned through `AuthService`, not by + a client-side radio selection. +- Remember-me behavior may persist only the username in browser storage. It must + never persist passwords, password hashes, redirects, permission state, or + extend the server session. - JSPs render data with JSP EL/JSTL, not scriptlet Java. - JSPs may read safe session snapshots, but they must not call DAOs or inspect password hashes. @@ -67,10 +78,12 @@ rendering. - Good: failed login keeps the escaped username and never redisplays the password. +- Good: selecting a role radio option or checking remember-me does not change + the server-side authentication decision. - Base: dashboard reads `sessionScope.authenticatedUser.displayName` and `sessionScope.userRole` only for display/navigation. -- Bad: JSP uses scriptlets, JDBC, or raw request parameters to decide - authentication. +- Bad: JSP, JavaScript, or Servlet code trusts `loginRole` to grant a role or + stores the password in browser storage. ### 6. Tests Required @@ -79,6 +92,8 @@ rendering. files. - Run service-level auth checks for required fields, invalid credentials, success, DAO fallback, and permission checks. +- When login page scripts change, scan them to confirm only usernames can be + stored client-side and `password` is never persisted. - When Maven/Tomcat is available, run a Servlet/JSP compile or package check. ### 7. Wrong vs Correct @@ -87,6 +102,7 @@ rendering. ```jsp <%-- JSP checks request.getParameter("password") or runs SQL directly. --%> +<%-- JavaScript stores the password or LoginServlet trusts loginRole. --%> ``` #### Correct diff --git a/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/check.jsonl b/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/check.jsonl index dc5c124..1f2fc1a 100644 --- a/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/check.jsonl +++ b/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/check.jsonl @@ -4,3 +4,4 @@ {"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Run UI-oriented quality review for removed redundant actions."} {"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Review Chinese demo data against schema and seed-data conventions."} {"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify backend layer boundaries and checks for schema-only data changes."} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify the login JSP keeps the POST /login contract, request fields, and safe rendering behavior."} diff --git a/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/prd.md b/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/prd.md index 62ad4a4..77f12d3 100644 --- a/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/prd.md +++ b/.trellis/tasks/04-28-remove-redundant-actions-add-cn-data/prd.md @@ -2,7 +2,7 @@ ## Goal -精简已登录页面中与侧边栏重复的右侧跨模块跳转按钮,并补充更贴近中文图书馆场景的演示图书与读者数据。 +精简已登录页面中与侧边栏重复的右侧跨模块跳转按钮,补充更贴近中文图书馆场景的演示图书与读者数据,并按参考截图重构真实可用的登录界面。 ## What I already know @@ -11,6 +11,8 @@ * “新增图书”“新增分类”“新增读者档案”“新增账户”等当前页面内的主要操作仍应保留。 * 演示数据位于 `src/main/resources/db/schema.sql`,当前包含英文读者名、英文分类和英文图书。 * 项目是 JSP + Servlet + MySQL 架构,前端页面在 `src/main/webapp/WEB-INF/jsp/`,数据库初始化脚本使用 `utf8mb4`。 +* 用户补充要求:仿照参考截图重构登录界面,必须是真实可用的登录表单,而不是静态展示页。 +* 参考截图特征:浅色模糊图书馆背景、居中的白色登录卡片、蓝色书本图标与“图书管理系统”标题、用户名/密码输入框图标、密码显隐按钮、身份单选项、记住我和忘记密码入口、蓝色主登录按钮。 ## Assumptions @@ -28,6 +30,10 @@ * 用户账户与角色页面不再显示跳转到读者档案的右侧按钮;保留新增账户入口。 * 数据库初始化脚本加入中文图书分类、中文书名、中文作者和中文读者姓名。 * 本地演示账号仍能用于登录验证。 +* 登录页按参考截图重构视觉,但保留现有 `POST /login`、`username`、`password`、`redirect`、错误提示和回填用户名等真实登录能力。 +* 登录页新增或保留真实可交互控件:密码显隐切换、登录身份单选项、记住我选项和忘记密码入口。 +* 登录身份选择不应破坏现有服务端认证;当前后端仍以账号密码和账号角色为准,前端角色选项仅作为登录意图提示或表单辅助字段。 +* 登录页需要在桌面和移动端保持可用,输入框、按钮和错误提示不能溢出或遮挡。 ## Acceptance Criteria @@ -36,6 +42,10 @@ * [x] `schema.sql` 包含多条中文图书数据和多条中文读者数据。 * [x] 中文演示数据使用 `utf8mb4` 兼容的文本,不引入新表或迁移机制。 * [x] 相关检查或可用的构建验证通过;若环境缺少 Maven,记录 fallback 验证。 +* [x] 登录页视觉接近参考截图,并使用真实表单提交到现有 `/login`。 +* [x] 密码显隐、记住我、身份单选项在浏览器中可交互且不破坏登录流程。 +* [x] 登录失败时继续显示服务端错误提示并保留用户名/redirect。 +* [x] 登录页在移动端和桌面端布局稳定,无文字或控件重叠。 ## Definition of Done @@ -48,11 +58,13 @@ * 不重设计侧边栏或整体视觉风格。 * 不新增页面、权限、路由或服务层能力。 * 不改变借阅记录、报表、用户账户或读者档案的业务逻辑。 +* 不实现真实找回密码流程;忘记密码入口可展示当前系统暂未开放或指向安全的占位交互。 ## Technical Notes * Likely JSP files: `src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp`, `src/main/webapp/WEB-INF/jsp/books/catalog.jsp`, `src/main/webapp/WEB-INF/jsp/books/manage.jsp`, `src/main/webapp/WEB-INF/jsp/books/categories.jsp`, `src/main/webapp/WEB-INF/jsp/readers/manage.jsp`, `src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp`. +* Login files: `src/main/webapp/WEB-INF/jsp/auth/login.jsp`, `src/main/webapp/static/css/app.css`, and possibly small inline or static JavaScript for password visibility/remember-me interactions. * Data file: `src/main/resources/db/schema.sql`. * Relevant specs: frontend JSP/component/state/quality guidelines and backend database/quality guidelines. -* Final verification: `git diff --check`, JSP scriptlet/SQL/JDBC scan, removed-link scan, and `/home/sjy/.sdkman/candidates/maven/current/bin/mvn clean package` passed. -* Spec update decision: no `.trellis/spec/` update needed because this task did not introduce new routes, APIs, tables, cross-layer contracts, or reusable implementation conventions. +* Final verification: `git diff --check`, `node --check src/main/webapp/static/js/login.js`, JSP scriptlet/SQL/JDBC scans, removed-link scan, password persistence scan, and `/home/sjy/.sdkman/candidates/maven/current/bin/mvn clean package` passed. +* Spec update decision: `.trellis/spec/frontend/type-safety.md` documents the new presentation-only login controls (`loginRole`, `rememberUsername`) and the username-only remember-me constraint. diff --git a/src/main/webapp/WEB-INF/jsp/auth/login.jsp b/src/main/webapp/WEB-INF/jsp/auth/login.jsp index b0ccb4e..51fc581 100644 --- a/src/main/webapp/WEB-INF/jsp/auth/login.jsp +++ b/src/main/webapp/WEB-INF/jsp/auth/login.jsp @@ -6,44 +6,119 @@ - 登录 - MZH 图书馆 - + 登录 - 图书管理系统 + -<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
-
-

图书馆管理

-

登录

+ -
+ diff --git a/src/main/webapp/static/css/app.css b/src/main/webapp/static/css/app.css index f6990ee..1fe6429 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -302,16 +302,40 @@ textarea { } .auth-page { + position: relative; + min-height: 100vh; + overflow-x: hidden; + isolation: isolate; + background: #edf4ff; +} + +.auth-page::before { + content: ""; + position: fixed; + inset: -22px; + z-index: -2; background: - linear-gradient(rgba(245, 247, 251, 0.86), rgba(245, 247, 251, 0.94)), + linear-gradient(90deg, rgba(241, 246, 255, 0.76), rgba(249, 252, 255, 0.64)), url("../images/library-login.svg") center / cover no-repeat; + filter: blur(10px) saturate(0.78); + transform: scale(1.04); +} + +.auth-page::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background: + radial-gradient(circle at 50% 42%, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18) 34%, rgba(227, 237, 255, 0.5) 100%), + linear-gradient(180deg, rgba(247, 250, 255, 0.68), rgba(231, 239, 255, 0.78)); } .auth-shell { - width: min(1120px, calc(100% - 32px)); - min-height: calc(100vh - 64px); + width: min(960px, calc(100% - 32px)); + min-height: 100vh; display: grid; - align-items: center; + place-items: center; margin: 0 auto; padding: 48px 0; } @@ -357,8 +381,64 @@ body:not(.auth-page) .dashboard-shell { } .login-panel { - width: min(420px, 100%); - padding: 32px; + width: min(540px, 100%); + padding: 44px 64px 56px; +} + +.auth-page .login-panel { + border-color: rgba(219, 229, 244, 0.78); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 18px 42px rgba(51, 65, 85, 0.16); +} + +.login-card-head { + display: grid; + gap: 12px; + justify-items: center; + margin-bottom: 34px; + text-align: center; +} + +.login-brand-row { + display: flex; + align-items: center; + justify-content: center; + gap: 22px; +} + +.login-brand-mark { + width: 58px; + height: 58px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: #1372e8; +} + +.login-brand-mark svg { + width: 100%; + height: 100%; +} + +.login-card-head h1 { + margin: 0; + color: #0f2546; + font-size: 34px; + font-weight: 900; + line-height: 1.14; +} + +.login-subtitle { + margin: 0; + color: #6f7b8a; + font-size: 18px; + line-height: 1.4; +} + +.login-error { + margin: 0 0 20px; } .eyebrow { @@ -389,7 +469,7 @@ h2 { .login-form { display: grid; - gap: 10px; + gap: 20px; } .login-form label, @@ -401,7 +481,7 @@ h2 { font-weight: 800; } -.login-form input, +.login-form .login-control, .search-form input, .search-form select, .dashboard-search-form input, @@ -425,7 +505,7 @@ h2 { outline: 0; } -.login-form input:focus, +.login-form .login-control:focus, .search-form input:focus, .search-form select:focus, .dashboard-search-form input:focus, @@ -444,6 +524,169 @@ h2 { box-shadow: 0 0 0 3px rgba(40, 105, 232, 0.14); } +.login-field { + display: grid; + gap: 6px; +} + +.login-input-shell { + min-height: 58px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 0 18px; + border: 1px solid rgba(203, 213, 225, 0.9); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.login-password-shell { + grid-template-columns: 34px minmax(0, 1fr) 42px; +} + +.login-input-shell:focus-within { + border-color: #2d7df0; + box-shadow: 0 0 0 3px rgba(45, 125, 240, 0.13); +} + +.login-input-icon { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #6b7280; +} + +.login-input-icon svg, +.password-toggle svg { + width: 24px; + height: 24px; +} + +.login-input-shell .login-control { + width: 100%; + min-width: 0; + min-height: 56px; + padding: 0; + border: 0; + color: #111827; + background: transparent; + box-shadow: none; +} + +.login-input-shell .login-control:focus { + box-shadow: none; +} + +.login-input-shell .login-control::placeholder { + color: #7b8494; +} + +.password-toggle { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 8px; + color: #747b89; + background: transparent; + cursor: pointer; +} + +.password-toggle:hover, +.password-toggle:focus-visible { + color: #1d4fd7; + background: #eef5ff; + outline: 0; +} + +.login-role-group { + min-width: 0; + display: flex; + align-items: center; + gap: 26px; + margin: 4px 0 0; + padding: 0; + border: 0; +} + +.login-role-title { + flex: 0 0 auto; + padding: 0; + color: #1f2937; + font-size: 16px; + font-weight: 800; +} + +.login-role-options { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 26px; + flex: 1 1 auto; +} + +.login-role-options label, +.login-check { + display: inline-flex; + align-items: center; + gap: 8px; + color: #4b5563; + font-size: 16px; + line-height: 1.2; + white-space: nowrap; + cursor: pointer; +} + +.login-role-options input, +.login-check input { + width: 18px; + height: 18px; + flex: 0 0 auto; + margin: 0; + accent-color: #1478ef; +} + +.login-options-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 2px; +} + +.forgot-password-link { + border: 0; + padding: 0; + color: #1478c8; + background: transparent; + font-size: 16px; + cursor: pointer; +} + +.forgot-password-link:hover, +.forgot-password-link:focus-visible { + color: #0f5da8; + text-decoration: underline; + outline: 0; +} + +.login-help-message { + margin: -10px 0 0; + padding: 9px 12px; + border: 1px solid rgba(20, 120, 200, 0.16); + border-radius: 8px; + color: #31536f; + background: #f3f8ff; + font-size: 13px; +} + .button { min-height: 36px; display: inline-flex; @@ -498,6 +741,15 @@ h2 { margin-top: 12px; } +.login-form .login-submit { + width: 100%; + min-height: 58px; + margin-top: 4px; + border-radius: 8px; + font-size: 21px; + box-shadow: 0 10px 22px rgba(20, 104, 234, 0.26); +} + .message { margin-bottom: 16px; padding: 10px 12px; @@ -1118,6 +1370,76 @@ h2 { padding: 0 16px; } + .auth-shell { + width: min(100%, calc(100% - 24px)); + padding: 24px 0; + } + + .auth-page .login-panel { + padding: 30px 22px 34px; + } + + .login-card-head { + gap: 8px; + margin-bottom: 26px; + } + + .login-brand-row { + gap: 12px; + } + + .login-brand-mark { + width: 42px; + height: 42px; + } + + .login-card-head h1 { + font-size: 26px; + } + + .login-subtitle { + font-size: 15px; + } + + .login-input-shell { + min-height: 52px; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + padding: 0 14px; + } + + .login-password-shell { + grid-template-columns: 28px minmax(0, 1fr) 38px; + } + + .login-input-shell .login-control { + min-height: 50px; + } + + .login-role-group { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .login-role-options { + width: 100%; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + } + + .login-role-options label, + .login-check, + .forgot-password-link { + font-size: 14px; + } + + .login-form .login-submit { + min-height: 52px; + font-size: 18px; + } + h1 { font-size: 24px; } diff --git a/src/main/webapp/static/js/login.js b/src/main/webapp/static/js/login.js new file mode 100644 index 0000000..59f0435 --- /dev/null +++ b/src/main/webapp/static/js/login.js @@ -0,0 +1,65 @@ +(function () { + var form = document.querySelector("[data-login-form]"); + var username = document.getElementById("username"); + var password = document.getElementById("password"); + var remember = document.querySelector("[data-remember-username]"); + var toggle = document.querySelector("[data-password-toggle]"); + var forgot = document.querySelector("[data-forgot-password]"); + var passwordHelp = document.getElementById("password-help"); + var storageKey = "mzh.library.login.username"; + + function readStoredUsername() { + try { + return window.localStorage.getItem(storageKey) || ""; + } catch (ex) { + return ""; + } + } + + function writeStoredUsername(value) { + try { + if (value) { + window.localStorage.setItem(storageKey, value); + } else { + window.localStorage.removeItem(storageKey); + } + } catch (ex) { + // Storage may be disabled; login should still submit normally. + } + } + + if (username && remember) { + var storedUsername = readStoredUsername(); + if (storedUsername) { + remember.checked = true; + if (!username.value) { + username.value = storedUsername; + } + } + } + + if (form && username && remember) { + form.addEventListener("submit", function () { + writeStoredUsername(remember.checked ? username.value.trim() : ""); + }); + } + + if (toggle && password) { + toggle.addEventListener("click", function () { + var nextVisible = password.type !== "text"; + password.type = nextVisible ? "text" : "password"; + toggle.setAttribute("aria-pressed", String(nextVisible)); + toggle.setAttribute("aria-label", nextVisible ? "隐藏密码" : "显示密码"); + password.focus(); + }); + } + + if (forgot && passwordHelp) { + forgot.addEventListener("click", function () { + passwordHelp.hidden = !passwordHelp.hidden; + if (!passwordHelp.hidden) { + passwordHelp.focus(); + } + }); + } +}());