mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
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:
1 parent
ec88707e6b
commit
e578a90543
4 files changed
+312
-41
No files matched your search
+143
-5
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user