feat(shelf): 新增书架分页展示与排序选择

This commit is contained in:
admin committed 2026-09-21 22:25:11 +08:00
1 parent bbed5a5b98
commit 40b00a8b40
7 files changed
+92 -8

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ DEPLOY_BRANCH=master ./deploy.sh
| 偏好分类 | 阅读分类占比饼图 | | 偏好分类 | 阅读分类占比饼图 |
| 偏好作者 | 阅读最多的作者排行 | | 偏好作者 | 阅读最多的作者排行 |
| 读书排行 | 阅读时长最久的书籍 TOP 20 | | 读书排行 | 阅读时长最久的书籍 TOP 20 |
| 书架 | 全部书架书籍展示,支持分类筛选和已读完筛选 | | 书架 | 每页 48 本,底部页码分页,支持按最近阅读、书名或作者排序 |
| 笔记概览 | 有笔记的书籍及划线/想法/书签统计 | | 笔记概览 | 有笔记的书籍及划线/想法/书签统计 |
## 数据库表结构 ## 数据库表结构
+3 -1
View File
@@ -7,7 +7,7 @@
| 数据 | 来源 | 说明 | | 数据 | 来源 | 说明 |
|------|------|------| |------|------|------|
| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 | | 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间和置顶标记 |
| 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 | | 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 |
| 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 | | 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 |
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 | | 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
@@ -17,6 +17,8 @@
MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。 MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。
当前书架页一次读取书架电子书,在前端按状态筛选后每页显示 48 本。排序选项为最近阅读(默认)、书名升序、书名降序和作者升序;置顶书籍始终排在前面。接口仍支持 `limit` / `offset` 参数供其他调用者分页,书架页使用全量结果以供书架、阅读目标和发现功能共享。
## 接口清单 ## 接口清单
| 方法 | 路径 | 参数 | 说明 | | 方法 | 路径 | 参数 | 说明 |
+8
View File
@@ -81,6 +81,14 @@ GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。
目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。 目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。
## 正式看板书架排序与分页
正式看板从 `/api/reading/shelf` 读取电子书列表。前端按当前书架状态筛选后排序,每页展示 48 本;用户切换状态或排序时回到第 1 页,点击底部页码时只替换当前书籍网格。排序框右对齐显示在页码列表上方。
排序默认按最近阅读时间倒序;还可按书名升序、书名降序或作者升序。四种方式都先将 `is_top` 置顶书籍排在前面。接口把微信读书的 `isTop` 映射为 `is_top`,前端排序只使用筛选结果的副本,避免改变其他页面共享的书架顺序。
例:筛选“全部电子书”共 101 本,选择“书名升序”并打开第 2 页。页面只显示排序后第 49 至 96 本,在网格下方显示第 1、2、3 页;切换到“最近阅读”后回到第 1 页,并按最近阅读日期倒序显示前 48 本,置顶书籍仍排在该页最前面。
## 正式看板的阅读目标持久化 ## 正式看板的阅读目标持久化
正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `reading_api.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。 正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `reading_api.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。
+2
View File
@@ -171,6 +171,8 @@ node --check static/js/weapi.js
本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。 本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。
当前书架电子书列表每页显示 48 本,页码导航位于列表下方;翻页会替换当前页内容。右侧排序框提供最近阅读、书名升序、书名降序和作者升序,置顶书籍始终优先;切换书架状态或排序会回到第 1 页。此功能使用已加载的书架数据,不需要数据库迁移。
## master 自动部署 ## master 自动部署
`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。 `master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。
+3
View File
@@ -364,6 +364,7 @@ def make_book(
author=None, author=None,
category=None, category=None,
finish=0, finish=0,
is_top=0,
read_update_time=0, read_update_time=0,
cover_url=None, cover_url=None,
deep_link=None, deep_link=None,
@@ -401,6 +402,7 @@ def make_book(
"read_time_str": duration_str(read_time) if read_time else "", "read_time_str": duration_str(read_time) if read_time else "",
"read_update_time": ts_to_date(read_update_time), "read_update_time": ts_to_date(read_update_time),
"finish_reading": finish, "finish_reading": finish,
"is_top": int(is_top or 0),
"deep_link": deep_link or "", "deep_link": deep_link or "",
} }
@@ -589,6 +591,7 @@ def shelf_books(conn, with_progress=False, progress_limit=30):
row.get("author"), row.get("author"),
row.get("category"), row.get("category"),
finish=row.get("finishReading"), finish=row.get("finishReading"),
is_top=row.get("isTop"),
read_update_time=row.get("readUpdateTime"), read_update_time=row.get("readUpdateTime"),
cover_url=row.get("cover"), cover_url=row.get("cover"),
deep_link=row.get("deepLink"), deep_link=row.get("deepLink"),
+12
View File
@@ -92,3 +92,15 @@
.stack-more{margin-left:8px;white-space:nowrap} .stack-more{margin-left:8px;white-space:nowrap}
} }
.login-page{min-height:100vh;display:grid;place-items:center;padding:24px}.login-shell{width:min(100%,420px)}.login-logo{justify-content:center;margin-bottom:26px}.login-logo .logo-mark{border-radius:9px}.login-logo svg{width:24px;height:24px}.login-panel{padding:30px}.login-panel h1{font-size:23px;margin:0 0 8px}.login-panel>p{margin-bottom:23px}.login-form{display:grid;gap:16px;margin-top:22px}.login-form label{display:grid;gap:7px;font-size:12px;color:var(--text)}.login-form input{width:100%;min-height:42px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);padding:10px 12px}.login-form input:focus{border-color:var(--accent)}.login-form .primary{width:100%;min-height:42px;margin-top:4px}.login-error{margin-top:16px;padding:10px 12px;border:1px solid #e6b9b5;border-radius:7px;background:#fff5f4;color:#a8443c;font-size:12px;line-height:1.6}.login-foot{text-align:center;margin-top:20px}.top-actions form{margin:0}.logout-button{white-space:nowrap}@media(max-width:600px){.login-page{padding:18px}.login-panel{padding:24px}.top-actions{gap:9px}.logout-button{font-size:10px}} .login-page{min-height:100vh;display:grid;place-items:center;padding:24px}.login-shell{width:min(100%,420px)}.login-logo{justify-content:center;margin-bottom:26px}.login-logo .logo-mark{border-radius:9px}.login-logo svg{width:24px;height:24px}.login-panel{padding:30px}.login-panel h1{font-size:23px;margin:0 0 8px}.login-panel>p{margin-bottom:23px}.login-form{display:grid;gap:16px;margin-top:22px}.login-form label{display:grid;gap:7px;font-size:12px;color:var(--text)}.login-form input{width:100%;min-height:42px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);padding:10px 12px}.login-form input:focus{border-color:var(--accent)}.login-form .primary{width:100%;min-height:42px;margin-top:4px}.login-error{margin-top:16px;padding:10px 12px;border:1px solid #e6b9b5;border-radius:7px;background:#fff5f4;color:#a8443c;font-size:12px;line-height:1.6}.login-foot{text-align:center;margin-top:20px}.top-actions form{margin:0}.logout-button{white-space:nowrap}@media(max-width:600px){.login-page{padding:18px}.login-panel{padding:24px}.top-actions{gap:9px}.logout-button{font-size:10px}}
/* 书架分页与排序 */
.shelf-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px;flex-wrap:wrap}
.shelf-sort-control{display:flex;align-items:center;gap:8px;margin-left:auto;color:var(--muted);font-size:11px}
.shelf-sort-control select{min-width:140px}
.shelf-pagination{display:flex;justify-content:center;align-items:center;gap:6px;margin-top:18px;flex-wrap:wrap}
.shelf-pagination .page-btn{min-width:36px;padding:8px 10px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--text);font:inherit;font-size:11px;cursor:pointer}
.shelf-pagination .page-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
.shelf-pagination .page-btn:disabled{opacity:.45;cursor:not-allowed}
.shelf-pagination .page-btn.active{border-color:var(--accent);background:var(--accent);color:#fff}
@media(max-width:600px){.shelf-toolbar{align-items:flex-start}.shelf-pagination{gap:4px}.shelf-pagination .page-btn{min-width:32px;padding:7px 8px}}
+63 -6
View File
@@ -24,7 +24,7 @@ let searchComposing = false, discoverSearchTimer = null;
let recommendOffset = 0; let recommendOffset = 0;
let noteBook = 'all', noteType = 'all', goalFilter = 'all'; let noteBook = 'all', noteType = 'all', goalFilter = 'all';
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0; let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30; let shelfPage = 1, shelfSort = 'recent', chapterLimit = 30, searchScope = 'store', notesLimit = 30;
let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = []; let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '', goalBusy = false; let toastTimer, storageWarning = '', goalBusy = false;
@@ -332,6 +332,50 @@ function renderLists(lists) {
return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单。</div>'}</div>`; return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单。</div>'}</div>`;
} }
function compareShelfBooks(a, b) {
const pinOrder = Number(b.is_top || 0) - Number(a.is_top || 0);
if (pinOrder) return pinOrder;
const titleOrder = String(a.title || '').localeCompare(String(b.title || ''), 'zh-CN');
const authorOrder = String(a.author || '').localeCompare(String(b.author || ''), 'zh-CN');
const idOrder = String(a.id || '').localeCompare(String(b.id || ''));
if (shelfSort === 'title_asc') return titleOrder || idOrder;
if (shelfSort === 'title_desc') return -titleOrder || idOrder;
if (shelfSort === 'author_asc') return authorOrder || titleOrder || idOrder;
const dateA = String(a.read_update_time || '');
const dateB = String(b.read_update_time || '');
if (dateA !== dateB) {
if (!dateA) return 1;
if (!dateB) return -1;
return dateB.localeCompare(dateA);
}
return titleOrder || idOrder;
}
function renderShelfPagination(current, total) {
if (total <= 1) return '';
const maxButtons = 7;
let start = Math.max(1, current - 3);
let end = Math.min(total, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
let html = `<nav class="shelf-pagination" aria-label="书架分页">`;
html += `<button type="button" class="page-btn" ${current <= 1 ? 'disabled' : ''} data-action="shelf-page" data-id="${current - 1}">上一页</button>`;
if (start > 1) {
html += `<button type="button" class="page-btn" data-action="shelf-page" data-id="1">1</button>`;
if (start > 2) html += '<span class="page-info" aria-hidden="true">…</span>';
}
for (let i = start; i <= end; i++) {
html += `<button type="button" class="page-btn ${i === current ? 'active' : ''}" ${i === current ? 'aria-current="page"' : ''} data-action="shelf-page" data-id="${i}">${i}</button>`;
}
if (end < total) {
if (end < total - 1) html += '<span class="page-info" aria-hidden="true">…</span>';
html += `<button type="button" class="page-btn" data-action="shelf-page" data-id="${total}">${total}</button>`;
}
html += `<button type="button" class="page-btn" ${current >= total ? 'disabled' : ''} data-action="shelf-page" data-id="${current + 1}">下一页</button></nav>`;
return html;
}
function renderShelf() { function renderShelf() {
if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf'); if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf');
if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); } if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); }
@@ -348,8 +392,20 @@ function renderShelf() {
} else if (shelfTab === 'articles') { } else if (shelfTab === 'articles') {
content = `<article class="card media-card"><div class="media-icon">▤</div><div><h3>${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}</h3><p>这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。</p><p class="hint">书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。</p><button class="text-button" data-action="article-search">去发现页搜索 →</button></div></article>`; content = `<article class="card media-card"><div class="media-icon">▤</div><div><h3>${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}</h3><p>这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。</p><p class="hint">书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。</p><button class="text-button" data-action="article-search">去发现页搜索 →</button></div></article>`;
} else { } else {
const shown = filtered.slice(0, shelfLimit); const sorted = [...filtered].sort(compareShelfBooks);
content = `<div class="card panel"><div class="books full-books">${shown.map(bookCard).join('') || '<div class="empty">这里还没有对应的书。</div>'}</div>${filtered.length > shown.length ? `<button class="text-button section-space" data-action="shelf-more">+ 继续加载(还有 ${filtered.length - shown.length} 本)</button>` : ''}</div>`; const totalPages = Math.ceil(sorted.length / SHELF_PAGE);
const currentPage = totalPages ? Math.min(shelfPage, totalPages) : 1;
shelfPage = currentPage;
const shown = sorted.slice((currentPage - 1) * SHELF_PAGE, currentPage * SHELF_PAGE);
const pageSummary = totalPages ? `共 ${sorted.length} 本 · 第 ${currentPage}/${totalPages} 页` : `共 ${sorted.length} 本`;
const sortOptions = [
['recent', '最近阅读'],
['title_asc', '书名升序'],
['title_desc', '书名降序'],
['author_asc', '作者升序'],
];
const sortControl = sortOptions.map(([value, label]) => `<option value="${value}" ${shelfSort === value ? 'selected' : ''}>${label}</option>`).join('');
content = `<div class="shelf-toolbar"><span class="hint">${pageSummary}</span><label class="shelf-sort-control" for="shelf-sort">排序 <select id="shelf-sort" aria-label="书架排序">${sortControl}</select></label></div><div class="card panel"><div class="books full-books">${shown.map(bookCard).join('') || '<div class="empty">这里还没有对应的书。</div>'}</div></div>${renderShelfPagination(currentPage, totalPages)}`;
} }
const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`; const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`;
@@ -600,7 +656,7 @@ function renderDiscover() {
} else { } else {
const list = payload.books || []; const list = payload.books || [];
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase())); const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
content = `<div class="card panel"><div class="books full-books">${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`; content = `<div class="card panel"><div class="books full-books">${(payload.local ? local.slice(0, SHELF_PAGE) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
} }
return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') + return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') +
@@ -709,8 +765,8 @@ function handleAction(button) {
case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; } case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; }
case 'append-goal': appendGoal(id); break; case 'append-goal': appendGoal(id); break;
case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break; case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break;
case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break; case 'shelf-tab': shelfTab = id; shelfPage = 1; renderPage(); break;
case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break; case 'shelf-page': shelfPage = Number(id); renderPage(); break;
case 'period': period = id; renderPage(); break; case 'period': period = id; renderPage(); break;
case 'discover-tab': discoverTab = id; renderPage(); break; case 'discover-tab': discoverTab = id; renderPage(); break;
case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break; case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break;
@@ -758,6 +814,7 @@ document.addEventListener('change', event => {
selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail(); selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail();
if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message));
} }
if (event.target.id === 'shelf-sort') { shelfSort = event.target.value; shelfPage = 1; renderPage(); }
if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); } if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); }
}); });
$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; }; $('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; };