feat: collapsible hierarchical category tags + category sort for shelf

- Backend: add parent_category param (matches '历史-*' sub-categories),
  sort=category mode (ORDER BY category ASC), return category_tree with
  parent→sub→count hierarchy
- Frontend: replace flat 63-tag list with collapsible parent tags showing
  book counts, click parent to expand sub-category pills with fade-in
  animation, click sub to filter to exact category
- Add '分类排序' toggle button: when active, books are grouped by
  category with colored section headers showing category name + count
- Clicking same parent again collapses it; '全部' resets everything
- Mobile responsive: smaller tag sizes, reduced indent on narrow screens
This commit is contained in:
admin committed 2026-07-17 12:21:36 +08:00
1 parent ec88707e6b
commit e578a90543
4 files changed
+312 -41

No files matched your search

+143 -5
View File
@@ -336,14 +336,119 @@ body {
}
/* ===== 书架分类标签 ===== */
/* 层级折叠分类标签 */
.shelf-categories {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
align-items: center;
}
.shelf-category-tag {
.shelf-cat-parent {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 5px;
}
.shelf-cat-parent:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-bg);
}
.shelf-cat-parent.active {
border-color: var(--accent);
color: #fff;
background: var(--accent);
font-weight: 500;
}
.shelf-cat-count {
font-size: 11px;
padding: 1px 7px;
border-radius: 10px;
background: var(--accent-bg);
color: var(--accent-dark);
}
.shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25);
color: #fff;
}
.shelf-cat-arrow {
font-size: 9px;
margin-left: 2px;
transition: transform 0.2s;
}
.shelf-cat-subs {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
padding: 4px 0 4px 20px;
margin-bottom: 4px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.shelf-cat-sub {
padding: 3px 12px;
border: 1px solid var(--border);
border-radius: 16px;
font-size: 12px;
color: var(--text-light);
background: var(--bg-primary);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.shelf-cat-sub:hover {
border-color: var(--accent);
color: var(--accent);
}
.shelf-cat-sub.active {
border-color: var(--accent);
color: #fff;
background: var(--accent);
font-weight: 500;
}
.shelf-cat-count-sm {
font-size: 10px;
padding: 0 5px;
border-radius: 8px;
background: var(--accent-bg);
color: var(--accent-dark);
}
.shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25);
color: #fff;
}
/* 分类排序按钮 */
.shelf-sort-btn {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 20px;
@@ -355,19 +460,45 @@ body {
white-space: nowrap;
}
.shelf-category-tag:hover {
.shelf-sort-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-bg);
}
.shelf-category-tag.active {
.shelf-sort-btn.active {
border-color: var(--accent);
color: #fff;
background: var(--accent);
font-weight: 500;
}
/* 分类分组标题 */
.shelf-group-header {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin: 24px 0 12px;
padding: 8px 16px;
border-radius: var(--radius-sm);
background: var(--accent-bg);
border-left: 4px solid var(--accent);
}
.shelf-group-header:first-child {
margin-top: 0;
}
.shelf-group-count {
font-size: 12px;
font-weight: 400;
color: var(--text-light);
margin-left: 8px;
}
.shelf-group-header + .book-grid {
margin-bottom: 8px;
}
/* ===== Hero 区 ===== */
.hero-section {
position: relative;
@@ -1906,10 +2037,17 @@ body {
.shelf-categories {
gap: 6px;
}
.shelf-category-tag {
.shelf-cat-parent {
font-size: 12px;
padding: 4px 10px;
}
.shelf-cat-sub {
font-size: 11px;
padding: 3px 9px;
}
.shelf-cat-subs {
padding-left: 10px;
}
.back-to-top {
bottom: 20px;
right: 20px;
+129 -29
View File
@@ -222,15 +222,18 @@ function globalSearch() {
doSearch();
}
// ===== 书架分类标签 =====
// ===== 书架分类标签(可折叠层级结构) =====
let shelfCategories = [];
let shelfActiveCategory = ""; // 空字符串表示"全部"
let shelfCategoryTree = {}; // { "历史": {count:114, subs:{"世界史":5,...}}, ... }
let shelfActiveCategory = ""; // 完整分类名,如 "历史-中国古代"
let shelfActiveParent = ""; // 父分类名,如 "历史";空表示"全部"
let shelfExpandedParents = new Set(); // 当前展开的父分类
let shelfSortMode = "default"; // "default" 或 "category"
async function loadShelfCategories() {
try {
const data = await fetchAPI("/api/shelf?page=1&per_page=1");
shelfCategories = data.categories || [];
shelfCategoryTree = data.category_tree || {};
renderShelfCategories();
} catch (e) {
console.error("加载分类失败:", e);
@@ -241,19 +244,87 @@ function renderShelfCategories() {
const container = document.getElementById("shelfCategories");
if (!container) return;
let html = `<button class="shelf-category-tag ${shelfActiveCategory === "" ? "active" : ""}" onclick="filterByCategory('')">全部</button>`;
html += shelfCategories
.map((cat) => `<button class="shelf-category-tag ${shelfActiveCategory === cat ? "active" : ""}" onclick="filterByCategory('${cat.replace(/'/g, "\\'")}')">${cat}</button>`)
.join("");
let html = "";
// "全部" 按钮
const allActive = !shelfActiveParent && !shelfActiveCategory;
html += `<button class="shelf-cat-parent ${allActive ? "active" : ""}" onclick="filterByParent('')">全部</button>`;
// 父分类按钮(按书籍数量降序)
const parents = Object.entries(shelfCategoryTree)
.sort((a, b) => b[1].count - a[1].count);
for (const [parent, info] of parents) {
const isActive = shelfActiveParent === parent && !shelfActiveCategory;
const isExpanded = shelfExpandedParents.has(parent);
const arrow = Object.keys(info.subs).length > 0
? `<span class="shelf-cat-arrow ${isExpanded ? "expanded" : ""}">${isExpanded ? "▼" : "▶"}</span>`
: "";
html += `<button class="shelf-cat-parent ${isActive ? "active" : ""}"
onclick="filterByParent('${parent.replace(/'/g, "\\'")}')">${parent}
<span class="shelf-cat-count">${info.count}</span>${arrow}
</button>`;
// 展开的子分类
if (isExpanded && Object.keys(info.subs).length > 0) {
html += `<div class="shelf-cat-subs">`;
for (const [sub, count] of Object.entries(info.subs).sort((a, b) => b[1] - a[1])) {
const fullCat = `${parent}-${sub}`;
const subActive = shelfActiveCategory === fullCat;
html += `<button class="shelf-cat-sub ${subActive ? "active" : ""}"
onclick="event.stopPropagation();filterByCategory('${fullCat.replace(/'/g, "\\'")}')">${sub}
<span class="shelf-cat-count-sm">${count}</span>
</button>`;
}
html += `</div>`;
}
}
container.innerHTML = html;
}
function filterByCategory(category) {
shelfActiveCategory = category;
function filterByParent(parent) {
if (parent) {
// 点击父分类:展开/折叠子分类,同时筛选该父分类下的所有书
if (shelfExpandedParents.has(parent) && shelfActiveParent === parent && !shelfActiveCategory) {
// 已展开且已选中 → 折叠
shelfExpandedParents.delete(parent);
shelfActiveParent = "";
shelfActiveCategory = "";
} else {
// 展开 + 筛选
shelfExpandedParents.add(parent);
shelfActiveParent = parent;
shelfActiveCategory = "";
}
} else {
// 点击"全部"
shelfExpandedParents.clear();
shelfActiveParent = "";
shelfActiveCategory = "";
}
renderShelfCategories();
loadShelf(1);
}
function filterByCategory(fullCategory) {
shelfActiveCategory = fullCategory;
// 确保父分类已展开
const parent = fullCategory.split("-")[0];
shelfExpandedParents.add(parent);
shelfActiveParent = parent;
renderShelfCategories();
loadShelf(1);
}
function toggleShelfSort() {
shelfSortMode = shelfSortMode === "category" ? "default" : "category";
const btn = document.getElementById("shelfSortBtn");
btn.classList.toggle("active", shelfSortMode === "category");
loadShelf(1);
}
// ===== 初始化 =====
document.addEventListener("DOMContentLoaded", async () => {
@@ -612,7 +683,12 @@ async function loadShelf(page) {
const finished = document.getElementById("finishedOnly").checked ? "1" : "0";
const params = new URLSearchParams({ page: page, per_page: 60, finished: finished });
if (keyword) params.set("keyword", keyword);
if (shelfActiveCategory) params.set("category", shelfActiveCategory);
if (shelfActiveCategory) {
params.set("category", shelfActiveCategory);
} else if (shelfActiveParent) {
params.set("parent_category", shelfActiveParent);
}
if (shelfSortMode === "category") params.set("sort", "category");
try {
const data = await fetchAPI(`/api/shelf?${params}`);
@@ -621,24 +697,14 @@ async function loadShelf(page) {
document.getElementById("shelfInfo").textContent =
`共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`;
grid.innerHTML = data.books
.map((book) => {
const badges = [];
if (book.is_top) badges.push(`<span class="book-top-badge">置顶</span>`);
if (book.finish_reading) badges.push(`<span class="book-finished-badge">已读完</span>`);
return `
<div class="book-card" onclick="openBook('${book.book_id}')">
<div class="book-cover-wrapper">
<img class="book-cover-img" src="${hdCover(book.cover)}" alt="${book.title}"
loading="lazy"
onerror="this.onerror=null;this.src='${book.cover}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22160%22%3E%3Crect fill=%22%23e8e0d4%22 width=%22120%22 height=%22160%22/%3E%3Ctext x=%2260%22 y=%2280%22 text-anchor=%22middle%22 fill=%22%239b8e80%22 font-size=%2212%22%3E无封面%3C/text%3E%3C/svg%3E'}">
${badges.join("")}
</div>
<div class="book-title">${book.title}</div>
<div class="book-author">${book.author}</div>
</div>`;
})
.join("");
if (shelfSortMode === "category" && data.books.length > 0) {
// 按分类分组渲染
grid.innerHTML = renderBooksGroupedByCategory(data.books);
} else {
grid.innerHTML = data.books
.map((book) => renderBookCard(book))
.join("");
}
renderPagination(page, data.total_pages);
} catch (e) {
@@ -646,6 +712,40 @@ async function loadShelf(page) {
}
}
function renderBookCard(book) {
const badges = [];
if (book.is_top) badges.push(`<span class="book-top-badge">置顶</span>`);
if (book.finish_reading) badges.push(`<span class="book-finished-badge">已读完</span>`);
return `
<div class="book-card" onclick="openBook('${book.book_id}')">
<div class="book-cover-wrapper">
<img class="book-cover-img" src="${hdCover(book.cover)}" alt="${book.title}"
loading="lazy"
onerror="this.onerror=null;this.src='${book.cover}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22160%22%3E%3Crect fill=%22%23e8e0d4%22 width=%22120%22 height=%22160%22/%3E%3Ctext x=%2260%22 y=%2280%22 text-anchor=%22middle%22 fill=%22%239b8e80%22 font-size=%2212%22%3E无封面%3C/text%3E%3C/svg%3E'}">
${badges.join("")}
</div>
<div class="book-title">${book.title}</div>
<div class="book-author">${book.author}</div>
</div>`;
}
function renderBooksGroupedByCategory(books) {
// 按 category 分组
const groups = {};
books.forEach((book) => {
const cat = book.category || "未分类";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(book);
});
let html = "";
for (const [cat, items] of Object.entries(groups)) {
html += `<div class="shelf-group-header">${cat} <span class="shelf-group-count">${items.length} 本</span></div>`;
html += `<div class="book-grid">${items.map(renderBookCard).join("")}</div>`;
}
return html;
}
function renderPagination(current, total) {
const pag = document.getElementById("pagination");
if (total <= 1) { pag.innerHTML = ""; return; }