前端优化

This commit is contained in:
Zzzz
2026-04-28 19:26:08 +08:00
parent 36db197e75
commit 0a386b81f9
10 changed files with 607 additions and 424 deletions
@@ -45,6 +45,9 @@ image-first design and preserve the Servlet/JSP layered architecture.
- Do not implement UI only from text descriptions when an approved image - Do not implement UI only from text descriptions when an approved image
reference exists. reference exists.
- Do not put SQL, DAO calls, or business workflows in JSP pages. - Do not put SQL, DAO calls, or business workflows in JSP pages.
- Do not hard-code operational dashboard/report metrics, sample people, fixed
borrow dates, or fake table rows in JSP pages; use Servlet-provided request
attributes and empty states.
- Do not rely only on browser validation for protected workflows. - Do not rely only on browser validation for protected workflows.
--- ---
@@ -36,6 +36,100 @@ changes the frontend architecture.
--- ---
## Scenario: Dashboard Workbench Request Contract
### 1. Scope / Trigger
- Trigger: the authenticated workbench spans Servlet request attributes,
service-derived report/catalog/borrowing data, and role-specific JSP display.
- Route: `GET /dashboard`.
- JSP path: `WEB-INF/jsp/dashboard.jsp`.
### 2. Signatures
- Servlet: `DashboardServlet.doGet(HttpServletRequest, HttpServletResponse)`.
- Services used for page data:
- `BookService.listCategories()`.
- `BookService.searchBooks(new BookSearchCriteria())`.
- `ReaderService.searchReaders(new ReaderSearchCriteria())` for staff reader
totals.
- `ReportService.loadReportCenter(AuthenticatedUser actor)` for
administrator/librarian users.
- `BorrowingService.searchRecords(actor, new BorrowRecordSearchCriteria())`
for administrator/librarian users.
- Request attributes:
- `currentUser: AuthenticatedUser`.
- `categories: List<BookCategory>`.
- `dashboardBooks: List<Book>`.
- `dashboardMetrics: List<DashboardMetric>`.
- `reportCenter: ReportCenter` for staff users when report loading succeeds.
- `dashboardBorrowRecords: List<BorrowRecord>` for staff users.
- `errorMessage: String` when a service returns a safe failure.
### 3. Contracts
- Workbench values must come from request attributes populated by the Servlet;
JSP must not embed operational sample rows, fixed dates, or fake totals.
- Staff metrics use `ReportCenter` values derived from `books` and
`borrow_records`, plus reader totals from `ReaderService`; reader fallback
metrics may derive from `dashboardBooks`.
- Popular ranking, overdue rows, and borrowing rows render only real service
results and show empty states when lists are empty.
- Category filters render from `categories`, the same source used by catalog and
book-management pages.
- Role-gated sections stay in JSP conditionals based on `sessionScope.userRole`;
staff-only data is not requested for reader users.
### 4. Validation & Error Matrix
- Category load failure -> `categories` is an empty list and `errorMessage` is
set.
- Book search failure -> `dashboardBooks` is an empty list and `errorMessage`
is set.
- Reader total load failure -> staff metrics fall back to another real
service-derived metric and `errorMessage` is set.
- Staff report load failure -> report-backed sections show empty states and
`errorMessage` is set.
- Staff borrowing search failure -> `dashboardBorrowRecords` is an empty list
and `errorMessage` is set.
- Empty service result -> render a stable empty state, not hard-coded fallback
sample data.
### 5. Good/Base/Bad Cases
- Good: a librarian opens `/dashboard` and sees report-backed metrics, current
borrowing rows, overdue rows, popular ranking, and real book rows.
- Base: no borrow records exist; the workbench keeps the layout and shows empty
states for ranking, borrowing, and overdue panels.
- Bad: `dashboard.jsp` contains names, book IDs, 2024 dates, or counts that do
not come from request attributes.
### 6. Tests Required
- Run Maven compile/test for Servlet and JavaBean contract checks.
- Run standalone service checks covering report, borrowing, catalog/book, and
permission policy behavior when available.
- Scan `dashboard.jsp` for static sample names, fixed dates, and decorative
sample-only values after dashboard changes.
- Verify staff and reader role conditionals still show only the intended
sections.
### 7. Wrong vs Correct
#### Wrong
```text
dashboard.jsp -> hard-coded metric "12,586" and fixed rows like "L20240521001"
```
#### Correct
```text
dashboard.jsp <- DashboardServlet <- ReportService/BookService/ReaderService/BorrowingService
```
---
## Page Scripts ## Page Scripts
Small JavaScript can improve interaction, such as confirm dialogs or local form Small JavaScript can improve interaction, such as confirm dialogs or local form
@@ -0,0 +1,5 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Verify frontend work follows JSP/CSS conventions"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify responsive UI and simplified authenticated shell"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify backend layering and no static data regressions"}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify dashboard uses existing derived report data correctly"}
@@ -0,0 +1,8 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS conventions for authenticated shell and dashboard UI"}
{"file": ".trellis/spec/backend/index.md", "reason": "Backend Servlet/service/DAO layering for dashboard real data"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "JSP fragments, cards, tables, and reusable presentation rules"}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Server-rendered request/session state conventions"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "UI quality checks for JSP/CSS changes"}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Existing report data contracts and database-derived summary rules"}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "ServiceResult and servlet error handling conventions"}
@@ -0,0 +1,54 @@
# Fix Frontend Workbench Display
## Goal
Make the authenticated workbench reflect real application data and simplify the navigation-heavy UI so it does not duplicate the sidebar.
## What I Already Know
- The user reported that the frontend workbench data does not match actual data.
- The current `dashboard.jsp` hard-codes metric values, popular book ranking rows, borrowing rows, overdue rows, and book rows.
- The workbench shortcut cards for 读者管理, 报表中心, 借阅流通, and 系统日志 duplicate links already present in the sidebar.
- The UI uses circular single-character markers beside text in metrics, shortcut cards, sidebar links, role chips, and topbar user summary.
- The sidebar is fixed on desktop, but responsive CSS changes `.app-sidebar` and `.app-topbar` to static layout under 960px, effectively removing the persistent sidebar behavior.
- Existing report infrastructure already exposes actual inventory summary, borrowing summary, overdue rows, and popular books through `ReportService.loadReportCenter(...)`.
## Assumptions
- "UI text beside an unnecessary circle with one character" applies to decorative single-character icon circles in the authenticated shell and workbench, not to plain text labels or table status pills.
- The workbench should reuse existing server-rendered JSP/Servlet patterns rather than introducing client-side state.
- When a specific real data source does not yet exist, prefer showing an existing real metric over keeping a static fake metric.
## Requirements
- Replace hard-coded workbench summary metrics with real data.
- Replace the hard-coded popular book ranking with real ranking data.
- Replace hard-coded borrowing/overdue/book table samples with real data or remove the fake sample rows in favor of empty states.
- Keep the workbench catalog search category selector populated from real categories.
- Remove the workbench shortcut entry block containing 读者管理, 报表中心, 借阅流通, and 系统日志.
- Remove the decorative circular single-character UI markers around text in the authenticated shell/workbench where they are not functionally necessary.
- Ensure the sidebar cannot be hidden or collapsed by responsive layout rules.
- Keep role-based visibility and permissions intact for administrator, librarian, and reader users.
## Acceptance Criteria
- [ ] Workbench metrics are rendered from request attributes populated by backend services, not hard-coded numbers.
- [ ] Popular ranking and table content no longer contain static sample records such as 张晓明, 活着, 三体, or fixed 2024 dates unless those values come from the database.
- [ ] The workbench no longer shows shortcut cards for 读者管理, 报表中心, 借阅流通, or 系统日志.
- [ ] Decorative single-character circles next to UI text are removed or restyled as plain text/spacing without circular badges.
- [ ] Sidebar remains visible and occupies its sidebar column across responsive breakpoints.
- [ ] Existing navigation links still work and remain role-aware.
- [ ] Project lint/type-check or the closest available Java build/test command passes.
## Out Of Scope
- Adding new major dashboard modules beyond the current workbench content.
- Redesigning unrelated pages outside the shared authenticated shell and workbench.
- Changing database schema unless necessary to replace static workbench data.
## Technical Notes
- Likely files: `src/main/java/com/mzh/library/controller/DashboardServlet.java`, `src/main/webapp/WEB-INF/jsp/dashboard.jsp`, `src/main/webapp/WEB-INF/jsp/common/header.jspf`, and `src/main/webapp/static/css/app.css`.
- Existing actual report data: `ReportServiceImpl`, `JdbcReportDao`, `ReportCenter`, `InventorySummary`, `BorrowingSummary`, `OverdueReportRow`, and `PopularBookReportRow`.
- Existing category/book patterns: `BookServiceImpl`, `JdbcBookDao`, and `BookCatalogServlet`.
- Existing borrowing list pattern: `BorrowingServiceImpl.searchRecords(...)` and `BorrowingManagementServlet`.
@@ -0,0 +1,26 @@
{
"id": "frontend-workbench-display-fix",
"name": "frontend-workbench-display-fix",
"title": "修复前端工作台展示",
"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": {}
}
@@ -1,9 +1,37 @@
package com.mzh.library.controller; package com.mzh.library.controller;
import com.mzh.library.dao.impl.JdbcBookDao;
import com.mzh.library.dao.impl.JdbcBorrowRecordDao;
import com.mzh.library.dao.impl.JdbcReaderDao;
import com.mzh.library.dao.impl.JdbcReportDao;
import com.mzh.library.entity.AuthenticatedUser; import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import com.mzh.library.entity.BookStatus;
import com.mzh.library.entity.BorrowRecord;
import com.mzh.library.entity.BorrowRecordSearchCriteria;
import com.mzh.library.entity.BorrowingSummary;
import com.mzh.library.entity.InventorySummary;
import com.mzh.library.entity.Reader;
import com.mzh.library.entity.ReaderSearchCriteria;
import com.mzh.library.entity.ReportCenter;
import com.mzh.library.entity.Role;
import com.mzh.library.service.BookService;
import com.mzh.library.service.BorrowingService;
import com.mzh.library.service.ReaderService;
import com.mzh.library.service.ReportService;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.service.impl.BookServiceImpl;
import com.mzh.library.service.impl.BorrowingServiceImpl;
import com.mzh.library.service.impl.ReaderServiceImpl;
import com.mzh.library.service.impl.ReportServiceImpl;
import com.mzh.library.util.SessionAttributes; import com.mzh.library.util.SessionAttributes;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServlet;
@@ -14,13 +42,200 @@ import javax.servlet.http.HttpSession;
public class DashboardServlet extends HttpServlet { public class DashboardServlet extends HttpServlet {
private static final String DASHBOARD_JSP = "/WEB-INF/jsp/dashboard.jsp"; private static final String DASHBOARD_JSP = "/WEB-INF/jsp/dashboard.jsp";
private BookService bookService;
private BorrowingService borrowingService;
private ReaderService readerService;
private ReportService reportService;
@Override
public void init() {
this.bookService = new BookServiceImpl(new JdbcBookDao());
this.borrowingService = new BorrowingServiceImpl(new JdbcBorrowRecordDao());
this.readerService = new ReaderServiceImpl(new JdbcReaderDao());
this.reportService = new ReportServiceImpl(new JdbcReportDao());
}
@Override @Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
HttpSession session = request.getSession(false); AuthenticatedUser user = currentUser(request);
AuthenticatedUser user = session == null
? null
: (AuthenticatedUser) session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
request.setAttribute("currentUser", user); request.setAttribute("currentUser", user);
ServiceResult<List<BookCategory>> categoryResult = bookService.listCategories();
request.setAttribute("categories", categoryResult.isSuccessful()
? listOrEmpty(categoryResult.getData())
: Collections.emptyList());
if (!categoryResult.isSuccessful()) {
setErrorMessage(request, categoryResult.getMessage());
}
ServiceResult<List<Book>> bookResult = bookService.searchBooks(new BookSearchCriteria());
List<Book> dashboardBooks = bookResult.isSuccessful()
? listOrEmpty(bookResult.getData())
: Collections.emptyList();
request.setAttribute("dashboardBooks", dashboardBooks);
if (!bookResult.isSuccessful()) {
setErrorMessage(request, bookResult.getMessage());
}
List<DashboardMetric> metrics = Collections.emptyList();
if (isStaff(user)) {
Integer readerTotal = null;
ServiceResult<List<Reader>> readerResult = readerService.searchReaders(new ReaderSearchCriteria());
if (readerResult.isSuccessful()) {
readerTotal = listOrEmpty(readerResult.getData()).size();
} else {
setErrorMessage(request, readerResult.getMessage());
}
ServiceResult<ReportCenter> reportResult = reportService.loadReportCenter(user);
if (reportResult.isSuccessful()) {
ReportCenter reportCenter = reportResult.getData();
request.setAttribute("reportCenter", reportCenter);
metrics = metricsFromReport(reportCenter, readerTotal);
} else {
setErrorMessage(request, reportResult.getMessage());
}
ServiceResult<List<BorrowRecord>> borrowResult =
borrowingService.searchRecords(user, new BorrowRecordSearchCriteria());
request.setAttribute("dashboardBorrowRecords", borrowResult.isSuccessful()
? listOrEmpty(borrowResult.getData())
: Collections.emptyList());
if (!borrowResult.isSuccessful()) {
setErrorMessage(request, borrowResult.getMessage());
}
}
if (metrics.isEmpty() && bookResult.isSuccessful()) {
metrics = metricsFromBooks(dashboardBooks);
}
request.setAttribute("dashboardMetrics", metrics);
request.getRequestDispatcher(DASHBOARD_JSP).forward(request, response); request.getRequestDispatcher(DASHBOARD_JSP).forward(request, response);
} }
private AuthenticatedUser currentUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
}
private boolean isStaff(AuthenticatedUser user) {
return user != null && (user.getRole() == Role.ADMINISTRATOR || user.getRole() == Role.LIBRARIAN);
}
private <T> List<T> listOrEmpty(List<T> values) {
return values == null ? Collections.emptyList() : values;
}
private List<DashboardMetric> metricsFromReport(ReportCenter reportCenter, Integer readerTotal) {
if (reportCenter == null) {
return Collections.emptyList();
}
InventorySummary inventory = reportCenter.getInventorySummary();
BorrowingSummary borrowing = reportCenter.getBorrowingSummary();
List<DashboardMetric> metrics = new ArrayList<>();
metrics.add(new DashboardMetric("馆藏总册", valueOf(inventory, MetricField.TOTAL_COPIES), "", "来自报表中心"));
metrics.add(new DashboardMetric("当前借出", valueOf(borrowing, MetricField.ACTIVE_LOANS), "", "实时借阅记录"));
metrics.add(new DashboardMetric("逾期借阅", valueOf(borrowing, MetricField.OVERDUE_LOANS), "", "需跟进记录"));
if (readerTotal == null) {
metrics.add(new DashboardMetric("可借册数", valueOf(inventory, MetricField.AVAILABLE_COPIES),
"", "馆藏可借库存"));
} else {
metrics.add(new DashboardMetric("读者总数", readerTotal, "", "实时读者档案"));
}
return metrics;
}
private List<DashboardMetric> metricsFromBooks(List<Book> books) {
int totalTitles = 0;
int totalCopies = 0;
int availableCopies = 0;
int unavailableOrEmptyTitles = 0;
for (Book book : books) {
totalTitles++;
totalCopies += book.getTotalCopies();
availableCopies += book.getAvailableCopies();
if (book.getStatus() != BookStatus.AVAILABLE || book.getAvailableCopies() <= 0) {
unavailableOrEmptyTitles++;
}
}
List<DashboardMetric> metrics = new ArrayList<>();
metrics.add(new DashboardMetric("图书种类", totalTitles, "", "来自馆藏检索"));
metrics.add(new DashboardMetric("馆藏总册", totalCopies, "", "来自馆藏检索"));
metrics.add(new DashboardMetric("可借册数", availableCopies, "", "来自馆藏检索"));
metrics.add(new DashboardMetric("需关注馆藏", unavailableOrEmptyTitles, "", "不可借或无库存"));
return metrics;
}
private int valueOf(InventorySummary summary, MetricField field) {
if (summary == null) {
return 0;
}
switch (field) {
case TOTAL_COPIES:
return summary.getTotalCopies();
case AVAILABLE_COPIES:
return summary.getAvailableCopies();
default:
return 0;
}
}
private int valueOf(BorrowingSummary summary, MetricField field) {
if (summary == null) {
return 0;
}
switch (field) {
case ACTIVE_LOANS:
return summary.getActiveLoans();
case OVERDUE_LOANS:
return summary.getOverdueLoans();
default:
return 0;
}
}
private void setErrorMessage(HttpServletRequest request, String message) {
if (message != null && !message.isEmpty() && request.getAttribute("errorMessage") == null) {
request.setAttribute("errorMessage", message);
}
}
private enum MetricField {
TOTAL_COPIES,
AVAILABLE_COPIES,
ACTIVE_LOANS,
OVERDUE_LOANS
}
public static final class DashboardMetric {
private final String label;
private final int value;
private final String unit;
private final String note;
private DashboardMetric(String label, int value, String unit, String note) {
this.label = label;
this.value = value;
this.unit = unit;
this.note = note;
}
public String getLabel() {
return label;
}
public int getValue() {
return value;
}
public String getUnit() {
return unit;
}
public String getNote() {
return note;
}
}
} }
@@ -7,7 +7,6 @@
<c:set var="currentUri" value="${pageContext.request.requestURI}" /> <c:set var="currentUri" value="${pageContext.request.requestURI}" />
<aside class="app-sidebar" aria-label="主导航"> <aside class="app-sidebar" aria-label="主导航">
<a class="sidebar-brand" href="${pageContext.request.contextPath}/dashboard"> <a class="sidebar-brand" href="${pageContext.request.contextPath}/dashboard">
<span class="brand-mark" aria-hidden="true">书</span>
<span class="brand-text">图书管理系统</span> <span class="brand-text">图书管理系统</span>
</a> </a>
@@ -15,7 +14,6 @@
<p class="sidebar-section-title">角色工作台</p> <p class="sidebar-section-title">角色工作台</p>
<c:if test="${sessionScope.userRole == 'administrator'}"> <c:if test="${sessionScope.userRole == 'administrator'}">
<a class="role-chip role-chip-admin" href="${pageContext.request.contextPath}/admin/home"> <a class="role-chip role-chip-admin" href="${pageContext.request.contextPath}/admin/home">
<span class="role-chip-icon" aria-hidden="true">管</span>
<span class="role-chip-copy"> <span class="role-chip-copy">
<strong>管理员</strong> <strong>管理员</strong>
<small>系统管理</small> <small>系统管理</small>
@@ -24,7 +22,6 @@
</c:if> </c:if>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}"> <c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<a class="role-chip role-chip-librarian" href="${pageContext.request.contextPath}/librarian/home"> <a class="role-chip role-chip-librarian" href="${pageContext.request.contextPath}/librarian/home">
<span class="role-chip-icon" aria-hidden="true">馆</span>
<span class="role-chip-copy"> <span class="role-chip-copy">
<strong>馆员</strong> <strong>馆员</strong>
<small>流通工作</small> <small>流通工作</small>
@@ -33,7 +30,6 @@
</c:if> </c:if>
<c:if test="${sessionScope.userRole == 'reader'}"> <c:if test="${sessionScope.userRole == 'reader'}">
<a class="role-chip role-chip-reader" href="${pageContext.request.contextPath}/reader/home"> <a class="role-chip role-chip-reader" href="${pageContext.request.contextPath}/reader/home">
<span class="role-chip-icon" aria-hidden="true">读</span>
<span class="role-chip-copy"> <span class="role-chip-copy">
<strong>读者</strong> <strong>读者</strong>
<small>自助服务</small> <small>自助服务</small>
@@ -45,64 +41,53 @@
<nav class="side-nav" aria-label="模块导航"> <nav class="side-nav" aria-label="模块导航">
<a class="side-nav-link ${fn:contains(currentUri, '/dashboard') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/dashboard') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/dashboard"> href="${pageContext.request.contextPath}/dashboard">
<span class="nav-icon" aria-hidden="true">台</span>
<span class="nav-text">工作台</span> <span class="nav-text">工作台</span>
</a> </a>
<a class="side-nav-link ${fn:contains(currentUri, '/catalog') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/catalog') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/catalog"> href="${pageContext.request.contextPath}/catalog">
<span class="nav-icon" aria-hidden="true">搜</span>
<span class="nav-text">馆藏检索</span> <span class="nav-text">馆藏检索</span>
</a> </a>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}"> <c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<a class="side-nav-link ${fn:contains(currentUri, '/books') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/books') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/books"> href="${pageContext.request.contextPath}/books">
<span class="nav-icon" aria-hidden="true">书</span>
<span class="nav-text">图书管理</span> <span class="nav-text">图书管理</span>
</a> </a>
<a class="side-nav-link ${fn:contains(currentUri, '/book-categories') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/book-categories') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/book-categories"> href="${pageContext.request.contextPath}/book-categories">
<span class="nav-icon" aria-hidden="true">类</span>
<span class="nav-text">图书分类管理</span> <span class="nav-text">图书分类管理</span>
</a> </a>
<a class="side-nav-link ${fn:contains(currentUri, '/readers') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/readers') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/readers"> href="${pageContext.request.contextPath}/readers">
<span class="nav-icon" aria-hidden="true">人</span>
<span class="nav-text">读者管理</span> <span class="nav-text">读者管理</span>
</a> </a>
<a class="side-nav-link ${fn:contains(currentUri, '/borrowing') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/borrowing') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/borrowing"> href="${pageContext.request.contextPath}/borrowing">
<span class="nav-icon" aria-hidden="true">借</span>
<span class="nav-text">借阅流通</span> <span class="nav-text">借阅流通</span>
</a> </a>
<a class="side-nav-link ${fn:contains(currentUri, '/reports') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/reports') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/reports"> href="${pageContext.request.contextPath}/reports">
<span class="nav-icon" aria-hidden="true">报</span>
<span class="nav-text">报表中心</span> <span class="nav-text">报表中心</span>
</a> </a>
</c:if> </c:if>
<c:if test="${sessionScope.userRole == 'reader'}"> <c:if test="${sessionScope.userRole == 'reader'}">
<a class="side-nav-link ${fn:contains(currentUri, '/reader/loans') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/reader/loans') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/reader/loans"> href="${pageContext.request.contextPath}/reader/loans">
<span class="nav-icon" aria-hidden="true">历</span>
<span class="nav-text">读者借阅历史</span> <span class="nav-text">读者借阅历史</span>
</a> </a>
</c:if> </c:if>
<c:if test="${sessionScope.userRole == 'administrator'}"> <c:if test="${sessionScope.userRole == 'administrator'}">
<a class="side-nav-link ${fn:contains(currentUri, '/admin/users') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/admin/users') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/admin/users"> href="${pageContext.request.contextPath}/admin/users">
<span class="nav-icon" aria-hidden="true">户</span>
<span class="nav-text">用户管理</span> <span class="nav-text">用户管理</span>
</a> </a>
<a class="side-nav-link ${fn:contains(currentUri, '/admin/system-logs') ? 'is-active' : ''}" <a class="side-nav-link ${fn:contains(currentUri, '/admin/system-logs') ? 'is-active' : ''}"
href="${pageContext.request.contextPath}/admin/system-logs"> href="${pageContext.request.contextPath}/admin/system-logs">
<span class="nav-icon" aria-hidden="true">志</span>
<span class="nav-text">系统日志</span> <span class="nav-text">系统日志</span>
</a> </a>
</c:if> </c:if>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<span class="sidebar-menu-dot" aria-hidden="true">≡</span>
<a href="${pageContext.request.contextPath}/logout">退出登录</a> <a href="${pageContext.request.contextPath}/logout">退出登录</a>
</div> </div>
</aside> </aside>
@@ -115,15 +100,7 @@
<button type="submit" aria-label="搜索">搜</button> <button type="submit" aria-label="搜索">搜</button>
</form> </form>
<div class="topbar-actions"> <div class="topbar-actions">
<span class="notification-dot" aria-label="通知">!</span>
<span class="user-summary"> <span class="user-summary">
<span class="avatar" aria-hidden="true">
<c:choose>
<c:when test="${sessionScope.userRole == 'administrator'}">管</c:when>
<c:when test="${sessionScope.userRole == 'librarian'}">馆</c:when>
<c:otherwise>读</c:otherwise>
</c:choose>
</span>
<span class="user-meta"> <span class="user-meta">
<span class="user-pill"> <span class="user-pill">
<c:out value="${sessionScope.authenticatedUser.displayName}" /> <c:out value="${sessionScope.authenticatedUser.displayName}" />
+108 -141
View File
@@ -31,39 +31,36 @@
</div> </div>
</section> </section>
<c:if test="${not empty errorMessage}">
<p class="message message-error"><c:out value="${errorMessage}" /></p>
</c:if>
<section class="dashboard-metrics" aria-label="核心指标"> <section class="dashboard-metrics" aria-label="核心指标">
<article class="metric-card"> <c:choose>
<span class="metric-icon metric-blue" aria-hidden="true">书</span> <c:when test="${empty dashboardMetrics}">
<article class="metric-card metric-card-empty">
<div> <div>
<h2>馆藏总量</h2> <h2>核心指标</h2>
<p class="metric-value">12,586 <small>册</small></p> <p class="metric-value">--</p>
<p class="metric-trend trend-up">较上月 ↑ 5.2%</p> <p class="metric-trend">暂无可展示的实时数据。</p>
</div> </div>
</article> </article>
</c:when>
<c:otherwise>
<c:forEach var="metric" items="${dashboardMetrics}">
<article class="metric-card"> <article class="metric-card">
<span class="metric-icon metric-green" aria-hidden="true">借</span>
<div> <div>
<h2>在借数量</h2> <h2><c:out value="${metric.label}" /></h2>
<p class="metric-value">1,258 <small>册</small></p> <p class="metric-value">
<p class="metric-trend trend-up">较上月 ↑ 3.1%</p> <c:out value="${metric.value}" />
</div> <small><c:out value="${metric.unit}" /></small>
</article> </p>
<article class="metric-card"> <p class="metric-trend"><c:out value="${metric.note}" /></p>
<span class="metric-icon metric-orange" aria-hidden="true">期</span>
<div>
<h2>逾期数量</h2>
<p class="metric-value">87 <small>册</small></p>
<p class="metric-trend trend-down">较上月 ↓ 12.4%</p>
</div>
</article>
<article class="metric-card">
<span class="metric-icon metric-purple" aria-hidden="true">者</span>
<div>
<h2>读者总数</h2>
<p class="metric-value">3,682 <small>人</small></p>
<p class="metric-trend trend-up">较上月 ↑ 4.8%</p>
</div> </div>
</article> </article>
</c:forEach>
</c:otherwise>
</c:choose>
</section> </section>
<section class="dashboard-grid" aria-label="检索与排行"> <section class="dashboard-grid" aria-label="检索与排行">
@@ -85,7 +82,10 @@
<div class="search-field"> <div class="search-field">
<label for="dashCategory">分类</label> <label for="dashCategory">分类</label>
<select id="dashCategory" name="categoryId"> <select id="dashCategory" name="categoryId">
<option value="">请选择分类</option> <option value="">全部分类</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}"><c:out value="${category.name}" /></option>
</c:forEach>
</select> </select>
</div> </div>
<div class="dashboard-form-actions"> <div class="dashboard-form-actions">
@@ -100,25 +100,36 @@
<h2>热门图书排行</h2> <h2>热门图书排行</h2>
<span>借阅次数TOP10</span> <span>借阅次数TOP10</span>
</div> </div>
<c:choose>
<c:when test="${empty reportCenter or empty reportCenter.popularBooks}">
<p class="empty-state">暂无热门排行数据。</p>
</c:when>
<c:otherwise>
<c:set var="rankingMax" value="${reportCenter.popularBooks[0].borrowCount}" />
<div class="rank-chart" aria-label="热门图书排行柱状图"> <div class="rank-chart" aria-label="热门图书排行柱状图">
<div class="rank-item"><span class="rank-value">230</span><span class="rank-bar" style="--bar-height: 92%;"></span><small>活着</small></div> <c:forEach var="row" items="${reportCenter.popularBooks}" end="9">
<div class="rank-item"><span class="rank-value">198</span><span class="rank-bar" style="--bar-height: 79%;"></span><small>三体</small></div> <div class="rank-item">
<div class="rank-item"><span class="rank-value">175</span><span class="rank-bar" style="--bar-height: 70%;"></span><small>百年孤独</small></div> <span class="rank-value"><c:out value="${row.borrowCount}" /></span>
<div class="rank-item"><span class="rank-value">164</span><span class="rank-bar" style="--bar-height: 66%;"></span><small>围城</small></div> <span class="rank-bar"
<div class="rank-item"><span class="rank-value">150</span><span class="rank-bar" style="--bar-height: 60%;"></span><small>平凡的世界</small></div> style="--bar-height: ${rankingMax > 0 ? row.borrowCount * 100 / rankingMax : 0}%;"></span>
<div class="rank-item"><span class="rank-value">138</span><span class="rank-bar" style="--bar-height: 55%;"></span><small>解忧杂货店</small></div> <small><c:out value="${row.title}" /></small>
<div class="rank-item"><span class="rank-value">120</span><span class="rank-bar" style="--bar-height: 48%;"></span><small>红楼梦</small></div>
<div class="rank-item"><span class="rank-value">112</span><span class="rank-bar" style="--bar-height: 45%;"></span><small>白夜行</small></div>
<div class="rank-item"><span class="rank-value">98</span><span class="rank-bar" style="--bar-height: 39%;"></span><small>追风筝的人</small></div>
<div class="rank-item"><span class="rank-value">85</span><span class="rank-bar" style="--bar-height: 34%;"></span><small>小王子</small></div>
</div> </div>
</c:forEach>
</div>
</c:otherwise>
</c:choose>
</article> </article>
</section> </section>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}"> <c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<section class="dashboard-table-grid" aria-label="业务表格"> <section class="dashboard-table-grid" aria-label="业务表格">
<article class="dashboard-panel table-panel-compact table-panel-wide"> <article class="dashboard-panel table-panel-compact table-panel-wide">
<h2>借阅流通 <span>最新记录</span></h2> <h2>借阅流通 <span>实时记录</span></h2>
<c:choose>
<c:when test="${empty dashboardBorrowRecords}">
<p class="empty-state">暂无借阅流通记录。</p>
</c:when>
<c:otherwise>
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table dashboard-data-table"> <table class="data-table dashboard-data-table">
<thead> <thead>
@@ -134,63 +145,45 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<c:forEach var="record" items="${dashboardBorrowRecords}" end="4">
<tr> <tr>
<td>L20240521001</td> <td>#<c:out value="${record.id}" /></td>
<td>张晓明</td> <td><c:out value="${record.readerName}" /></td>
<td>B001245</td> <td><c:out value="${record.bookIdentifier}" /></td>
<td>活着</td> <td><c:out value="${record.bookTitle}" /></td>
<td>2024-05-21</td> <td><c:out value="${record.borrowedAtText}" /></td>
<td>2024-06-04</td> <td><c:out value="${record.dueAtText}" /></td>
<td><span class="status-pill status-active">在借</span></td> <td>
<td><span class="stock-plus">库存-1</span></td> <span class="status-pill status-${record.displayStatusCode}">
</tr> <c:out value="${record.displayStatusName}" />
<tr> </span>
<td>L20240521002</td> </td>
<td>李华</td> <td>
<td>B001026</td> <c:choose>
<td>三体</td> <c:when test="${record.displayStatusCode == 'returned'}">
<td>2024-05-20</td> <span class="stock-return">库存已返还</span>
<td>2024-06-03</td> </c:when>
<td><span class="status-pill status-active">在借</span></td> <c:otherwise>
<td><span class="stock-plus">库存-1</span></td> <span class="stock-plus">借出占用</span>
</tr> </c:otherwise>
<tr> </c:choose>
<td>L20240521003</td> </td>
<td>王丽</td>
<td>B002031</td>
<td>百年孤独</td>
<td>2024-05-18</td>
<td>2024-06-01</td>
<td><span class="status-pill status-returned">已归还</span></td>
<td><span class="stock-return">库存+1</span></td>
</tr>
<tr>
<td>L20240521004</td>
<td>陈强</td>
<td>B001895</td>
<td>围城</td>
<td>2024-05-10</td>
<td>2024-05-24</td>
<td><span class="status-pill status-overdue">逾期</span></td>
<td><span class="stock-plus">库存-1</span></td>
</tr>
<tr>
<td>L20240521005</td>
<td>刘洋</td>
<td>B002119</td>
<td>解忧杂货店</td>
<td>2024-05-12</td>
<td>2024-05-26</td>
<td><span class="status-pill status-overdue">逾期</span></td>
<td><span class="stock-plus">库存-1</span></td>
</tr> </tr>
</c:forEach>
</tbody> </tbody>
</table> </table>
</div> </div>
</c:otherwise>
</c:choose>
</article> </article>
<article class="dashboard-panel table-panel-compact"> <article class="dashboard-panel table-panel-compact">
<h2>逾期列表 <span>待处理</span></h2> <h2>逾期列表 <span>待处理</span></h2>
<c:choose>
<c:when test="${empty reportCenter or empty reportCenter.overdueRows}">
<p class="empty-state">当前没有逾期未还的借阅记录。</p>
</c:when>
<c:otherwise>
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table dashboard-data-table overdue-table"> <table class="data-table dashboard-data-table overdue-table">
<thead> <thead>
@@ -203,14 +196,20 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>陈强</td><td>B001895</td><td>围城</td><td>2024-05-24</td><td><span class="overdue-days">7天</span></td></tr> <c:forEach var="row" items="${reportCenter.overdueRows}" end="4">
<tr><td>赵敏</td><td>B001122</td><td>平凡的世界</td><td>2024-05-20</td><td><span class="overdue-days">11天</span></td></tr> <tr>
<tr><td>孙涛</td><td>B002003</td><td>红楼梦</td><td>2024-05-18</td><td><span class="overdue-days">13天</span></td></tr> <td><c:out value="${row.readerName}" /></td>
<tr><td>周雨</td><td>B000987</td><td>追风筝的人</td><td>2024-05-17</td><td><span class="overdue-days">14天</span></td></tr> <td><c:out value="${row.bookIdentifier}" /></td>
<tr><td>吴迪</td><td>B001776</td><td>白夜行</td><td>2024-05-15</td><td><span class="overdue-days">16天</span></td></tr> <td><c:out value="${row.bookTitle}" /></td>
<td><c:out value="${row.dueAtText}" /></td>
<td><span class="overdue-days"><c:out value="${row.overdueDays}" />天</span></td>
</tr>
</c:forEach>
</tbody> </tbody>
</table> </table>
</div> </div>
</c:otherwise>
</c:choose>
</article> </article>
<article class="dashboard-panel table-panel-compact table-panel-wide"> <article class="dashboard-panel table-panel-compact table-panel-wide">
@@ -218,6 +217,11 @@
<h2>图书管理 <span>馆藏列表</span></h2> <h2>图书管理 <span>馆藏列表</span></h2>
<a href="${pageContext.request.contextPath}/books">进入管理</a> <a href="${pageContext.request.contextPath}/books">进入管理</a>
</div> </div>
<c:choose>
<c:when test="${empty dashboardBooks}">
<p class="empty-state">暂无馆藏图书记录。</p>
</c:when>
<c:otherwise>
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table dashboard-data-table"> <table class="data-table dashboard-data-table">
<thead> <thead>
@@ -226,79 +230,42 @@
<th scope="col">书名</th> <th scope="col">书名</th>
<th scope="col">作者</th> <th scope="col">作者</th>
<th scope="col">分类</th> <th scope="col">分类</th>
<th scope="col">出版日期</th>
<th scope="col">库存状态</th> <th scope="col">库存状态</th>
<th scope="col">馆藏地</th>
<th scope="col">操作</th> <th scope="col">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<c:forEach var="book" items="${dashboardBooks}" end="4">
<tr> <tr>
<td>B001245</td><td>活着</td><td>余华</td><td>文学 &gt; 小说</td><td>2012-08-01</td> <td><c:out value="${book.identifier}" /></td>
<td><span class="status-pill status-available">可借(15</span></td><td>二楼文学区</td> <td><c:out value="${book.title}" /></td>
<td><a class="text-link" href="${pageContext.request.contextPath}/books">管理</a></td> <td><c:out value="${book.author}" /></td>
</tr> <td><c:out value="${book.categoryName}" /></td>
<tr> <td>
<td>B001026</td><td>三体</td><td>刘慈欣</td><td>文学 &gt; 科幻</td><td>2008-01-01</td> <span class="status-pill status-${book.status.code}">
<td><span class="status-pill status-available">可借(8</span></td><td>三楼科幻区</td> <c:out value="${book.status.displayName}" />
<td><a class="text-link" href="${pageContext.request.contextPath}/books">管理</a></td> <c:out value="${book.availableCopies}" />/<c:out value="${book.totalCopies}" />
</tr> </span>
<tr> </td>
<td>B002031</td><td>百年孤独</td><td>加西亚·马尔克斯</td><td>文学 &gt; 外国文学</td><td>2011-06-01</td>
<td><span class="status-pill status-available">可借(6</span></td><td>二楼文学区</td>
<td><a class="text-link" href="${pageContext.request.contextPath}/books">管理</a></td>
</tr>
<tr>
<td>B001895</td><td>围城</td><td>钱钟书</td><td>文学 &gt; 小说</td><td>2008-05-01</td>
<td><span class="status-pill status-available">可借(4</span></td><td>二楼文学区</td>
<td><a class="text-link" href="${pageContext.request.contextPath}/books">管理</a></td>
</tr>
<tr>
<td>B002119</td><td>解忧杂货店</td><td>东野圭吾</td><td>文学 &gt; 小说</td><td>2014-07-01</td>
<td><span class="status-pill status-available">可借(10</span></td><td>二楼文学区</td>
<td><a class="text-link" href="${pageContext.request.contextPath}/books">管理</a></td> <td><a class="text-link" href="${pageContext.request.contextPath}/books">管理</a></td>
</tr> </tr>
</c:forEach>
</tbody> </tbody>
</table> </table>
</div> </div>
</c:otherwise>
</c:choose>
</article> </article>
<aside class="shortcut-grid" aria-label="快捷入口">
<a class="shortcut-card" href="${pageContext.request.contextPath}/readers">
<span class="shortcut-icon shortcut-blue" aria-hidden="true">者</span>
<strong>读者管理</strong>
<small>管理读者信息、证件办理与权限设置</small>
</a>
<a class="shortcut-card" href="${pageContext.request.contextPath}/reports">
<span class="shortcut-icon shortcut-green" aria-hidden="true">报</span>
<strong>报表中心</strong>
<small>生成各类统计报表,支持导出与分析</small>
</a>
<a class="shortcut-card" href="${pageContext.request.contextPath}/borrowing">
<span class="shortcut-icon shortcut-orange" aria-hidden="true">借</span>
<strong>借阅流通</strong>
<small>借书、还书、续借与逾期处理</small>
</a>
<c:if test="${sessionScope.userRole == 'administrator'}">
<a class="shortcut-card" href="${pageContext.request.contextPath}/admin/system-logs">
<span class="shortcut-icon shortcut-purple" aria-hidden="true">志</span>
<strong>系统日志</strong>
<small>系统操作日志与安全审计记录查询</small>
</a>
</c:if>
</aside>
</section> </section>
</c:if> </c:if>
<c:if test="${sessionScope.userRole == 'reader'}"> <c:if test="${sessionScope.userRole == 'reader'}">
<section class="shortcut-grid reader-shortcut-grid" aria-label="读者快捷入口"> <section class="shortcut-grid reader-shortcut-grid" aria-label="读者快捷入口">
<a class="shortcut-card" href="${pageContext.request.contextPath}/reader/loans"> <a class="shortcut-card" href="${pageContext.request.contextPath}/reader/loans">
<span class="shortcut-icon shortcut-blue" aria-hidden="true">历</span>
<strong>我的借阅</strong> <strong>我的借阅</strong>
<small>查看在借、已还、续借次数和逾期状态</small> <small>查看在借、已还、续借次数和逾期状态</small>
</a> </a>
<a class="shortcut-card" href="${pageContext.request.contextPath}/catalog"> <a class="shortcut-card" href="${pageContext.request.contextPath}/catalog">
<span class="shortcut-icon shortcut-green" aria-hidden="true">搜</span>
<strong>馆藏检索</strong> <strong>馆藏检索</strong>
<small>按书名、作者、分类或图书编号查找馆藏</small> <small>按书名、作者、分类或图书编号查找馆藏</small>
</a> </a>
+27 -193
View File
@@ -41,6 +41,10 @@ body {
line-height: 1.45; line-height: 1.45;
} }
body:not(.auth-page) {
min-width: calc(var(--sidebar-width) + 320px);
}
a { a {
color: inherit; color: inherit;
} }
@@ -113,9 +117,8 @@ textarea {
.sidebar-brand { .sidebar-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 11px;
min-height: 44px; min-height: 44px;
padding: 0 10px; padding: 0 12px;
color: #ffffff; color: #ffffff;
font-size: 17px; font-size: 17px;
font-weight: 800; font-weight: 800;
@@ -130,18 +133,6 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.brand-mark {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
color: #102033;
background: #ffffff;
font-size: 15px;
}
.role-workbench { .role-workbench {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -162,8 +153,8 @@ textarea {
.role-chip { .role-chip {
min-height: 44px; min-height: 44px;
display: grid; display: grid;
grid-template-columns: 30px minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 0 10px; gap: 2px;
align-items: center; align-items: center;
padding: 9px 11px; padding: 9px 11px;
border-radius: 7px; border-radius: 7px;
@@ -172,18 +163,6 @@ textarea {
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16); box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16);
} }
.role-chip-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.24);
font-size: 13px;
font-weight: 800;
}
.role-chip-copy { .role-chip-copy {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -226,29 +205,15 @@ textarea {
.side-nav-link { .side-nav-link {
min-height: 40px; min-height: 40px;
display: grid; display: grid;
grid-template-columns: 28px minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
align-items: center; align-items: center;
gap: 10px; padding: 10px 12px;
padding: 8px 10px;
border-radius: 7px; border-radius: 7px;
color: #c8d2df; color: #c8d2df;
line-height: 1.2; line-height: 1.2;
text-decoration: none; text-decoration: none;
} }
.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 { .side-nav-link .nav-text {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -263,12 +228,6 @@ textarea {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
} }
.side-nav-link:hover .nav-icon,
.side-nav-link.is-active .nav-icon {
color: #ffffff;
background: rgba(255, 255, 255, 0.18);
}
.sidebar-footer { .sidebar-footer {
min-height: 34px; min-height: 34px;
display: flex; display: flex;
@@ -287,11 +246,6 @@ textarea {
text-decoration: none; text-decoration: none;
} }
.sidebar-menu-dot {
color: #91a2bd;
font-size: 20px;
}
.app-topbar { .app-topbar {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -373,47 +327,18 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.notification-dot {
width: 24px;
height: 24px;
flex: 0 0 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
border-radius: 999px;
color: #ffffff;
background: #ef4444;
font-size: 12px;
font-weight: 800;
}
.user-summary { .user-summary {
min-width: 0; min-width: 0;
max-width: 240px; max-width: 240px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 10px 4px 4px; padding: 8px 12px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 999px; border-radius: 8px;
background: #f8fafc; background: #f8fafc;
} }
.avatar {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
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 { .user-meta {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -715,12 +640,14 @@ h2 {
flex: 1 1 230px; flex: 1 1 230px;
min-width: 0; min-width: 0;
min-height: 98px; min-height: 98px;
display: flex; display: block;
align-items: center;
gap: 18px;
padding: 18px 20px; padding: 18px 20px;
} }
.metric-card-empty {
grid-column: 1 / -1;
}
.metric-card > div { .metric-card > div {
min-width: 0; min-width: 0;
} }
@@ -731,35 +658,6 @@ h2 {
font-size: 13px; font-size: 13px;
} }
.metric-icon {
width: 52px;
height: 52px;
display: inline-flex;
flex: 0 0 52px;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #ffffff;
font-size: 18px;
font-weight: 800;
}
.metric-blue {
background: linear-gradient(135deg, #4b83f3, #2869e8);
}
.metric-green {
background: linear-gradient(135deg, #5ccaae, #1f9d68);
}
.metric-orange {
background: linear-gradient(135deg, #ffb25c, #f08a24);
}
.metric-purple {
background: linear-gradient(135deg, #9187f1, #6f60e5);
}
.metric-value { .metric-value {
margin-bottom: 4px; margin-bottom: 4px;
font-size: 24px; font-size: 24px;
@@ -933,7 +831,7 @@ h2 {
.shortcut-card { .shortcut-card {
min-height: 100px; min-height: 100px;
display: grid; display: grid;
grid-template-columns: 46px 1fr 14px; grid-template-columns: minmax(0, 1fr) 14px;
grid-template-rows: auto auto; grid-template-rows: auto auto;
gap: 5px 13px; gap: 5px 13px;
align-items: center; align-items: center;
@@ -944,7 +842,7 @@ h2 {
.shortcut-card::after { .shortcut-card::after {
content: ""; content: "";
grid-row: 1 / 3; grid-row: 1 / 3;
grid-column: 3; grid-column: 2;
color: #94a3b8; color: #94a3b8;
font-size: 24px; font-size: 24px;
} }
@@ -959,37 +857,6 @@ h2 {
line-height: 1.35; line-height: 1.35;
} }
.shortcut-icon {
grid-row: 1 / 3;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-weight: 900;
}
.shortcut-blue {
color: #2454cb;
background: #eaf1ff;
}
.shortcut-green {
color: #16825a;
background: #dcfce7;
}
.shortcut-orange {
color: #c46615;
background: #fff3df;
}
.shortcut-purple {
color: #6254d6;
background: #eeeaff;
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
@@ -1280,60 +1147,27 @@ h2 {
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.app-sidebar,
.app-topbar { .app-topbar {
position: static; grid-template-columns: minmax(0, 1fr) auto;
top: auto;
right: auto;
bottom: auto;
left: auto;
width: auto;
inset: auto;
}
.app-sidebar {
min-height: auto;
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-direction: column;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 10px; gap: 10px;
padding: 14px 16px; padding: 0 14px;
}
.breadcrumb {
display: none;
} }
.topbar-actions { .topbar-actions {
justify-content: space-between; justify-content: flex-end;
} }
.user-summary { .user-summary {
max-width: none; max-width: 180px;
} }
.user-pill, .user-pill,
.role-label { .role-label {
max-width: none; max-width: 140px;
}
body:not(.auth-page) .page-shell {
width: min(1280px, calc(100% - 24px));
margin: 0 auto;
padding: 18px 0 40px;
} }
.global-search { .global-search {