From e578a90543ec351a7dcf1e9c930e8e6b2f6bd5f5 Mon Sep 17 00:00:00 2001 From: kinghwj Date: Fri, 17 Jul 2026 12:21:36 +0800 Subject: [PATCH] feat: collapsible hierarchical category tags + category sort for shelf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 46 +++++++++++-- static/css/style.css | 148 ++++++++++++++++++++++++++++++++++++++-- static/js/app.js | 158 +++++++++++++++++++++++++++++++++++-------- templates/index.html | 1 + 4 files changed, 312 insertions(+), 41 deletions(-) diff --git a/app.py b/app.py index 1caa29a..a62360a 100644 --- a/app.py +++ b/app.py @@ -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, }) diff --git a/static/css/style.css b/static/css/style.css index 9954f36..91a1462 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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; diff --git a/static/js/app.js b/static/js/app.js index c3f6aa9..b670a03 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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 = ``; - html += shelfCategories - .map((cat) => ``) - .join(""); + let html = ""; + + // "全部" 按钮 + const allActive = !shelfActiveParent && !shelfActiveCategory; + html += ``; + + // 父分类按钮(按书籍数量降序) + 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 + ? `${isExpanded ? "▼" : "▶"}` + : ""; + + html += ``; + + // 展开的子分类 + if (isExpanded && Object.keys(info.subs).length > 0) { + html += `
`; + 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 += ``; + } + html += `
`; + } + } + 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(`置顶`); - if (book.finish_reading) badges.push(`已读完`); - return ` -
-
- ${book.title} - ${badges.join("")} -
-
${book.title}
-
${book.author}
-
`; - }) - .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(`置顶`); + if (book.finish_reading) badges.push(`已读完`); + return ` +
+
+ ${book.title} + ${badges.join("")} +
+
${book.title}
+
${book.author}
+
`; +} + +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 += `
${cat} ${items.length} 本
`; + html += `
${items.map(renderBookCard).join("")}
`; + } + return html; +} + function renderPagination(current, total) { const pag = document.getElementById("pagination"); if (total <= 1) { pag.innerHTML = ""; return; } diff --git a/templates/index.html b/templates/index.html index 4453921..1d49f26 100644 --- a/templates/index.html +++ b/templates/index.html @@ -125,6 +125,7 @@ +