diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md index 365c238..9a1670f 100644 --- a/.trellis/spec/backend/logging-guidelines.md +++ b/.trellis/spec/backend/logging-guidelines.md @@ -144,3 +144,74 @@ the server-side exception. ``` + +## Scenario: Login Diagnostic Logging + +### 1. Scope / Trigger + +- Trigger: Windows deployment login failures need server-side diagnostics across + `LoginServlet -> AuthServiceImpl -> JdbcUserDao -> JdbcUtil` without changing + the generic user-facing login messages. + +### 2. Signatures + +- Servlet route: `POST /login` with `username`, `password`, and optional + same-application `redirect`. +- Service signature: `AuthService.authenticate(String username, String password)`. +- DAO signature: `UserDao.findActiveByUsername(String username)`. +- DB config keys: `db.driver`, `db.url`, `db.username`, and `db.password`. + +### 3. Contracts + +- Login request logs may include remote address, context path, redirect presence, + username presence/length, sanitized username, and whether normalization changed + the username. +- Authentication logs must distinguish missing required fields, active user not + found, password mismatch, service error, and success. +- JDBC logs must confirm `db.properties` loading, required key resolution, + connection attempts, successful connections, and driver/connection failures. +- Logs must never include raw passwords, password hashes, salts, database + passwords, or unredacted password-like JDBC URL parameters. + +### 4. Validation & Error Matrix + +- Missing username or password -> log missing-field category and return the + existing required-field message. +- Unknown or inactive username -> log `active-user-not-found` and return the + existing invalid-credentials message. +- Existing user with bad password -> log `password-mismatch` and return the + existing invalid-credentials message. +- Missing DB config or JDBC failure -> log server-side details with credentials + redacted and return the existing service-unavailable message. + +### 5. Good/Base/Bad Cases + +- Good: a failed login shows whether the request reached the servlet, whether + the username was normalized, whether the active user row was found, and + whether password verification failed. +- Base: successful login keeps logging user ID and role only. +- Bad: a diagnostic log writes `password`, `password_hash`, salt, or a JDBC URL + containing `password=secret`. + +### 6. Tests Required + +- Run `mvn test` or the documented Maven path to compile Servlet, service, DAO, + and utility code. +- Scan changed logs for password/hash/salt/database-password output before + finishing. +- Keep `AuthServiceCheck` behavior expectations unchanged for required fields, + invalid credentials, success, permission checks, and DAO failure fallback. + +### 7. Wrong vs Correct + +#### Wrong + +```java +LOGGER.info("Login failed password=" + password + " hash=" + user.getPasswordHash()); +``` + +#### Correct + +```java +LOGGER.info("Login failed reason=password-mismatch userId=" + user.getId()); +``` diff --git a/.trellis/spec/frontend/component-guidelines.md b/.trellis/spec/frontend/component-guidelines.md index 77b5caf..1f8afbe 100644 --- a/.trellis/spec/frontend/component-guidelines.md +++ b/.trellis/spec/frontend/component-guidelines.md @@ -20,6 +20,13 @@ the reusable UI units. application frame lives in `src/main/webapp/WEB-INF/jsp/common/header.jspf` and owns the dark sidebar, top utility bar, role workbench links, module navigation, global search, user display, and logout link. +- Any `.jspf` fragment that contains user-visible Simplified Chinese text must + declare `<%@ page pageEncoding="UTF-8" %>` at the top. Do not rely only on the + including JSP page or response `Content-Type`; Tomcat/Jasper can otherwise + compile the fragment with a non-UTF-8 default and render mojibake. +- JSP-rendered HTML responses must be served as `text/html;charset=UTF-8` by + the encoding filter or the JSP page directive. Request/response character + encoding alone is not enough for browsers to decode Simplified Chinese safely. - Preserve role-conditioned navigation in that shared frame: administrator-only links stay inside `sessionScope.userRole == 'administrator'`; staff links stay inside `administrator or librarian`; reader-only links stay inside diff --git a/.trellis/tasks/04-28-windows-login-diagnostic-logs/check.jsonl b/.trellis/tasks/04-28-windows-login-diagnostic-logs/check.jsonl new file mode 100644 index 0000000..62bc5f5 --- /dev/null +++ b/.trellis/tasks/04-28-windows-login-diagnostic-logs/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Check backend architecture boundaries for login diagnostics."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify logs avoid passwords, hashes, salts, and credentials while remaining useful."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify login/authentication and database config contracts remain intact."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify Maven checks and backend quality expectations."} diff --git a/.trellis/tasks/04-28-windows-login-diagnostic-logs/implement.jsonl b/.trellis/tasks/04-28-windows-login-diagnostic-logs/implement.jsonl new file mode 100644 index 0000000..ecada99 --- /dev/null +++ b/.trellis/tasks/04-28-windows-login-diagnostic-logs/implement.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend Servlet/JSP/JDBC architecture context for login diagnostics."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Logging safety rules, sensitive-data redaction, and diagnostic expectations."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Login/authentication and database configuration contracts."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend quality and Maven verification requirements."} diff --git a/.trellis/tasks/04-28-windows-login-diagnostic-logs/prd.md b/.trellis/tasks/04-28-windows-login-diagnostic-logs/prd.md new file mode 100644 index 0000000..9cb6f86 --- /dev/null +++ b/.trellis/tasks/04-28-windows-login-diagnostic-logs/prd.md @@ -0,0 +1,70 @@ +# Add Windows Login Diagnostic Logs + +## Goal + +Add safe server-side diagnostic logs to the login/authentication path so a Windows-built deployment that returns `用户名或密码不正确。` can be diagnosed without exposing passwords, password hashes, or database credentials. + +## What I Already Know + +* The previous frontend rebuild task is solved and has been archived. +* On the Windows system build, login now reaches the invalid-credentials path: `用户名或密码不正确。` +* The user believes the database connection is probably already working. +* Existing login flow is `LoginServlet` -> `AuthServiceImpl` -> `JdbcUserDao.findActiveByUsername` -> `JdbcUtil`. +* `AuthServiceImpl` currently logs only generic login failure/success/service-error messages. +* Existing backend specs require login failures to keep the same generic user-facing message and to log server-side details for unavailable services. + +## Requirements + +* Add diagnostic logging around login POST handling, authentication lookup, password verification outcome, and database configuration/connection attempts. +* Logs must help distinguish: + * request reached `LoginServlet`; + * username normalization changed the submitted username; + * active user row was not found; + * user row was found but password verification failed; + * database configuration was loaded and which JDBC URL/user key were used, with secrets redacted; + * JDBC driver/connection failures if they happen. +* Do not log raw passwords, password hashes, salts, database passwords, or full sensitive config values. +* Preserve the current user-facing Chinese error message and login behavior. +* Keep the implementation in the existing Servlet + service + DAO + JDBC stack. +* Prefer `java.util.logging` patterns already used in the project. + +## Acceptance Criteria + +* [x] Login failure logs identify whether the username was absent, not found, or found with password mismatch. +* [x] Login request logs include safe request diagnostics such as remote address, context path, redirect presence, and submitted username length or sanitized username. +* [x] Database logs confirm `db.properties` loading and JDBC connection attempts with password redacted. +* [x] No log statement outputs a raw password, password hash, salt, or database password. +* [x] Existing login success/failure behavior remains unchanged for users. +* [x] `mvn test` or the closest available Maven verification command succeeds. + +## Definition Of Done + +* Diagnostic logging implemented in source. +* Maven verification run and results reported. +* No database schema changes. +* No unrelated frontend/layout changes. + +## Out Of Scope + +* Changing password hashing rules or seed user credentials. +* Adding a new logging framework. +* Changing database schema or production credentials. +* Reworking the login UI. +* Committing generated build artifacts. + +## Technical Notes + +* Likely impacted files: + * `src/main/java/com/mzh/library/controller/LoginServlet.java` + * `src/main/java/com/mzh/library/service/impl/AuthServiceImpl.java` + * `src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java` + * `src/main/java/com/mzh/library/util/JdbcUtil.java` +* Relevant specs: + * `.trellis/spec/backend/logging-guidelines.md` + * `.trellis/spec/backend/database-guidelines.md` + * `.trellis/spec/backend/quality-guidelines.md` +* Verification completed at 2026-04-28 18:22 +0800: + * `/home/sjy/.sdkman/candidates/maven/current/bin/mvn test` passed with `BUILD SUCCESS`. + * `/home/sjy/.sdkman/candidates/maven/current/bin/mvn package` passed with `BUILD SUCCESS` and produced `target/library-management.war`. + * `git diff --check` passed. + * Sensitive logger scan only found boolean password state fields, `password=`, and `password-mismatch` category labels. diff --git a/.trellis/tasks/04-28-windows-login-diagnostic-logs/task.json b/.trellis/tasks/04-28-windows-login-diagnostic-logs/task.json new file mode 100644 index 0000000..86b1dfc --- /dev/null +++ b/.trellis/tasks/04-28-windows-login-diagnostic-logs/task.json @@ -0,0 +1,26 @@ +{ + "id": "windows-login-diagnostic-logs", + "name": "windows-login-diagnostic-logs", + "title": "Add Windows login diagnostic logs", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "Zzzz", + "assignee": "Zzzz", + "createdAt": "2026-04-28", + "completedAt": null, + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/check.jsonl b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/check.jsonl new file mode 100644 index 0000000..1a7a7a9 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/check.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend checklist for reviewing JSP/CSS presentation changes."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify shared frame role navigation and Simplified Chinese copy remain correct."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify layout, accessibility basics, and no obvious overlap/clipping."} +{"file": ".trellis/spec/backend/index.md", "reason": "Verify encoding changes remain within Servlet/JSP architecture."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify layer boundaries and Maven build/test expectations."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Confirm the change preserves the agreed JSP + Servlet + Tomcat stack."} diff --git a/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/implement.jsonl b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/implement.jsonl new file mode 100644 index 0000000..4ea3144 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/implement.jsonl @@ -0,0 +1,7 @@ +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS presentation conventions and checklist for authenticated UI work."} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "JSP fragment and static asset placement constraints."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Shared header/sidebar fragment rules, role-conditioned navigation, and Simplified Chinese copy requirements."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "UI quality checks for JSP/CSS layout, accessibility basics, and visual consistency."} +{"file": ".trellis/spec/backend/index.md", "reason": "Servlet/JSP/Tomcat architecture context for the encoding filter change."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Layer-boundary and Maven verification requirements for backend-adjacent changes."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project stack and presentation-layer requirements."} diff --git a/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/prd.md b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/prd.md new file mode 100644 index 0000000..8891a3c --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/prd.md @@ -0,0 +1,83 @@ +# Fix Frontend Encoding And Layout + +## Goal + +Fix the authenticated JSP frontend so Simplified Chinese text renders correctly in the browser, then refine the shared application frame and dashboard layout so navigation, role workbench links, search, user identity, and page content look coordinated at common desktop and mobile widths. + +## What I Already Know + +* The user wants to view the actual frontend UI after the frontend refactor. +* The running Tomcat application appears to show the pre-refactor UI. +* The project is a Java 11 Maven WAR application. +* Maven produces `target/library-management.war`. +* Frontend assets and JSPs live under `src/main/webapp`. +* Local Tomcat path is `/home/sjy/apps/tomcat/apache-tomcat-9.0.117/apache-tomcat-9.0.117`. +* Local MySQL is running at `127.0.0.1:3306`. +* The user now sees severe mojibake such as `书 图书管理系统`, which is UTF-8 Chinese content being decoded as a non-UTF-8 encoding. +* The visible broken area is the authenticated shell: sidebar brand, role workbench, module navigation, topbar search, notification, user pill, and role label. +* JSP pages already declare `contentType="text/html;charset=UTF-8"` and ``; `CharacterEncodingFilter` currently sets request/response character encoding but does not force an HTML content type. +* Follow-up user screenshot shows the authenticated shell still renders as ordinary document-flow text: the dark fixed sidebar is missing, the topbar is loose, and the dashboard metric cards collapse into a vertical text column. HTML and CSS endpoint checks alone are not sufficient. + +## Requirements + +* Ensure every JSP-rendered HTML response is explicitly served as UTF-8 so Chinese labels, placeholders, headings, and role names do not render as mojibake. +* Preserve the JSP + Servlet + CSS stack; do not introduce a frontend framework. +* Keep all user-facing JSP copy in Simplified Chinese. +* Refine the shared authenticated frame in `header.jspf`/CSS: + * Sidebar brand and role workbench should be readable and not visually crowded. + * Navigation links should align consistently and avoid repeated glyph/text collisions. + * Topbar search, notification, user display, and role label should fit without overlap. +* Refine dashboard layout in CSS so metric cards, search/ranking panels, tables, and shortcut cards have balanced spacing and degrade cleanly on narrower viewports. +* Rebuild and redeploy the WAR to the local Tomcat instance after source changes. +* Verify `/library-management/login` is reachable and a known login reaches `/library-management/dashboard`. +* Verify the dashboard HTML/headers indicate UTF-8 and the rendered shell text is readable Chinese. +* Verify the rendered page visually in a browser at desktop width: + * A dark fixed left sidebar must be visible. + * The topbar must start to the right of the sidebar and align search/user controls. + * Dashboard content must start below the topbar and to the right of the sidebar. + * Metrics must render as cards in a grid on desktop, not as plain vertical text. + * No overlapping, no unstyled header text, and no horizontal crowding at 1920px-wide desktop. + +## Acceptance Criteria + +* [x] `mvn clean package` succeeds after the frontend/encoding changes. +* [x] Tomcat `webapps/library-management.war` is refreshed from `target/library-management.war`. +* [x] Old expanded deployment directory is removed before restart. +* [x] Tomcat listens on port `8080`. +* [x] `/library-management/login` returns HTTP 200. +* [x] `admin/admin123` login redirects to `/library-management/dashboard`. +* [x] Authenticated dashboard response uses UTF-8 and no longer displays mojibake for Chinese UI text. +* [ ] Sidebar, role chips, topbar search/actions, dashboard panels, and responsive layout avoid obvious overlap, clipping, duplicated visual noise, and unstyled document-flow rendering in an actual browser screenshot. + +## Definition Of Done + +* Encoding fix implemented in source. +* Layout refinement implemented in JSP/CSS source. +* Rebuild and deploy completed. +* Verification results reported to the user. +* No database schema changes or unrelated backend behavior changes. + +## Out Of Scope + +* Replacing the JSP/CSS frontend with React, Vue, or another SPA framework. +* Changing database schema or seed data. +* Committing build artifacts. +* Reworking business workflows beyond what is needed to render the current pages correctly. + +## Technical Notes + +* Build command from README: `/home/sjy/.sdkman/candidates/maven/current/bin/mvn clean package` if `mvn` is unavailable. +* Deployment target: `/home/sjy/apps/tomcat/apache-tomcat-9.0.117/apache-tomcat-9.0.117/webapps/library-management.war`. +* Rebuild completed at 2026-04-28 16:55 +0800; deployed WAR size is 4,489,937 bytes. +* Previous deployment was moved to `/home/sjy/apps/tomcat/apache-tomcat-9.0.117/apache-tomcat-9.0.117/deploy-backups/_pre-rebuild-20260428-1656/`. +* Deployed `static/css/app.css` is byte-for-byte identical to `src/main/webapp/static/css/app.css`. +* Likely impacted files from inspection: + * `src/main/java/com/mzh/library/filter/CharacterEncodingFilter.java` + * `src/main/webapp/WEB-INF/jsp/common/header.jspf` + * `src/main/webapp/WEB-INF/jsp/dashboard.jsp` + * `src/main/webapp/static/css/app.css` +* Final verification completed at 2026-04-28 17:34 +0800: + * `/library-management/login` returns `200` with `Content-Type: text/html;charset=UTF-8`. + * `admin/admin123` login reaches `/library-management/dashboard` with UTF-8 content. + * Dashboard HTML contains readable Chinese markers including `图书管理系统`, `角色工作台`, `管理员工作台`, `馆藏检索`, `用户管理`, `系统日志`, and `退出登录`. +* Follow-up visual regression reported after that verification: screenshot shows the authenticated shell unstyled at desktop width despite readable Chinese. Future verification must include a browser screenshot or equivalent computed-style/layout assertion, not only `curl`. diff --git a/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/task.json b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/task.json new file mode 100644 index 0000000..94325c8 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-04-28-rebuild-current-frontend/task.json @@ -0,0 +1,26 @@ +{ + "id": "04-28-rebuild-current-frontend", + "name": "04-28-rebuild-current-frontend", + "title": "rebuild and redeploy current frontend", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "Zzzz", + "assignee": "Zzzz", + "createdAt": "2026-04-28", + "completedAt": "2026-04-28", + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/src/main/java/com/mzh/library/controller/LoginServlet.java b/src/main/java/com/mzh/library/controller/LoginServlet.java index 185b664..74caad0 100644 --- a/src/main/java/com/mzh/library/controller/LoginServlet.java +++ b/src/main/java/com/mzh/library/controller/LoginServlet.java @@ -8,6 +8,7 @@ import com.mzh.library.service.impl.AuthServiceImpl; import com.mzh.library.util.SessionAttributes; import java.io.IOException; +import java.util.logging.Logger; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; @@ -16,6 +17,7 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; public class LoginServlet extends HttpServlet { + private static final Logger LOGGER = Logger.getLogger(LoginServlet.class.getName()); private static final String LOGIN_JSP = "/WEB-INF/jsp/auth/login.jsp"; private static final String DASHBOARD_PATH = "/dashboard"; private static final int SESSION_TIMEOUT_SECONDS = 30 * 60; @@ -40,9 +42,13 @@ public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - String username = trim(request.getParameter("username")); + String submittedUsername = request.getParameter("username"); + String username = trim(submittedUsername); String password = request.getParameter("password"); - String redirect = safeRedirect(request.getParameter("redirect")); + String submittedRedirect = request.getParameter("redirect"); + String redirect = safeRedirect(submittedRedirect); + + logLoginPost(request, submittedUsername, username, password, submittedRedirect, redirect); AuthenticationResult result = authService.authenticate(username, password); if (!result.isAuthenticated()) { @@ -57,6 +63,26 @@ public class LoginServlet extends HttpServlet { response.sendRedirect(resolveRedirect(request, redirect)); } + private void logLoginPost( + HttpServletRequest request, + String submittedUsername, + String username, + String password, + String submittedRedirect, + String redirect + ) { + LOGGER.info("Login POST reached" + + " remoteAddr=" + safeLogValue(request.getRemoteAddr()) + + " contextPath=" + safeLogValue(request.getContextPath()) + + " redirectSubmitted=" + !trim(submittedRedirect).isEmpty() + + " redirectAccepted=" + !redirect.isEmpty() + + " usernameSubmitted=" + (submittedUsername != null) + + " usernameLength=" + length(submittedUsername) + + " normalizedUsernameLength=" + username.length() + + " usernameNormalizedChanged=" + !username.equals(nullToEmpty(submittedUsername)) + + " passwordSubmitted=" + (password != null)); + } + private boolean isAuthenticated(HttpServletRequest request) { HttpSession session = request.getSession(false); return session != null && session.getAttribute(SessionAttributes.AUTHENTICATED_USER) != null; @@ -97,4 +123,29 @@ public class LoginServlet extends HttpServlet { private String trim(String value) { return value == null ? "" : value.trim(); } + + private int length(String value) { + return value == null ? 0 : value.length(); + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private String safeLogValue(String value) { + if (value == null) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + int limit = Math.min(value.length(), 120); + for (int i = 0; i < limit; i++) { + char current = value.charAt(i); + builder.append(Character.isISOControl(current) ? '?' : current); + } + if (value.length() > limit) { + builder.append("..."); + } + return builder.toString(); + } } diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java index 4c932fd..93f5b51 100644 --- a/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java +++ b/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java @@ -18,8 +18,11 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; public class JdbcUserDao implements UserDao, UserAccountDao { + private static final Logger LOGGER = Logger.getLogger(JdbcUserDao.class.getName()); private static final String USER_COLUMNS = "" + "id, username, password_hash, display_name, role_code, active, created_at, updated_at "; @@ -48,18 +51,27 @@ public class JdbcUserDao implements UserDao, UserAccountDao { @Override public Optional findActiveByUsername(String username) { + LOGGER.info("Active user lookup start username=" + safeLogValue(username) + + " usernameLength=" + length(username)); try (Connection connection = JdbcUtil.getConnection(); PreparedStatement statement = connection.prepareStatement(FIND_ACTIVE_BY_USERNAME)) { statement.setString(1, username); try (ResultSet resultSet = statement.executeQuery()) { if (!resultSet.next()) { + LOGGER.info("Active user lookup result=not-found username=" + safeLogValue(username)); return Optional.empty(); } - return Optional.of(mapUser(resultSet)); + User user = mapUser(resultSet); + LOGGER.info("Active user lookup result=found" + + " userId=" + user.getId() + + " role=" + user.getRole().getCode() + + " username=" + safeLogValue(username)); + return Optional.of(user); } } catch (SQLException | IllegalArgumentException ex) { + LOGGER.log(Level.SEVERE, "Active user lookup failed username=" + safeLogValue(username), ex); throw new DaoException("Unable to load active user by username", ex); } } @@ -205,4 +217,25 @@ public class JdbcUserDao implements UserDao, UserAccountDao { private LocalDateTime toLocalDateTime(Timestamp timestamp) { return timestamp == null ? null : timestamp.toLocalDateTime(); } + + private int length(String value) { + return value == null ? 0 : value.length(); + } + + private String safeLogValue(String value) { + if (value == null) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + int limit = Math.min(value.length(), 120); + for (int i = 0; i < limit; i++) { + char current = value.charAt(i); + builder.append(Character.isISOControl(current) ? '?' : current); + } + if (value.length() > limit) { + builder.append("..."); + } + return builder.toString(); + } } diff --git a/src/main/java/com/mzh/library/filter/CharacterEncodingFilter.java b/src/main/java/com/mzh/library/filter/CharacterEncodingFilter.java index 15daa81..3469523 100644 --- a/src/main/java/com/mzh/library/filter/CharacterEncodingFilter.java +++ b/src/main/java/com/mzh/library/filter/CharacterEncodingFilter.java @@ -8,6 +8,7 @@ import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; public class CharacterEncodingFilter implements Filter { private String encoding = "UTF-8"; @@ -25,6 +26,39 @@ public class CharacterEncodingFilter implements Filter { throws IOException, ServletException { request.setCharacterEncoding(encoding); response.setCharacterEncoding(encoding); + if (isHtmlRequest(request)) { + response.setContentType("text/html;charset=" + encoding); + } chain.doFilter(request, response); } + + private boolean isHtmlRequest(ServletRequest request) { + if (!(request instanceof HttpServletRequest)) { + return true; + } + + HttpServletRequest httpRequest = (HttpServletRequest) request; + String contextPath = httpRequest.getContextPath(); + String requestUri = httpRequest.getRequestURI(); + String path = requestUri.substring(contextPath.length()); + return !path.startsWith("/static/") + && !path.equals("/favicon.ico") + && !hasStaticAssetExtension(path); + } + + private boolean hasStaticAssetExtension(String path) { + String normalizedPath = path.toLowerCase(); + return normalizedPath.endsWith(".css") + || normalizedPath.endsWith(".js") + || normalizedPath.endsWith(".png") + || normalizedPath.endsWith(".jpg") + || normalizedPath.endsWith(".jpeg") + || normalizedPath.endsWith(".gif") + || normalizedPath.endsWith(".svg") + || normalizedPath.endsWith(".ico") + || normalizedPath.endsWith(".woff") + || normalizedPath.endsWith(".woff2") + || normalizedPath.endsWith(".ttf") + || normalizedPath.endsWith(".map"); + } } diff --git a/src/main/java/com/mzh/library/service/impl/AuthServiceImpl.java b/src/main/java/com/mzh/library/service/impl/AuthServiceImpl.java index ae71761..328770b 100644 --- a/src/main/java/com/mzh/library/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/mzh/library/service/impl/AuthServiceImpl.java @@ -36,24 +36,49 @@ public class AuthServiceImpl implements AuthService { @Override public AuthenticationResult authenticate(String username, String password) { String normalizedUsername = normalizeUsername(username); - if (normalizedUsername.isEmpty() || password == null || password.trim().isEmpty()) { + if (!normalizedUsername.equals(nullToEmpty(username))) { + LOGGER.info("Login username normalized" + + " usernameSubmitted=" + (username != null) + + " usernameLength=" + length(username) + + " normalizedUsernameLength=" + normalizedUsername.length() + + " normalizedUsername=" + safeLogValue(normalizedUsername)); + } + + boolean usernameMissing = normalizedUsername.isEmpty(); + boolean passwordMissing = password == null || password.trim().isEmpty(); + if (usernameMissing || passwordMissing) { + LOGGER.info("Login rejected reason=missing-required" + + " usernameSubmitted=" + (username != null) + + " usernameMissing=" + usernameMissing + + " passwordSubmitted=" + (password != null) + + " passwordMissing=" + passwordMissing); return AuthenticationResult.failure(REQUIRED_MESSAGE); } try { + LOGGER.info("Login lookup start username=" + safeLogValue(normalizedUsername)); Optional user = userDao.findActiveByUsername(normalizedUsername); - if (!user.isPresent() || !PasswordHasher.verify(password, user.get().getPasswordHash())) { - LOGGER.info("Login failed for username=" + normalizedUsername); + if (!user.isPresent()) { + LOGGER.info("Login failed reason=active-user-not-found username=" + safeLogValue(normalizedUsername)); return AuthenticationResult.failure(INVALID_MESSAGE); } - User authenticated = user.get(); + User candidate = user.get(); + if (!PasswordHasher.verify(password, candidate.getPasswordHash())) { + LOGGER.info("Login failed reason=password-mismatch" + + " userId=" + candidate.getId() + + " role=" + candidate.getRole().getCode() + + " username=" + safeLogValue(normalizedUsername)); + return AuthenticationResult.failure(INVALID_MESSAGE); + } + + User authenticated = candidate; Set permissions = permissionPolicy.permissionsFor(authenticated.getRole()); AuthenticatedUser sessionUser = AuthenticatedUser.from(authenticated, permissions); LOGGER.info("Login success userId=" + authenticated.getId() + " role=" + authenticated.getRole().getCode()); return AuthenticationResult.success(sessionUser); } catch (DaoException | IllegalStateException ex) { - LOGGER.log(Level.SEVERE, "Login service error for username=" + normalizedUsername, ex); + LOGGER.log(Level.SEVERE, "Login service error for username=" + safeLogValue(normalizedUsername), ex); return AuthenticationResult.failure(UNAVAILABLE_MESSAGE); } } @@ -66,4 +91,29 @@ public class AuthServiceImpl implements AuthService { private String normalizeUsername(String username) { return username == null ? "" : username.trim(); } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private int length(String value) { + return value == null ? 0 : value.length(); + } + + private String safeLogValue(String value) { + if (value == null) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + int limit = Math.min(value.length(), 120); + for (int i = 0; i < limit; i++) { + char current = value.charAt(i); + builder.append(Character.isISOControl(current) ? '?' : current); + } + if (value.length() > limit) { + builder.append("..."); + } + return builder.toString(); + } } diff --git a/src/main/java/com/mzh/library/util/JdbcUtil.java b/src/main/java/com/mzh/library/util/JdbcUtil.java index 82ce508..570445c 100644 --- a/src/main/java/com/mzh/library/util/JdbcUtil.java +++ b/src/main/java/com/mzh/library/util/JdbcUtil.java @@ -8,10 +8,17 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; public final class JdbcUtil { + private static final Logger LOGGER = Logger.getLogger(JdbcUtil.class.getName()); private static final String CONFIG_FILE = "db.properties"; private static final String DEFAULT_DRIVER = "com.mysql.cj.jdbc.Driver"; + private static final String DRIVER_KEY = "db.driver"; + private static final String URL_KEY = "db.url"; + private static final String USERNAME_KEY = "db.username"; + private static final String PASSWORD_KEY = "db.password"; @FunctionalInterface public interface TransactionCallback { @@ -23,16 +30,42 @@ public final class JdbcUtil { public static Connection getConnection() { Properties properties = loadProperties(); - String driver = properties.getProperty("db.driver", DEFAULT_DRIVER); - String url = required(properties, "db.url"); - String username = required(properties, "db.username"); - String password = required(properties, "db.password"); + String driver = properties.getProperty(DRIVER_KEY, DEFAULT_DRIVER); + String url = required(properties, URL_KEY); + String username = required(properties, USERNAME_KEY); + String password = required(properties, PASSWORD_KEY); + + LOGGER.info("Database connection configuration resolved" + + " file=" + CONFIG_FILE + + " driverKey=" + DRIVER_KEY + + " driver=" + safeLogValue(driver) + + " jdbcUrl=" + redactJdbcUrl(url) + + " usernameKey=" + USERNAME_KEY + + " usernameConfigured=" + !username.isEmpty() + + " password="); + LOGGER.info("Database connection attempt" + + " driverKey=" + DRIVER_KEY + + " driver=" + safeLogValue(driver) + + " jdbcUrl=" + redactJdbcUrl(url) + + " usernameKey=" + USERNAME_KEY); try { Class.forName(driver); - return DriverManager.getConnection(url, username, password); - } catch (ClassNotFoundException | SQLException ex) { + Connection connection = DriverManager.getConnection(url, username, password); + LOGGER.info("Database connection established jdbcUrl=" + redactJdbcUrl(url) + + " usernameKey=" + USERNAME_KEY); + return connection; + } catch (ClassNotFoundException ex) { + LOGGER.log(Level.SEVERE, "JDBC driver unavailable driver=" + safeLogValue(driver) + + " jdbcUrl=" + redactJdbcUrl(url) + + " usernameKey=" + USERNAME_KEY, ex); throw new DaoException("Unable to open database connection", ex); + } catch (SQLException ex) { + SQLException safeException = safeSqlException(ex); + LOGGER.log(Level.SEVERE, "Database connection failed driver=" + safeLogValue(driver) + + " jdbcUrl=" + redactJdbcUrl(url) + + " usernameKey=" + USERNAME_KEY, safeException); + throw new DaoException("Unable to open database connection", safeException); } } @@ -68,13 +101,20 @@ public final class JdbcUtil { .getContextClassLoader() .getResourceAsStream(CONFIG_FILE)) { if (inputStream == null) { + LOGGER.severe("Database configuration file not found file=" + CONFIG_FILE); throw new DaoException("Missing database configuration file: " + CONFIG_FILE, null); } Properties properties = new Properties(); properties.load(inputStream); + LOGGER.info("Database configuration loaded file=" + CONFIG_FILE + + " driverConfigured=" + hasText(properties, DRIVER_KEY) + + " urlConfigured=" + hasText(properties, URL_KEY) + + " usernameConfigured=" + hasText(properties, USERNAME_KEY) + + " passwordConfigured=" + hasText(properties, PASSWORD_KEY)); return properties; } catch (IOException ex) { + LOGGER.log(Level.SEVERE, "Unable to read database configuration file=" + CONFIG_FILE, ex); throw new DaoException("Unable to read database configuration", ex); } } @@ -82,8 +122,55 @@ public final class JdbcUtil { private static String required(Properties properties, String key) { String value = properties.getProperty(key); if (value == null || value.trim().isEmpty()) { + LOGGER.severe("Missing database configuration value key=" + key); throw new DaoException("Missing database configuration value: " + key, null); } return value.trim(); } + + private static String redactJdbcUrl(String value) { + if (value == null) { + return ""; + } + return safeLogValue(redactSensitive(value)); + } + + private static SQLException safeSqlException(SQLException ex) { + SQLException safeException = new SQLException( + safeLogValue(redactSensitive(ex.getMessage())), + ex.getSQLState(), + ex.getErrorCode() + ); + safeException.setStackTrace(ex.getStackTrace()); + return safeException; + } + + private static String redactSensitive(String value) { + if (value == null) { + return ""; + } + return value.replaceAll("(?i)(password|pwd|pass|secret|token)(\\s*[=:]\\s*)([^;&\\s]*)", "$1$2"); + } + + private static boolean hasText(Properties properties, String key) { + String value = properties.getProperty(key); + return value != null && !value.trim().isEmpty(); + } + + private static String safeLogValue(String value) { + if (value == null) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + int limit = Math.min(value.length(), 240); + for (int i = 0; i < limit; i++) { + char current = value.charAt(i); + builder.append(Character.isISOControl(current) ? '?' : current); + } + if (value.length() > limit) { + builder.append("..."); + } + return builder.toString(); + } } diff --git a/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp b/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp index 4de884d..5604559 100644 --- a/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp +++ b/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp @@ -7,7 +7,7 @@ <c:out value="${formTitle}" /> - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp b/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp index f12aff3..00965bb 100644 --- a/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp +++ b/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp @@ -7,7 +7,7 @@ 用户管理 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/auth/login.jsp b/src/main/webapp/WEB-INF/jsp/auth/login.jsp index ec0e189..b0ccb4e 100644 --- a/src/main/webapp/WEB-INF/jsp/auth/login.jsp +++ b/src/main/webapp/WEB-INF/jsp/auth/login.jsp @@ -7,7 +7,7 @@ 登录 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/auth/unauthorized.jsp b/src/main/webapp/WEB-INF/jsp/auth/unauthorized.jsp index e200ba6..eaa56f2 100644 --- a/src/main/webapp/WEB-INF/jsp/auth/unauthorized.jsp +++ b/src/main/webapp/WEB-INF/jsp/auth/unauthorized.jsp @@ -6,7 +6,7 @@ 无权限 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/books/catalog.jsp b/src/main/webapp/WEB-INF/jsp/books/catalog.jsp index 1712e70..061a0ed 100644 --- a/src/main/webapp/WEB-INF/jsp/books/catalog.jsp +++ b/src/main/webapp/WEB-INF/jsp/books/catalog.jsp @@ -7,7 +7,7 @@ 馆藏检索 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/books/categories.jsp b/src/main/webapp/WEB-INF/jsp/books/categories.jsp index 6298690..def99cb 100644 --- a/src/main/webapp/WEB-INF/jsp/books/categories.jsp +++ b/src/main/webapp/WEB-INF/jsp/books/categories.jsp @@ -6,7 +6,7 @@ 分类管理 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/books/category-form.jsp b/src/main/webapp/WEB-INF/jsp/books/category-form.jsp index ae11a4b..a146061 100644 --- a/src/main/webapp/WEB-INF/jsp/books/category-form.jsp +++ b/src/main/webapp/WEB-INF/jsp/books/category-form.jsp @@ -7,7 +7,7 @@ <c:out value="${formTitle}" /> - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/books/form.jsp b/src/main/webapp/WEB-INF/jsp/books/form.jsp index 7bb8b99..ed9d430 100644 --- a/src/main/webapp/WEB-INF/jsp/books/form.jsp +++ b/src/main/webapp/WEB-INF/jsp/books/form.jsp @@ -7,7 +7,7 @@ <c:out value="${formTitle}" /> - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/books/manage.jsp b/src/main/webapp/WEB-INF/jsp/books/manage.jsp index 7a1d311..5a87003 100644 --- a/src/main/webapp/WEB-INF/jsp/books/manage.jsp +++ b/src/main/webapp/WEB-INF/jsp/books/manage.jsp @@ -7,7 +7,7 @@ 图书管理 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp b/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp index 33c35b8..6341f66 100644 --- a/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp +++ b/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp @@ -7,7 +7,7 @@ 新增借阅 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp b/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp index b3ecca0..b1e7b1a 100644 --- a/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp +++ b/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp @@ -7,7 +7,7 @@ 借阅管理 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index 0c04cc7..29f3acf 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -1,83 +1,102 @@ +<%@ page pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> -
+
- +
! - - - - - - + + + + + + + + + +
diff --git a/src/main/webapp/WEB-INF/jsp/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/dashboard.jsp index a9632fe..237d8fe 100644 --- a/src/main/webapp/WEB-INF/jsp/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/dashboard.jsp @@ -6,7 +6,7 @@ 控制台 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp b/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp index 62d1d27..c0c60cf 100644 --- a/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp +++ b/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp @@ -7,7 +7,7 @@ 系统日志 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/reader/loans.jsp b/src/main/webapp/WEB-INF/jsp/reader/loans.jsp index c9a0b32..a33a2ed 100644 --- a/src/main/webapp/WEB-INF/jsp/reader/loans.jsp +++ b/src/main/webapp/WEB-INF/jsp/reader/loans.jsp @@ -6,7 +6,7 @@ 借阅历史 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/readers/form.jsp b/src/main/webapp/WEB-INF/jsp/readers/form.jsp index 38d49b1..62a893a 100644 --- a/src/main/webapp/WEB-INF/jsp/readers/form.jsp +++ b/src/main/webapp/WEB-INF/jsp/readers/form.jsp @@ -7,7 +7,7 @@ <c:out value="${formTitle}" /> - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/readers/manage.jsp b/src/main/webapp/WEB-INF/jsp/readers/manage.jsp index aa45307..29f86ef 100644 --- a/src/main/webapp/WEB-INF/jsp/readers/manage.jsp +++ b/src/main/webapp/WEB-INF/jsp/readers/manage.jsp @@ -7,7 +7,7 @@ 读者管理 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp index 71551ed..44f682f 100644 --- a/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp @@ -6,7 +6,7 @@ 报表 - MZH 图书馆 - + <%@ include file="/WEB-INF/jsp/common/header.jspf" %> diff --git a/src/main/webapp/WEB-INF/jsp/role-home.jsp b/src/main/webapp/WEB-INF/jsp/role-home.jsp index 6e8468b..dbf8087 100644 --- a/src/main/webapp/WEB-INF/jsp/role-home.jsp +++ b/src/main/webapp/WEB-INF/jsp/role-home.jsp @@ -6,7 +6,7 @@ <c:out value="${areaName}" /> - 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 d002526..d15df03 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -17,7 +17,7 @@ --color-purple: #7c6ee6; --shadow-panel: 0 10px 28px rgba(15, 23, 42, 0.09); --shadow-soft: 0 4px 14px rgba(15, 23, 42, 0.06); - --sidebar-width: 248px; + --sidebar-width: 264px; --topbar-height: 64px; } @@ -32,7 +32,9 @@ html { body { margin: 0; min-height: 100vh; + color: #111827; color: var(--color-ink); + background: #eef3f8; background: var(--color-page); font-family: Arial, "Microsoft YaHei", sans-serif; font-size: 14px; @@ -65,7 +67,8 @@ textarea { min-height: 0; } -.auth-page .app-header { +.auth-page .app-header, +.app-header-public { min-height: 64px; display: flex; align-items: center; @@ -74,16 +77,7 @@ textarea { background: rgba(255, 255, 255, 0.9); } -.app-header:has(.auth-brand) { - min-height: 64px; - display: flex; - align-items: center; - padding: 0 32px; - border-bottom: 1px solid rgba(226, 232, 240, 0.8); - background: rgba(255, 255, 255, 0.94); -} - -.app-header:has(.auth-brand) + .page-shell { +.app-header-public + .page-shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 36px 0 56px; @@ -98,31 +92,44 @@ textarea { .app-sidebar { position: fixed; + top: 0; + bottom: 0; + left: 0; inset: 0 auto 0 0; z-index: 30; + width: 264px; width: var(--sidebar-width); + min-height: 100vh; display: flex; flex-direction: column; - padding: 22px 14px 16px; + overflow-y: auto; + padding: 20px 16px 16px; color: #cbd5e1; - background: - radial-gradient(circle at 25% 8%, rgba(59, 130, 246, 0.16), transparent 34%), - linear-gradient(180deg, #101a2b 0%, #172235 46%, #0f1726 100%); + background: #101a2b; + background: linear-gradient(180deg, #101a2b 0%, #172235 46%, #0f1726 100%); box-shadow: 12px 0 30px rgba(15, 23, 42, 0.18); } .sidebar-brand { display: flex; align-items: center; - gap: 10px; - min-height: 42px; - padding: 0 12px; + gap: 11px; + min-height: 44px; + padding: 0 10px; color: #ffffff; font-size: 17px; font-weight: 800; text-decoration: none; } +.brand-text { + min-width: 0; + overflow: hidden; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + .brand-mark { width: 28px; height: 28px; @@ -136,11 +143,15 @@ textarea { } .role-workbench { - margin-top: 24px; - padding: 0 0 14px; + display: grid; + gap: 8px; + margin-top: 22px; + padding: 0 0 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); } .sidebar-section-title { + grid-column: 1 / -1; margin: 0 0 10px; padding: 0 12px; color: #91a2bd; @@ -149,24 +160,21 @@ textarea { } .role-chip { - min-height: 38px; + min-height: 44px; display: grid; - grid-template-columns: 28px 1fr; - grid-template-rows: auto auto; - gap: 0 9px; + grid-template-columns: 30px minmax(0, 1fr); + gap: 0 10px; align-items: center; - margin: 8px 0; - padding: 8px 11px; + padding: 9px 11px; border-radius: 7px; color: #ffffff; text-decoration: none; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16); } -.role-chip span { - grid-row: 1 / 3; - width: 26px; - height: 26px; +.role-chip-icon { + width: 28px; + height: 28px; display: inline-flex; align-items: center; justify-content: center; @@ -176,13 +184,25 @@ textarea { font-weight: 800; } +.role-chip-copy { + min-width: 0; + display: grid; + gap: 2px; +} + .role-chip strong { + overflow: hidden; line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; } .role-chip small { + overflow: hidden; color: rgba(255, 255, 255, 0.82); font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; } .role-chip-admin { @@ -199,30 +219,43 @@ textarea { .side-nav { display: grid; - gap: 5px; - margin-top: 4px; + gap: 6px; + margin-top: 14px; } .side-nav-link { min-height: 40px; - display: flex; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); align-items: center; - gap: 11px; - padding: 9px 11px; + gap: 10px; + padding: 8px 10px; border-radius: 7px; color: #c8d2df; + line-height: 1.2; text-decoration: none; } -.side-nav-link span { - width: 22px; +.side-nav-link .nav-icon { + width: 26px; + height: 26px; display: inline-flex; + align-items: center; justify-content: center; + border-radius: 6px; color: #9aa9bd; + background: rgba(255, 255, 255, 0.06); font-size: 13px; font-weight: 800; } +.side-nav-link .nav-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .side-nav-link:hover, .side-nav-link.is-active { color: #ffffff; @@ -230,9 +263,10 @@ textarea { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); } -.side-nav-link:hover span, -.side-nav-link.is-active span { +.side-nav-link:hover .nav-icon, +.side-nav-link.is-active .nav-icon { color: #ffffff; + background: rgba(255, 255, 255, 0.18); } .sidebar-footer { @@ -260,12 +294,20 @@ textarea { .app-topbar { position: fixed; + top: 0; + right: 0; + left: 264px; + left: var(--sidebar-width); inset: 0 0 auto var(--sidebar-width); z-index: 25; + height: 64px; height: var(--topbar-height); display: flex; + display: grid; + grid-template-columns: minmax(140px, 1fr) minmax(260px, 420px) auto; align-items: center; - gap: 18px; + justify-content: space-between; + gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--color-border); background: rgba(255, 255, 255, 0.96); @@ -273,11 +315,13 @@ textarea { } .breadcrumb { - flex: 1; - min-width: 160px; + min-width: 0; + overflow: hidden; color: #475569; font-size: 13px; font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; } .breadcrumb span { @@ -286,8 +330,9 @@ textarea { } .global-search { - width: min(360px, 34vw); - min-width: 240px; + width: 100%; + max-width: 420px; + min-width: 0; height: 38px; display: flex; align-items: center; @@ -298,6 +343,7 @@ textarea { .global-search input { width: 100%; + min-width: 0; height: 100%; padding: 0 12px; border: 0; @@ -307,7 +353,8 @@ textarea { } .global-search button { - width: 40px; + width: 42px; + flex: 0 0 42px; height: 100%; border: 0; color: #64748b; @@ -316,16 +363,20 @@ textarea { } .topbar-actions { + flex: 0 0 auto; display: flex; align-items: center; + justify-content: flex-end; gap: 11px; + min-width: 0; color: #334155; white-space: nowrap; } .notification-dot { - width: 22px; - height: 22px; + width: 24px; + height: 24px; + flex: 0 0 24px; display: inline-flex; align-items: center; justify-content: center; @@ -337,6 +388,18 @@ textarea { font-weight: 800; } +.user-summary { + min-width: 0; + max-width: 240px; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: #f8fafc; +} + .avatar { width: 32px; height: 32px; @@ -347,14 +410,24 @@ textarea { color: #102033; background: linear-gradient(135deg, #dbeafe, #ffffff); font-weight: 800; + flex: 0 0 32px; box-shadow: inset 0 0 0 1px #bfdbfe; } +.user-meta { + min-width: 0; + display: grid; + gap: 2px; + line-height: 1.12; +} + .user-pill, .role-label { - max-width: 160px; + display: block; + max-width: 180px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .user-pill { @@ -390,10 +463,17 @@ textarea { body:not(.auth-page) .page-shell { width: auto; max-width: none; + margin-left: 264px; margin-left: var(--sidebar-width); + padding: 82px 24px 44px; padding: calc(var(--topbar-height) + 18px) 24px 44px; } +body:not(.auth-page) .dashboard-shell { + max-width: 1360px; + margin-right: auto; +} + .login-panel, .notice-panel, .dashboard-hero, @@ -405,9 +485,12 @@ body:not(.auth-page) .page-shell { .dashboard-panel, .metric-card, .shortcut-card { + border: 1px solid #e2e8f0; border: 1px solid var(--color-border); border-radius: 8px; + background: #ffffff; background: var(--color-panel); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.09); box-shadow: var(--shadow-panel); } @@ -573,8 +656,8 @@ h2 { } .dashboard-hero { - padding: 24px; - margin-bottom: 16px; + padding: 24px 26px; + margin-bottom: 18px; } .dashboard-welcome { @@ -584,6 +667,14 @@ h2 { gap: 18px; } +.dashboard-welcome > div:first-child { + min-width: 0; +} + +.dashboard-welcome p { + max-width: 760px; +} + .dashboard-welcome p:last-child, .workspace-card p:last-child, .notice-panel p:last-child { @@ -592,6 +683,7 @@ h2 { .welcome-user { min-width: 150px; + max-width: 240px; display: grid; gap: 4px; padding: 11px 14px; @@ -611,13 +703,17 @@ h2 { } .dashboard-metrics { + display: flex; + flex-wrap: wrap; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 16px; - margin-bottom: 16px; + gap: 18px; + margin-bottom: 18px; } .metric-card { + flex: 1 1 230px; + min-width: 0; min-height: 98px; display: flex; align-items: center; @@ -625,6 +721,10 @@ h2 { padding: 18px 20px; } +.metric-card > div { + min-width: 0; +} + .metric-card h2 { margin-bottom: 5px; color: #475569; @@ -665,6 +765,7 @@ h2 { font-size: 24px; font-weight: 900; line-height: 1; + white-space: nowrap; } .metric-value small { @@ -687,13 +788,17 @@ h2 { } .dashboard-grid { + display: flex; + flex-wrap: wrap; display: grid; - grid-template-columns: minmax(320px, 0.96fr) minmax(420px, 1.34fr); - gap: 16px; - margin-bottom: 16px; + grid-template-columns: minmax(300px, 0.9fr) minmax(0, 1.3fr); + gap: 18px; + margin-bottom: 18px; } .dashboard-panel { + flex: 1 1 360px; + min-width: 0; padding: 16px; } @@ -705,7 +810,7 @@ h2 { .dashboard-search-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 13px 22px; + gap: 13px 16px; align-items: end; } @@ -749,9 +854,9 @@ h2 { .rank-chart { height: 162px; display: grid; - grid-template-columns: repeat(10, minmax(36px, 1fr)); + grid-template-columns: repeat(10, minmax(26px, 1fr)); align-items: end; - gap: 12px; + gap: 10px; padding: 6px 4px 0; border-top: 1px solid #eef2f7; background: @@ -793,9 +898,12 @@ h2 { } .dashboard-table-grid { + display: flex; + flex-wrap: wrap; display: grid; - grid-template-columns: minmax(0, 1.8fr) minmax(300px, 0.9fr); - gap: 16px; + grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.75fr); + align-items: start; + gap: 18px; } .table-panel-wide { @@ -814,7 +922,7 @@ h2 { .shortcut-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; } @@ -1157,6 +1265,10 @@ h2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric-card { + flex-basis: calc(50% - 9px); + } + .dashboard-grid, .dashboard-table-grid { grid-template-columns: 1fr; @@ -1171,6 +1283,10 @@ h2 { .app-sidebar, .app-topbar { position: static; + top: auto; + right: auto; + bottom: auto; + left: auto; width: auto; inset: auto; } @@ -1180,12 +1296,40 @@ h2 { border-radius: 0; } + .role-workbench { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } + + .side-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sidebar-footer { + margin-top: 14px; + } + .app-topbar { height: auto; - flex-wrap: wrap; + flex-direction: column; + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + gap: 10px; padding: 14px 16px; } + .topbar-actions { + justify-content: space-between; + } + + .user-summary { + max-width: none; + } + + .user-pill, + .role-label { + max-width: none; + } + body:not(.auth-page) .page-shell { width: min(1280px, calc(100% - 24px)); margin: 0 auto; @@ -1194,6 +1338,7 @@ h2 { .global-search { width: 100%; + max-width: none; } } @@ -1214,6 +1359,7 @@ h2 { .dashboard-metrics, .dashboard-search-form, + .side-nav, .shortcut-grid, .search-form, .borrowing-search-form, @@ -1222,6 +1368,11 @@ h2 { grid-template-columns: 1fr; } + .metric-card, + .dashboard-panel { + flex-basis: 100%; + } + .rank-chart { grid-template-columns: repeat(5, minmax(36px, 1fr)); height: auto; @@ -1231,6 +1382,15 @@ h2 { height: 132px; } + .dashboard-form-actions, + .topbar-actions { + flex-wrap: wrap; + } + + .dashboard-form-actions .button { + flex: 1 1 120px; + } + .login-panel, .notice-panel, .dashboard-hero,