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
+300 -29

No files matched your search

+39 -7
View File
@@ -504,17 +504,21 @@ def api_shelf(conn):
page (int): 页码,默认 1
per_page (int): 每页数量,默认 60
keyword (str): 书名或作者搜索(模糊匹配)
category (str): 分类筛选(精确匹配分类名)
category (str): 分类筛选(精确匹配完整分类名,如 "历史-中国古代")
parent_category (str): 父分类筛选(如 "历史" 匹配所有 "历史-*" 子分类)
finished (str): "1" 表示只看已读完
sort (str): "category" 表示按分类排序,其他默认按置顶+阅读时间
Returns:
JSON: {books: [...], total, page, per_page, total_pages, categories: [...]}
JSON: {books: [...], total, page, per_page, total_pages, categories: [...], category_tree: {...}}
"""
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 60))
keyword = request.args.get("keyword", "").strip()
category = request.args.get("category", "").strip()
parent_category = request.args.get("parent_category", "").strip()
finished_only = request.args.get("finished", "0") == "1"
sort_mode = request.args.get("sort", "").strip()
offset = (page - 1) * per_page
cursor = conn.cursor()
@@ -528,6 +532,9 @@ def api_shelf(conn):
if category:
where_clauses.append("category = %s")
params.append(category)
if parent_category:
where_clauses.append("(category = %s OR category LIKE %s)")
params.extend([parent_category, f"{parent_category}-%"])
if finished_only:
where_clauses.append("finish_reading=1")
@@ -537,16 +544,40 @@ def api_shelf(conn):
cursor.execute(f"SELECT COUNT(*) AS cnt FROM books{where_sql}", params)
total = cursor.fetchone()["cnt"]
# 查询当前页数据(置顶优先,最近阅读时间倒序)
# 排序
if sort_mode == "category":
order_sql = " ORDER BY category ASC, is_top DESC, read_update_time DESC"
else:
order_sql = " ORDER BY is_top DESC, read_update_time DESC"
# 查询当前页数据
cursor.execute(
f"SELECT * FROM books{where_sql} ORDER BY is_top DESC, read_update_time DESC LIMIT %s OFFSET %s",
f"SELECT * FROM books{where_sql}{order_sql} LIMIT %s OFFSET %s",
params + [per_page, offset],
)
rows = cursor.fetchall()
# 获取所有分类列表(用于前端筛选下拉框)
cursor.execute("SELECT DISTINCT category FROM books WHERE category != '' ORDER BY category")
categories = [r["category"] for r in cursor.fetchall()]
# 获取所有分类列表及层级树
cursor.execute(
"SELECT category, COUNT(*) AS cnt FROM books "
"WHERE category IS NOT NULL AND category != '' "
"GROUP BY category ORDER BY category"
)
cat_rows = cursor.fetchall()
# 构建层级树: { "历史": {"count": 114, "subs": {"世界史": 5, "中国古代": 10, ...}}, ... }
category_tree = {}
for r in cat_rows:
parts = r["category"].split("-", 1)
parent = parts[0]
sub = parts[1] if len(parts) > 1 else ""
if parent not in category_tree:
category_tree[parent] = {"count": 0, "subs": {}}
category_tree[parent]["count"] += r["cnt"]
if sub:
category_tree[parent]["subs"][sub] = r["cnt"]
categories = [r["category"] for r in cat_rows]
books = []
for row in rows:
@@ -570,6 +601,7 @@ def api_shelf(conn):
"per_page": per_page,
"total_pages": (total + per_page - 1) // per_page,
"categories": categories,
"category_tree": category_tree,
})
+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;
+117 -17
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,8 +697,22 @@ async function loadShelf(page) {
document.getElementById("shelfInfo").textContent =
`共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`;
if (shelfSortMode === "category" && data.books.length > 0) {
// 按分类分组渲染
grid.innerHTML = renderBooksGroupedByCategory(data.books);
} else {
grid.innerHTML = data.books
.map((book) => {
.map((book) => renderBookCard(book))
.join("");
}
renderPagination(page, data.total_pages);
} catch (e) {
console.error("加载书架失败:", e);
}
}
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>`);
@@ -637,13 +727,23 @@ async function loadShelf(page) {
<div class="book-title">${book.title}</div>
<div class="book-author">${book.author}</div>
</div>`;
})
.join("");
}
renderPagination(page, data.total_pages);
} catch (e) {
console.error("加载书架失败:", e);
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) {
+1
View File
@@ -125,6 +125,7 @@
<label class="filter-checkbox">
<input type="checkbox" id="finishedOnly"> 仅看已读完
</label>
<button class="shelf-sort-btn" id="shelfSortBtn" onclick="toggleShelfSort()">分类排序</button>
</div>
<div class="shelf-info" id="shelfInfo"></div>
</div>