Files
WeReadBoard/static/js/app.js
T

875 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
阅间 · 微信读书看板 —— 交互逻辑
--------------------------------------------------------------------------
视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。
差异只在于数据来源:设计稿读取 demo-data.js 的静态示例,
这里改为调用 /api/reading/* 实时接口。
========================================================================== */
const $ = selector => document.querySelector(selector);
const esc = value =>
String(value == null ? '' : value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-goals';
const legacyStorageKeys = [storageKey, 'weread-goals-' + 'v' + '2'];
const PICKER_LIMIT = 60;
const SHELF_PAGE = 48;
/* ------------------------------- 运行时状态 ------------------------------ */
let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week';
let searchComposing = false, discoverSearchTimer = null;
let recommendOffset = 0;
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
let shelfPage = 1, shelfSort = 'recent', chapterLimit = 30, searchScope = 'store', notesLimit = 30;
let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '', goalBusy = false;
/* 接口数据缓存 */
let books = [];
let booklists = [];
let shelfData = null;
let notebooksData = null;
let statusData = null;
let dashboardData = {};
let notesData = {};
let bookData = {};
let discoverData = {};
let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存 */
/* 加载状态:idle | loading | ready | error */
const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
let goals = [], legacyGoals = [], legacyGoalStorageKey = '';
try {
for (const key of legacyStorageKeys) {
const saved = localStorage.getItem(key);
if (!saved || localStorage.getItem(`${key}-imported`)) continue;
const parsed = JSON.parse(saved);
if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid');
legacyGoals = parsed;
legacyGoalStorageKey = key;
break;
}
} catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; }
/* ------------------------------- 通用组件 ------------------------------- */
function toast(message) {
clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden');
toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500);
}
function statusBox(kind, title, text, action) {
const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO';
const button = action ? `<button class="secondary" data-action="${action}">${kind === 'error' ? '重试' : '继续'}</button>` : '';
return `<div class="card status-box fade"><div class="eyebrow">${eyebrow}</div><h3>${esc(title)}</h3><p>${esc(text || '')}</p>${button}</div>`;
}
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
$('#theme').innerHTML = `<svg><use href="#${theme === 'dark' ? 'sun' : 'moon'}"/></svg>`;
$('#theme').setAttribute('aria-label', `切换至${theme === 'dark' ? '亮' : '暗'}色主题`);
}
try { setTheme(localStorage.getItem('weread-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); }
$('#theme').onclick = () => {
const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme);
try { localStorage.setItem('weread-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); }
};
/* 封面:优先显示微信读书返回的真实封面,加载失败时回退到示意封面 */
function cover(book, mini = false) {
const short = book.short || book.title || '';
const rawCoverUrl = book.cover_url || (typeof book.cover === 'string' && /^https?:\/\//i.test(book.cover) ? book.cover : '');
const coverUrl = rawCoverUrl.replace(/\/(?:t6_|s_)/, '/t8_');
const coverClass = typeof book.cover === 'string' && !/^https?:\/\//i.test(book.cover) ? book.cover : '';
const fallback = mini
? `<div class="mini-cover ${coverClass}" aria-hidden="true"><b>${esc(book.title)}</b><small>${esc(book.author)}</small></div>`
: `<div class="cover ${coverClass}"><span class="tiny">${esc(book.en || '')}</span><span class="title">${esc(short).replaceAll('\n', '<br>')}</span><span class="author">${esc(book.author)}</span><span class="publisher">READING SPACE · 藏书</span></div>`;
if (!coverUrl) return fallback;
const className = mini ? 'real-cover mini-real-cover' : 'real-cover';
return `<img class="${className}" src="${esc(coverUrl)}" alt="${esc(book.title)}" loading="lazy" onerror='this.outerHTML=${JSON.stringify(fallback)}'>`;
}
function scoreText(book) {
return book.score ? `评分 ${(Number(book.score) * 10).toFixed(2)}%` : '暂无评分';
}
function stateText(book) {
if (book.status === 'unknown') return '状态待同步';
if (book.status === 'done') return '已读完';
if (book.status === 'wish') return '想读';
return book.progress > 0 ? `已读 ${book.progress}%` : '在读';
}
function bookCard(book) {
return `<button class="book" data-action="book" data-id="${esc(book.id)}" aria-label="查看${esc(book.title)}详情">${cover(book)}<h3>${esc(book.title)}</h3><p>${esc(book.author)}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div><div class="book-meta"><span>${stateText(book)}</span><span>${scoreText(book)}</span></div></button>`;
}
function bookRow(book, source = '') {
return `<div class="book-row">${cover(book, true)}<div class="book-row-text"><b>${esc(book.title)}</b><p>${esc(book.author)}${source ? ' · ' + esc(source) : ''}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div></div><span class="book-row-status">${esc(stateText(book))}</span><button class="text-button" data-action="book" data-id="${esc(book.id)}" aria-label="查看${esc(book.title)}详情">详情 ↗</button></div>`;
}
function welcome(title, subtitle, action = '') {
return `<section class="welcome fade"><div><div class="eyebrow">A LITTLE READING, EVERY DAY</div><h1>${esc(title)}</h1><p>${esc(subtitle)}</p></div><div class="page-actions">${action}</div></section>`;
}
const pageNames = { overview: '阅读概览', shelf: '我的书架', goals: '阅读目标', notes: '笔记与划线', discover: '发现好书' };
function navigate(next) {
page = next; $('#crumb').textContent = pageNames[page];
document.querySelectorAll('[data-page]').forEach(button => { button.classList.toggle('active', button.dataset.page === page); button.setAttribute('aria-current', button.dataset.page === page ? 'page' : 'false'); });
renderPage();
}
function renderPage() {
const views = { overview: renderOverview, shelf: renderShelf, goals: renderGoals, notes: renderNotes, discover: renderDiscover };
$('#page-content').innerHTML = views[page]();
}
function tabs(items, selected, action) {
return `<div class="type-chips">${items.map(([id, label]) => `<button class="${String(selected) === String(id) ? 'active' : ''}" aria-pressed="${String(selected) === String(id)}" data-action="${action}" data-id="${esc(id)}">${esc(label)}</button>`).join('')}</div>`;
}
/* ------------------------------- 数据加载 ------------------------------- */
async function ensureStatus() {
try {
statusData = await WeApi.status();
const counts = statusData.counts || {};
const shelfNav = document.querySelector('[data-page="shelf"] em');
if (shelfNav && counts.ebooks) shelfNav.textContent = counts.ebooks;
const conn = $('#conn-state');
if (conn) conn.textContent = statusData.ok ? '已连接微信读书 · 数据实时同步' : '微信读书接口暂不可用';
const foot = $('#footer-state');
if (foot) foot.textContent = statusData.fetch_time ? `数据快照 ${statusData.fetch_time}` : '数据来自微信读书个人账号';
} catch (error) {
const conn = $('#conn-state');
if (conn) conn.textContent = '接口连接失败:' + error.message;
}
}
async function ensureShelf(force = false) {
if (loadState.shelf === 'loading') return;
if (shelfData && !force) return;
loadState.shelf = 'loading'; loadError.shelf = '';
if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage();
try {
// 首屏只读取书架基础数据,避免等待逐本补拉进度和评分。
const data = await WeApi.shelf('all', { progress: false });
shelfData = data;
books = data.books || [];
booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` }));
loadState.shelf = 'ready';
const localDiscoverKey = `${searchScope}:books:${searchQuery}`;
if (!searchQuery) {
discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists };
loadState.discover[localDiscoverKey] = 'ready';
}
if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage();
// 后台补充评分和阅读进度,完成后静默刷新,不阻塞首屏。
WeApi.shelf('all', { progress: true }).then(enriched => {
shelfData = enriched;
books = enriched.books || books;
if (enriched.lists) {
booklists = enriched.lists.map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` }));
}
if (!searchQuery) {
discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists };
}
if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage();
}).catch(() => {
// 评分/进度补充失败时保留已展示的基础书架数据。
});
} catch (error) {
loadError.shelf = error.message; loadState.shelf = 'error';
}
if (['shelf', 'overview', 'goals'].includes(page)) renderPage();
}
async function ensureDashboard(target = period, force = false) {
if (loadState.dashboard[target] === 'loading') return;
if (dashboardData[target] && !force) return;
loadState.dashboard[target] = 'loading'; loadError.dashboard[target] = '';
if (page === 'overview' && period === target) renderPage();
try {
dashboardData[target] = await WeApi.dashboard(target);
loadState.dashboard[target] = 'ready';
} catch (error) {
loadError.dashboard[target] = error.message; loadState.dashboard[target] = 'error';
}
if (page === 'overview' && period === target) renderPage();
}
async function ensureNotebooks(force = false) {
if (loadState.notes.__list === 'loading') return;
if (notebooksData && !force) return;
loadState.notes.__list = 'loading';
try { notebooksData = await WeApi.notebooks(); loadState.notes.__list = 'ready'; }
catch (error) { loadError.notes.__list = error.message; loadState.notes.__list = 'error'; }
if (page === 'notes') renderPage();
}
async function ensureNotes(bookId, force = false) {
if (loadState.notes[bookId] === 'loading') return;
if (notesData[bookId] && !force) return;
loadState.notes[bookId] = 'loading'; loadError.notes[bookId] = '';
if (page === 'notes' && noteBook === bookId) renderPage();
try {
notesData[bookId] = await WeApi.notes(bookId, 4);
loadState.notes[bookId] = 'ready';
} catch (error) { loadError.notes[bookId] = error.message; loadState.notes[bookId] = 'error'; }
if (page === 'notes' && noteBook === bookId) renderPage();
}
async function ensureBook(bookId, force = false) {
if (!bookId) return;
if (loadState.book[bookId] === 'loading') return;
if (bookData[bookId] && !force) return;
loadState.book[bookId] = 'loading'; loadError.book[bookId] = '';
renderBookDetail();
try { bookData[bookId] = await WeApi.book(bookId); loadState.book[bookId] = 'ready'; }
catch (error) { loadError.book[bookId] = error.message; loadState.book[bookId] = 'error'; }
renderBookDetail();
}
function discoverKey() {
if (discoverTab === 'recommend') return 'recommend';
if (discoverTab === 'lists') return `${searchScope}:lists`;
return `${searchScope}:${discoverTab}:${searchQuery}`;
}
async function ensureDiscover(force = false, refresh = false) {
const key = discoverKey();
if (loadState.discover[key] === 'loading') return;
if (discoverData[key] && !force) return;
loadState.discover[key] = 'loading'; loadError.discover[key] = '';
if (page === 'discover') renderPage();
try {
if (searchScope === 'shelf') {
discoverData[key] = { local: true, books: books, authors: [], lists: [] };
} else if (discoverTab === 'recommend') {
discoverData[key] = await WeApi.recommend(recommendOffset, refresh);
} else {
discoverData[key] = await WeApi.search(searchQuery || '文学', discoverTab);
}
loadState.discover[key] = 'ready';
} catch (error) { loadError.discover[key] = error.message; loadState.discover[key] = 'error'; }
if (page === 'discover') renderPage();
}
/* ------------------------------- 阅读概览 ------------------------------- */
function periodHint(p) {
const now = new Date();
if (p === 'week') return '本周 · 周一至今';
if (p === 'month') return `${now.getFullYear()} 年 ${now.getMonth() + 1} 月`;
if (p === 'year') return `${now.getFullYear()} 年 · 按月`;
return '全部年份 · 按年';
}
function renderTrend(data) {
const values = data.trend.values || [];
const labels = data.trend.labels || [];
const unit = data.trend.unit;
const max = Math.max(10, Math.ceil(Math.max(...values, 1) / 10) * 10);
const gridlines = [max, Math.round((max * 2) / 3), Math.round(max / 3), 0]
.map(n => `<i data-label="${n}${n ? unit : ''}"></i>`).join('');
const bars = values.map((v, i) => `<div class="bar" style="--h:${(v / max) * 100}%" data-tip="${v}${unit}" role="img" aria-label="${esc(labels[i])} ${v}${unit}"><span>${esc(labels[i])}</span></div>`).join('');
const axis = period === 'year' ? '按月' : period === 'overall' ? '按年' : '按日';
return `<section class="card trend"><div class="section-head"><h2>阅读的日常 <small>每一天,都算数</small></h2><span class="hint">${axis}</span></div><div class="trend-total">${esc(data.stats[0].value)}<small>阅读与收听合计</small></div><div class="chart"><div class="gridlines">${gridlines}</div><div class="bars">${bars || '<span class="hint">该周期暂无阅读记录</span>'}</div></div></section>`;
}
function summaryGoals() {
if (loadState.goals !== 'ready') return goalLoadMessage();
return `<section class="card panel summary-goals"><div class="section-head"><h2>我的阅读目标</h2><button class="text-button" data-action="page" data-id="goals">全部 ↗</button></div>${goals.slice(0, 3).map(goal => { const m = goalMetrics(goal); return `<button class="goal-summary-row" data-action="goal-detail" data-id="${esc(goal.id)}"><strong>${esc(goal.name)}</strong><small>${m.done} / ${m.total} 本已读完 · ${m.status}</small><div class="progress" style="--progress:${m.percent}%"><i></i></div></button>`; }).join('') || '<p class="hint">还没有阅读目标,创建一份属于你的清单。</p>'}<button class="text-button section-space" data-action="new-goal">+ 新建阅读目标</button></section>`;
}
function renderOverview() {
const data = dashboardData[period];
if (!data && loadError.dashboard[period]) return statusBox('error', '暂时无法获取阅读统计', loadError.dashboard[period], 'retry-overview');
if (!data) { ensureDashboard(period); return statusBox('loading', '正在读取阅读统计…', '数据来自你的微信读书账号。'); }
const reading = books.filter(book => book.status === 'reading').slice(0, 6);
const stats = data.stats.map(s => `<article class="card stat"><div class="stat-label">${esc(s.label)}<svg><use href="#${s.icon}"/></svg></div><div class="stat-value compact-value">${s.value}</div><div class="stat-foot">${esc(s.hint)}</div></article>`).join('');
const topBooks = data.top_books.length
? data.top_books.map((b, i) => `<div class="insight-line"><b>${i + 1}. ${esc(b.title)}</b><span>${esc(b.read_time_str)}</span></div>`).join('')
: '<p class="hint">该周期还没有阅读时长记录。</p>';
const cats = data.categories.items.length
? data.categories.items.map(c => `<div class="insight-line"><b>${esc(c.title)}</b><span>相对偏好 ${c.val}</span></div><div class="progress" style="--progress:${c.val}%"><i></i></div>`).join('')
: '<p class="hint">接口未返回该周期的偏好分类。</p>';
const hours = data.time_distribution.values || [];
const hourMax = data.time_distribution.max || 1;
const hourBars = hours.length
? hours.map(v => `<i style="--height:${Math.round((v / hourMax) * 100)}%"></i>`).join('')
: '<span class="hint">接口未返回时段分布</span>';
const peak = data.time_distribution.peak_hour;
const authorLines = data.authors.length
? data.authors.map(a => `<div class="insight-line"><b>${esc(a.name)}</b><span>${a.count} 本${a.read_time ? ' · ' + esc(a.read_time) : ''}</span></div>`).join('')
: '<p class="hint">接口未返回该周期的偏好作者。</p>';
const publisherLines = data.publishers.length
? data.publishers.map(p => `<div class="insight-line"><b>${esc(p.name)}</b><span>${p.count} 本</span></div>`).join('')
: '<p class="hint">接口未返回偏好出版社。</p>';
const cpLines = data.cps.length
? data.cps.map(c => `<div class="insight-line"><b>${esc(c.name)}</b><span>${c.count} 本</span></div>`).join('')
: '';
const medalLines = data.medals.length
? data.medals.map(m => `<div class="insight-line"><b>${esc(m.name)}</b><span>${esc(m.detail || m.date || '')}</span></div>`).join('')
: '<div class="insight-line"><b>勋章</b><span>接口未返回</span></div>';
return welcome('在字里行间,遇见自己', '阅读、目标与灵感,在这里慢慢积累。', '<button class="primary" data-action="new-goal">+ 新建目标</button>') +
`<div class="toolbar">${tabs([['week', '本周'], ['month', '本月'], ['year', '本年'], ['overall', '总计']], period, 'period')}<span class="hint">${esc(periodHint(period))} · 微信读书统计接口</span></div>` +
`<section class="stats">${stats}</section>` +
`<div class="columns"><div>${renderTrend(data)}<section class="card shelf"><div class="section-head"><h2>继续阅读</h2><button class="text-button" data-action="page" data-id="shelf">全部书架 ↗</button></div><div class="books">${reading.length ? reading.map(bookCard).join('') : '<div class="empty">书架里还没有在读的书。</div>'}</div></section></div><aside class="rightcol">${summaryGoals()}<section class="quote"><div class="eyebrow">✦ 留给今天的一句话</div><p>不必追赶每一页,<br>只需为自己翻开一本书。</p><small>— 阅间 · 原创阅读手记</small></section></aside></div>` +
`<div class="insight-grid"><section class="card panel insight"><h3>读得最多</h3>${topBooks}${data.top_books.length ? `<p class="hint section-space">按${esc(periodHint(period))}的阅读时长排序</p>` : ''}</section><section class="card panel insight"><h3>偏爱的阅读方向</h3>${cats}<p class="hint section-space">相对偏好权重,不是阅读占比</p></section><section class="card panel insight"><h3>留给自己的时段</h3><div class="hour-bars">${hourBars}</div><div class="insight-line"><span>06:00</span><span>18:00</span><span>次日 05:00</span></div><p class="hint">${esc(data.time_distribution.word || '时段分布')}${peak != null ? ` · 峰值 ${String(peak).padStart(2, '0')}:00` : ''}</p></section></div>` +
`<details class="insight-detail card panel"><summary>更多阅读画像 · 作者、出版社、勋章与年度回顾</summary><div class="insight-grid"><div><h3>偏好作者与出版方</h3>${authorLines}${publisherLines}${cpLines}</div><div><h3>阅读与收听</h3><p class="hint">微信读书统计接口未返回「文字阅读 / 收听」时长拆分,接入该字段后可直接展示。</p><div class="insight-line"><b>阅读总时长</b><span>${esc(data.stats[0].value)}</span></div><div class="insight-line"><b>有效阅读天数</b><span>${esc(String(data.stats[1].value).replace(/<[^>]+>/g, ''))}</span></div></div><div><h3>阅读足迹</h3><div class="insight-line"><b>好友排行</b><span>${esc(data.rank.text || '本周暂无排行')}</span></div>${medalLines}<button class="text-button" data-action="year-report">年度回顾 →</button></div></div></details>`;
}
/* ------------------------------- 我的书架 ------------------------------- */
function listCard(list, index) {
const members = (list.ids || []).map(findBook).filter(Boolean);
return `<button class="card list-card" data-action="list" data-id="${esc(list.id)}"><div class="list-art">${members.slice(0, 3).map(book => cover(book, true)).join('')}</div><h3><span class="list-index">${String(index + 1).padStart(2, '0')}</span>${esc(list.name)}</h3><p>${esc(list.description || '')}</p><small>${list.book_count || members.length} 本书 · ${esc(list.source || '我的书单')}</small></button>`;
}
function renderLists(lists) {
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() {
if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf');
if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); }
const counts = shelfData.counts || {};
const filtered = books.filter(book => shelfTab === 'all' || book.status === shelfTab);
let content;
if (shelfTab === 'lists') content = renderLists(booklists);
else if (shelfTab === 'audio') {
const albums = shelfData.albums || [];
content = albums.length
? albums.map(album => `<article class="card media-card"><div class="media-icon">♫</div><div><h3>${esc(album.title)}</h3><p>${esc(album.author || '有声专辑')} · ${album.count} 集</p><p class="hint">${esc(album.intro || '内容完结不代表已听完;当前不纳入按本阅读目标。')}</p></div></article>`).join('')
: '<div class="card empty">书架接口当前没有返回有声书 / 专辑条目。</div>';
} 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>`;
} else {
const sorted = [...filtered].sort(compareShelfBooks);
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} 个书架条目。`;
return welcome('我的书架', sub) +
tabs([['all', `全部电子书 ${counts.ebooks || 0}`], ['reading', `在读 ${counts.reading || 0}`], ['done', `已读完 ${counts.done || 0}`], ['wish', `想读 ${counts.wish || 0}`], ['lists', `我的书单 ${counts.archives || 0}`], ['audio', `有声书 ${counts.albums || 0}`], ['articles', '文章收藏']], shelfTab, 'shelf-tab') + content;
}
function openList(id) {
const list = booklists.find(l => l.id === id);
if (!list) return;
const members = (list.ids || []).map(findBook).filter(Boolean);
showDetail('书单详情', `<div class="discovery-hero"><div class="eyebrow">A COLLECTION OF GOOD BOOKS</div><h2>${esc(list.name)}</h2><p>${esc(list.description || '')}</p><p class="source-tag">${esc(list.source || '我的书单')} · ${list.book_count || members.length} 本</p></div><div class="row-list">${members.map(book => bookRow(book)).join('') || '<div class="empty">书单中的书籍当前不在书架上。</div>'}</div><div class="goal-actions"><button class="primary" data-action="add-list" data-id="${esc(id)}">加入阅读目标</button></div>`);
}
/* ------------------------------- 阅读目标 ------------------------------- */
async function ensureGoals() {
if (loadState.goals === 'loading') return;
loadState.goals = 'loading'; loadError.goals = '';
if (['goals', 'overview'].includes(page)) renderPage();
try {
const data = await WeApi.goals();
goals = data.goals;
loadState.goals = 'ready';
} catch (error) {
loadState.goals = 'error'; loadError.goals = error.message;
}
if (['goals', 'overview'].includes(page)) renderPage();
}
function goalLoadMessage() {
return loadState.goals === 'error'
? statusBox('error', '暂时无法读取阅读目标', loadError.goals, 'retry-goals')
: statusBox('loading', '正在读取阅读目标…', '正在加载已保存的阅读清单。');
}
// 目标列表以数据库为准;读取完成前不打开编辑器,避免加载失败状态覆盖刚保存的目标。
function requireGoalsReady() {
if (loadState.goals === 'ready') return true;
navigate('goals');
if (loadState.goals !== 'loading') ensureGoals();
return false;
}
// 目标成员独立于书架;资料缺失时保留 ID 和分母,不能因同步失败而丢书。
function goalBook(id) {
return findBook(id) || { id, title: `书籍 ${id}`, author: '暂未获取书籍资料', status: 'unknown', progress: 0 };
}
function goalMetrics(goal, date = today()) {
const ids = [...new Set(goal.ids)], done = ids.filter(id => goalBook(id).status === 'done').length;
const status = ids.length && done === ids.length ? '已完成' : date < goal.start ? '未开始' : date > goal.end ? '已到期' : '进行中';
return { total: ids.length, done, percent: ids.length ? Math.round((done / ids.length) * 100) : 0, status };
}
function goalCard(goal) {
const result = goalMetrics(goal);
return `<article class="card goal-card"><div class="section-head"><span class="pill ${result.status === '已完成' ? 'green' : result.status === '已到期' ? 'amber' : ''}">${result.status}</span><button class="text-button" data-action="edit-goal" data-id="${esc(goal.id)}" aria-label="编辑${esc(goal.name)}">编辑 ↗</button></div><h3>${esc(goal.name)}</h3><div class="goal-date">${esc(goal.start)} — ${esc(goal.end)}</div><div class="cover-stack">${goal.ids.slice(0, 4).map(id => cover(goalBook(id), true)).join('')}${result.total > 4 ? `<span class="stack-more">+ ${result.total - 4} 本</span>` : ''}</div><div class="goal-progress"><span><strong>${result.done}</strong> / ${result.total} 本已读完</span><span>${result.percent}%</span></div><div class="progress" style="--progress:${result.percent}%"><i></i></div><div class="goal-card-footer"><small>${goal.sources.length ? `来自 ${goal.sources.length} 个书单 · 可混合单书` : '按本选书 · 自己定义节奏'}</small><button data-action="goal-detail" data-id="${esc(goal.id)}">查看清单 →</button></div></article>`;
}
function renderGoals() {
if (loadState.goals !== 'ready') return goalLoadMessage();
if (!shelfData && loadState.shelf !== 'error') ensureShelf();
const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成'));
return welcome('把想读,变成读过', '几本书、一份书单,或一段想留给阅读的时间。', '<button class="primary" data-action="new-goal">+ 新建阅读目标</button>') +
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(goal => goalMetrics(goal).status === '已完成').length}</strong>个已完成</span><span><strong>${new Set(goals.flatMap(goal => goal.ids)).size}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>` +
`${storageWarning ? `<p class="hint">${esc(storageWarning)}</p>` : ''}${legacyGoals.length ? `<p class="hint">发现此浏览器的 ${legacyGoals.length} 个旧目标。<button class="text-button" data-action="import-goals">导入已有目标</button> · 数据库已有目标会保留,原备份也会保留</p>` : ''}` +
tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') +
`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
}
function openGoalDetail(id) {
const goal = goals.find(g => g.id === id), m = goalMetrics(goal);
showDetail('阅读目标', `<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${esc(goal.start)} — ${esc(goal.end)}</span></div><h2>${esc(goal.name)}</h2><div class="metrics-row"><span><strong>${m.done} / ${m.total}</strong>本已读完</span><span><strong>${m.percent}%</strong>已完成</span></div><p class="hint">已读完直接计入完成,不受目标日期影响。</p>${goal.sources.length ? `<p class="source-tag">加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目</p>` : ''}<div class="row-list">${goal.ids.map(id => bookRow(goalBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button><button class="secondary" data-action="delete-goal" data-id="${esc(id)}">删除目标</button></div>`);
}
function openGoalEditor(id = null, prefill = [], sources = []) {
if (goalBusy) return;
if (!requireGoalsReady()) return;
$('#detail').close(); editingGoalId = id;
// 新建重试沿用同一个 ID,防止响应丢失时重复创建目标。
draftGoalId = id || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
const goal = id ? goals.find(g => g.id === id) : null;
draftIds = new Set(goal ? goal.ids : prefill); draftSources = [...(goal ? goal.sources : sources)];
$('#editor-title').textContent = goal ? '编辑阅读目标' : '新建阅读目标'; $('#goal-name').value = goal ? goal.name : '';
$('#goal-start').value = goal ? goal.start : today(); $('#goal-end').value = goal ? goal.end : `${today().slice(0, 4)}-12-31`;
$('#goal-end').min = $('#goal-start').value; $('#goal-end').setCustomValidity(''); $('#goal-name').setCustomValidity('');
$('#picker-search').value = ''; $('#goal-error').textContent = ''; renderPicker(); $('#goal-editor').showModal();
}
function selectedGoalBooks() { return [...draftIds]; }
function renderPicker() {
const query = $('#picker-search').value.trim().toLowerCase();
if (!shelfData && loadState.shelf !== 'error') ensureShelf();
const candidates = [...draftIds].map(goalBook).concat(books.filter(book => !draftIds.has(book.id)));
const matched = candidates.filter(book => `${book.title}${book.author}${book.category}`.toLowerCase().includes(query));
const list = matched.slice(0, PICKER_LIMIT);
$('#selection-count').textContent = `已选 ${draftIds.size} 本 · ${[...draftIds].filter(id => goalBook(id).status === 'done').length} 本已读完`;
$('#goal-picker').innerHTML = `${!shelfData ? '<p class="hint">书架尚未同步,已选书籍仍会保留。</p>' : ''}<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => `<button type="button" data-action="pick-list" data-id="${esc(l.id)}" class="${l.ids.every(id => draftIds.has(id)) ? 'active' : ''}">${l.ids.every(id => draftIds.has(id)) ? '✓' : '+'} ${esc(l.name)} · ${l.book_count || l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(book => `<label class="pick-row"><input type="checkbox" data-pick-book="${esc(book.id)}" ${draftIds.has(book.id) ? 'checked' : ''}><span><b>${esc(book.title)}</b><small>${esc(book.author)}</small></span><span class="pill ${book.status === 'done' ? 'green' : ''}">${esc(stateText(book))}</span></label>`).join('') || '<div class="empty">没有匹配的书籍</div>'}</div>${matched.length > list.length ? `<p class="hint">共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。</p>` : ''}`;
}
async function saveGoal(event) {
event.preventDefault();
if (goalBusy) return;
if (loadState.goals !== 'ready') { $('#goal-error').textContent = '阅读目标还在同步,请稍后重试。'; return; }
const name = $('#goal-name').value.trim(), start = $('#goal-start').value, end = $('#goal-end').value, ids = selectedGoalBooks();
if (!name) { $('#goal-name').setCustomValidity('请输入目标名称'); $('#goal-name').reportValidity(); return; }
if (end < start) { $('#goal-error').textContent = '结束日期不能早于开始日期。'; return; }
if (!ids.length) { $('#goal-error').textContent = '请至少选择一本书,或加入一个书单。'; return; }
const goal = { id: draftGoalId, name, start, end, ids, sources: [...new Set(draftSources)] };
goalBusy = true; $('#goal-form').inert = true; $('#goal-error').textContent = '正在保存…';
try {
const result = await WeApi.saveGoal(goal, Boolean(editingGoalId));
goals = editingGoalId ? goals.map(g => g.id === goal.id ? result.goal : g) : [result.goal, ...goals];
$('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存。');
} catch (error) {
$('#goal-error').textContent = error.message;
} finally {
goalBusy = false; $('#goal-form').inert = false;
}
}
function chooseTarget(ids, sources = []) {
if (loadState.goals !== 'ready') { navigate('goals'); return; }
showDetail('加入阅读目标', `<p class="detail-subtitle">选择已有目标,或为这些书新建目标。重复书籍只加入一次。</p><div class="row-list">${goals.map(g => `<div class="book-row"><div class="book-row-text"><b>${esc(g.name)}</b><p>${esc(g.start)} — ${esc(g.end)} · ${g.ids.length} 本</p></div><button class="secondary" data-action="append-goal" data-id="${esc(g.id)}">加入</button></div>`).join('') || '<div class="empty">还没有目标,先新建一个。</div>'}</div><div class="goal-actions"><button class="primary" data-action="new-with-selection">+ 新建目标</button></div>`);
pendingBooks = ids; pendingSources = sources;
}
async function appendGoal(id) {
if (goalBusy) return;
const goal = goals.find(g => g.id === id);
const next = { ...goal, ids: [...new Set([...goal.ids, ...pendingBooks])], sources: [...new Set([...goal.sources, ...pendingSources])] };
goalBusy = true;
try {
const result = await WeApi.saveGoal(next, true);
goals = goals.map(g => g.id === id ? result.goal : g);
renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。');
} catch (error) {
toast(error.message);
} finally { goalBusy = false; }
}
function confirmGoalDelete(id) {
const goal = goals.find(g => g.id === id);
showDetail('删除阅读目标', `<p>确定删除“${esc(goal.name)}”?删除后无法恢复,书架中的书籍不受影响。</p><div class="goal-actions"><button class="secondary" data-action="goal-detail" data-id="${esc(id)}">取消</button><button class="primary" data-action="confirm-delete-goal" data-id="${esc(id)}">确认删除</button></div>`);
}
async function deleteGoal(id) {
if (goalBusy) return;
goalBusy = true;
try {
await WeApi.deleteGoal(id);
goals = goals.filter(g => g.id !== id);
$('#detail').close(); renderPage(); toast('阅读目标已删除。');
} catch (error) {
toast(error.message);
} finally { goalBusy = false; }
}
async function importGoals() {
if (goalBusy || !legacyGoals.length) return;
goalBusy = true;
try {
const result = await WeApi.importGoals(legacyGoals);
// 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。
legacyGoals = [];
try { localStorage.setItem(`${legacyGoalStorageKey || storageKey}-imported`, '1'); }
catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; }
await ensureGoals();
toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`);
} catch (error) {
toast(error.message);
} finally { goalBusy = false; if (page === 'goals') renderPage(); }
}
/* ------------------------------- 笔记与划线 ------------------------------- */
function renderNotes() {
if (!notebooksData && loadState.notes.__list !== 'error') { ensureNotebooks(); return statusBox('loading', '正在读取笔记本…', '来自微信读书的笔记本概览。'); }
if (!notebooksData) return statusBox('error', '暂时无法读取笔记本', loadError.notes.__list || '', 'retry-notes');
if (!notesData[noteBook] && loadState.notes[noteBook] !== 'loading' && loadState.notes[noteBook] !== 'ready' && loadState.notes[noteBook] !== 'error') ensureNotes(noteBook);
const payload = notesData[noteBook];
const items = notebooksData.items || [];
const menuBooks = items.slice(0, 10);
const counts = (payload && payload.counts) || { highlights: 0, thoughts: 0, bookmarks: 0 };
const notes = (payload && payload.notes) || [];
const shownNotes = filteredNotes(notes);
const noteSlice = shownNotes.slice(0, notesLimit);
const body = !payload
? (loadState.notes[noteBook] === 'error'
? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes')
: statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。'))
: `<div class="metrics-row"><span><strong>${counts.highlights + counts.thoughts + counts.bookmarks}</strong>条笔记</span><span>划线 ${counts.highlights}</span><span>想法 ${counts.thoughts}</span><span>书签 ${counts.bookmarks}</span></div>` +
tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
(noteType === 'bookmark'
? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>`
: (noteSlice.map(noteCard).join('') || '<div class="card empty">当前筛选没有笔记。</div>')) +
(noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `<button class="text-button section-space" data-action="notes-more">+ 继续加载(还有 ${shownNotes.length - noteSlice.length} 条)</button>` : '') +
`<p class="hint section-space">${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。</p>`;
return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '<button class="secondary" data-action="export-notes">↓ 导出当前书目笔记</button>') +
`<div class="notebook-grid"><aside class="card panel notebook-menu"><button data-action="notebook" data-id="all" class="${noteBook === 'all' ? 'active' : ''}"><b>全部笔记本</b><small>${notebooksData.totals.books} 本书 · ${notebooksData.totals.notes} 条笔记</small></button>${menuBooks.map(item => `<button data-action="notebook" data-id="${esc(item.id)}" class="${noteBook === item.id ? 'active' : ''}"><b>${esc(item.title)}</b><small>${item.total_notes} 条 · 含书签 ${item.bookmark_count}</small></button>`).join('')}</aside><div>${body}</div></div>`;
}
function filteredNotes(notes) {
if (noteType === 'all') return notes;
if (noteType === 'highlight') return notes.filter(n => n.type === 'highlight');
if (noteType === 'thought') return notes.filter(n => n.type === 'thought');
return [];
}
function noteCard(note) {
return `<article class="note-content fade"><span class="pill ${note.type === 'thought' ? 'green' : ''}">${note.type === 'thought' ? '我的想法' : '划线'}</span><blockquote>${esc(note.text)}</blockquote><small>《${esc(note.book_title || '')}》 · ${esc(note.date || '')}</small><p class="hint">${esc(note.chapter || '未记录章节')}</p>${note.id ? `<button class="text-button section-space" data-action="personal-note" data-id="${esc(note.id)}" data-book="${esc(note.bookId || '')}" data-kind="${note.type}">查看笔记详情 →</button>` : ''}</article>`;
}
function exportNotes() {
const payload = notesData[noteBook] || { notes: [] };
const all = filteredNotes(payload.notes || []);
const notes = all.slice(0, 300);
const scopeName = noteBook === 'all' ? '全部笔记本(聚合)' : (notebooksData.items.find(item => item.id === noteBook) || {}).title || noteBook;
const header = `# 阅间笔记 · ${scopeName}\n\n导出时间:${today()} 来源:微信读书个人账号\n书签仅提供数量,不导出内容。${all.length > notes.length ? `\n本次导出前 ${notes.length} 条(共 ${all.length} 条),可切换单本笔记本分批导出。` : ''}\n\n`;
const text = header + notes.map(n => `## ${n.book_title || ''}\n\n${n.type === 'thought' ? '想法' : '划线'} · ${n.date || ''}${n.chapter ? ' · ' + n.chapter : ''}\n\n${n.text}`).join('\n\n');
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' }));
showDetail('导出笔记', `<p class="hint">已生成${esc(scopeName)}的划线与想法,共 ${notes.length} 条。可下载 Markdown,或直接选中下方内容复制;不含书签内容。</p><textarea readonly class="export-preview" aria-label="笔记导出内容">${esc(text)}</textarea><div class="goal-actions"><a class="primary" style="text-decoration:none" href="${url}" download="阅间笔记.md">下载 Markdown</a></div>`);
$('#detail').addEventListener('close', () => URL.revokeObjectURL(url), { once: true });
}
/* ------------------------------- 发现好书 ------------------------------- */
function renderDiscover() {
const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']];
if (!shelfData) ensureShelf();
const key = discoverKey();
// 默认书籍页直接使用已加载的本地书架,不等待远程书城搜索。
if (!discoverData[key] && !searchQuery && searchScope === 'shelf') {
discoverData[key] = { local: true, books, authors: [], lists: booklists };
loadState.discover[key] = 'ready';
}
const payload = discoverData[key];
let content = '';
if (loadError.discover[key]) content = statusBox('error', '暂时无法获取数据', loadError.discover[key], 'retry-discover');
else if (!payload) { ensureDiscover(); content = statusBox('loading', '正在寻找好书…', searchScope === 'shelf' ? '在你的书架中检索。' : '实时检索微信读书书城。'); }
else if (discoverTab === 'recommend') {
const list = payload.books || [];
const prefer = (dashboardData[period] && dashboardData[period].prefer_words.category) || '你的阅读偏好';
content = `<div class="discovery-hero"><div class="section-head"><div><div class="eyebrow">PICKED FOR YOUR NEXT CHAPTER</div><h2>读过的书,会带你遇见下一本。</h2><p>${esc(prefer)} · 微信读书实时推荐</p></div><button class="secondary" data-action="refresh-recommend">换一批</button></div></div><div class="card panel"><div class="books full-books">${list.map(recommendCard).join('') || '<div class="empty">接口暂时没有返回推荐书籍。</div>'}</div></div>`;
} else if (discoverTab === 'lists') {
const lists = searchScope === 'shelf' ? booklists : (payload.lists || []);
content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || (searchScope === 'shelf' ? '我的书单' : '腾讯书城') }))) + (!lists.length && searchScope !== 'shelf' ? '<p class="hint section-space">腾讯书城返回了书单数量,但当前接口没有返回具体书单条目。</p>' : '');
} else if (discoverTab === 'author') {
const authors = payload.authors || [];
content = `<div class="card panel">${authors.map(author => `<div class="book-row"><div class="avatar">${esc(author.name.slice(0, 1))}</div><div class="book-row-text"><b>${esc(author.name)}</b><p>作者 · 书城检索</p></div><button class="text-button" data-action="author-books" data-name="${esc(author.name)}">查看书籍 →</button></div>`).join('') || '<div class="empty">没有匹配的作者,换个关键词试试。</div>'}</div>`;
} else if (['novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) {
const list = payload.books || [];
const names = { novel: '网文', audio: '听书', fulltext: '全文', account: '公众号', article: '文章' };
content = list.length
? `<div class="card panel"><div class="books full-books">${list.map(searchCard).join('')}</div></div>`
: `<div class="card empty">「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。</div>`;
} else if (discoverTab === 'all') {
const list = payload.books || [];
const remoteLists = payload.lists || [];
content = `<div class="section-head"><h2>书籍</h2></div><div class="card panel"><div class="books">${list.slice(0, 8).map(searchCard).join('') || '<div class="empty">没有匹配的书籍。</div>'}</div></div><div class="section-head section-space"><h2>书单</h2></div>${renderLists(remoteLists)}${!remoteLists.length ? '<p class="hint section-space">腾讯书城未返回具体书单条目。</p>' : ''}`;
} else {
const list = payload.books || [];
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, SHELF_PAGE) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
}
return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') +
`<div class="toolbar"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="discover-search" aria-label="搜索发现内容" placeholder="书名、作者或关键词…" value="${esc(searchQuery)}"></label><label class="hint">搜索范围 <select id="search-scope" aria-label="搜索范围">${[['store', '书城'], ['shelf', '我的书架']].map(([id, label]) => `<option value="${id}" ${searchScope === id ? 'selected' : ''}>${label}</option>`).join('')}</select></label></div>` +
tabs(options, discoverTab, 'discover-tab') + `<div id="discover-results">${content}</div><p class="hint section-space">${(payload && payload.local) || searchScope === 'shelf' ? '书架检索' : '书城检索'} · 结果实时来自微信读书接口;推荐理由与书单成员以接口返回为准。</p>`;
}
function searchCard(book) {
const reason = book.reason ? `<p class="hint">${esc(book.reason)}</p>` : '';
return `<button class="book" data-action="book" data-id="${esc(book.id)}" aria-label="查看${esc(book.title)}详情">${cover(book)}<h3>${esc(book.title)}</h3><p>${esc(book.author)}</p>${reason}<div class="book-meta"><span>${esc(book.category || book.group || '书城')}</span><span>${scoreText(book)}</span></div></button>`;
}
function recommendCard(book) {
return `<button class="book" data-action="book" data-id="${esc(book.id)}" aria-label="查看${esc(book.title)}详情">${cover(book)}<h3>${esc(book.title)}</h3><p>${esc(book.author)}</p><div class="book-meta"><span>${esc(book.rating_title || '推荐')}</span><span>${scoreText(book)}</span></div>${book.reason ? `<p class="hint">${esc(book.reason)}</p>` : ''}</button>`;
}
/* ------------------------------- 详情弹窗 ------------------------------- */
function showDetail(title, html) { $('#detail-title').textContent = title; $('#detail-content').innerHTML = html; if (!$('#detail').open) $('#detail').showModal(); }
function openBook(id) { detailBook = id; bookTab = 'info'; selectedChapter = 0; reviewFilter = 0; chapterLimit = 30; renderBookDetail(); }
function renderBookDetail() {
const id = detailBook;
if (!id) return;
if (loadError.book[id]) { showDetail('书籍详情', statusBox('error', '暂时无法读取书籍详情', loadError.book[id], 'retry-book')); return; }
const data = bookData[id];
if (!data) { ensureBook(id); showDetail('书籍详情', statusBox('loading', '正在读取书籍详情…', '简介、目录、笔记、热门划线与点评。')); return; }
const info = data.book;
const progress = data.progress.progress;
const state = progress >= 100 ? '已读完' : progress > 0 ? `已读 ${progress}%` : '尚未开始阅读';
const readTimeText = data.progress.record_reading_time ? ` · 累计 ${esc(data.progress.record_reading_time_str)}` : '';
showDetail('书籍详情', `<div class="detail-body">${cover(info)}<div class="detail-info"><div class="eyebrow">READING SPACE / BOOK</div><h3>${esc(info.title)}</h3><p>${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}</p><p style="color:var(--green)">${info.rating ? `评分 ${(Number(info.rating) / 10).toFixed(2)}% · ${info.rating_count} 人评价` : '暂无评分'}</p><p>${state}${readTimeText}</p><button class="primary" data-action="add-book" data-id="${esc(info.id)}">+ 加入阅读目标</button>${info.deep_link ? ` <a class="secondary" style="text-decoration:none;display:inline-block;margin-top:8px" href="${esc(info.deep_link)}" target="_blank" rel="noreferrer">在微信读书中打开 ↗</a>` : ''}</div></div><div class="detail-tabs">${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => `<button data-action="book-tab" data-id="${tab}" class="${bookTab === tab ? 'active' : ''}">${label}</button>`).join('')}</div><div id="book-tab-content">${renderBookTab(data)}</div>`);
}
function renderBookTab(data) {
const info = data.book;
if (bookTab === 'info') return `<p class="detail-desc">${esc(info.intro || '接口未返回简介。')}</p><div class="book-details-grid section-space"><div><span>分类:</span>${esc(info.category || '未分类')}</div><div><span>最近阅读:</span>${esc(data.progress.update_time || '暂无记录')}</div><div><span>出版社:</span>${esc(info.publisher || '未提供')}</div><div><span>出版日期:</span>${esc(info.publish_time || '未提供')}</div><div><span>字数:</span>${info.word_count ? `${Math.round(info.word_count / 10000)} 万字` : '未提供'}</div><div><span>ISBN:</span>${esc(info.isbn || '未提供')}</div></div>`;
if (bookTab === 'chapters') {
const chapters = data.chapters.slice(0, chapterLimit);
return `<p class="hint">共 ${data.chapter_total} 章,当前显示 ${chapters.length} 章;点击可查看该章热门划线。</p>${chapters.map(chapter => `<div class="chapter-row"><span>${String(chapter.idx).padStart(2, '0')} &nbsp; ${esc(chapter.title)}${chapter.words ? `<small style="color:var(--muted)"> · ${Math.max(1, Math.round(chapter.words / 1000))} 千字</small>` : ''}</span><button class="text-button" data-action="chapter" data-id="${esc(chapter.uid)}">划线热度 →</button></div>`).join('') || '<div class="detail-empty">接口未返回目录。</div>'}${data.chapters.length > chapters.length ? `<button class="text-button section-space" data-action="chapter-more">+ 继续加载(还有 ${data.chapters.length - chapters.length} 章)</button>` : ''}`;
}
if (bookTab === 'notes') {
const notes = data.notes.items || [];
const counts = data.notes.counts;
return `<div class="metrics-row"><span><strong>${counts.highlights}</strong>条划线</span><span><strong>${counts.thoughts}</strong>条想法</span><span><strong>${counts.bookmarks}</strong>个书签</span></div>${notes.map(note => `<div class="note-content"><span class="pill ${note.type === 'thought' ? 'green' : ''}">${note.type === 'thought' ? '个人想法' : '划线'}</span><blockquote>${esc(note.text)}</blockquote><small>${esc(note.date || '')}${note.chapter ? ' · ' + esc(note.chapter) : ''}</small>${note.url ? `<div class="section-space"><a class="text-button" style="text-decoration:none" href="${esc(note.url)}">在微信读书中定位 ↗</a></div>` : ''}</div>`).join('') || '<div class="detail-empty">这本书暂无个人笔记。</div>'}`;
}
if (bookTab === 'popular') {
const marks = numberData[`hl:${info.id}:${selectedChapter}`] || data.best_bookmarks;
const items = (marks.items || []).slice(0, 20);
const chapterOptions = [['0', '全部章节']].concat(data.chapters.slice(0, 40).map(chapter => [String(chapter.uid), chapter.title]));
return `<div class="toolbar"><span class="hint">${selectedChapter ? esc((data.chapters.find(chapter => String(chapter.uid) === String(selectedChapter)) || {}).title || '所选章节') : '全书'} · 热门划线最多展示 20 条</span><select id="chapter-filter" aria-label="选择热门划线章节">${chapterOptions.map(([id, label]) => `<option value="${esc(id)}" ${String(selectedChapter) === String(id) ? 'selected' : ''}>${esc(label)}</option>`).join('')}</select></div>${items.map(item => `<article class="note-content"><span class="pill">${item.count} 人划线</span><blockquote>${esc(item.text)}</blockquote><small>${esc(item.chapter || '')}${marks.total ? ` · 全书共 ${marks.total} 条热门划线` : ''}</small><div class="section-space"><button class="text-button" data-action="highlight-thoughts" data-chapter="${esc(item.chapter_uid || 0)}" data-range="${esc(item.range || '')}" data-id="${esc(info.id)}">查看这条划线下他人的想法 →</button></div></article>`).join('') || '<div class="detail-empty">接口未返回热门划线。</div>'}<p class="hint">章节热度提供人数与位置;原文来自热门划线接口。</p>`;
}
if (bookTab === 'reviews') {
const payload = numberData[`rv:${info.id}:${reviewFilter}`] || { items: data.reviews.items, count: data.reviews.count };
const list = payload.items || [];
return tabs([[0, '全部'], [1, '推荐'], [4, '一般'], [2, '不行'], [3, '最新']], reviewFilter, 'review-filter') +
`<p class="hint">共 ${payload.count || list.length} 条公开点评</p>` +
(list.map(review => `<article class="review-row"><div class="meta"><b>${esc(review.name)}${review.is_finish ? ' · 已读完' : ''}</b><span>${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}</span></div><p>${esc(review.text)}</p><div class="section-head section-space"><small class="hint">${esc(review.date || '')}${review.chapter ? ' · ' + esc(review.chapter) : ''}</small><button class="text-button" data-action="review-detail" data-name="${esc(review.name)}" data-text="${esc(review.text)}">查看详情 →</button></div></article>`).join('') || '<div class="detail-empty">该筛选下接口没有返回点评。</div>');
}
const similar = data.similar || [];
return `<p class="hint">相似推荐 · 实时来自微信读书「相似书籍」接口</p><div class="books section-space">${similar.map(bookCard).join('') || '<div class="detail-empty">接口未返回相似推荐。</div>'}</div>`;
}
function openThought(text, subtitle) { showDetail('想法详情', `<article class="note-content"><span class="pill">内容</span><blockquote>${esc(text)}</blockquote><small>${esc(subtitle)}</small></article>`); }
async function openThoughtDetail(reviewId, bookId) {
showDetail('想法详情', statusBox('loading', '正在读取想法详情…', '正文、点赞与评论。'));
try {
const data = await WeApi.thought(reviewId);
showDetail('想法详情', `<article class="note-content"><span class="pill">我的想法</span><blockquote>${esc(data.text)}</blockquote>${data.abstract ? `<div class="note-original">划线原文:${esc(data.abstract)}</div>` : ''}<small>${esc(data.date || '')}${data.chapter ? ' · ' + esc(data.chapter) : ''}</small><div class="metrics-row"><span><strong>${data.like_count}</strong>个赞</span><span><strong>${data.comment_count}</strong>条评论</span></div>${(data.comments || []).map(comment => `<article class="review-row"><div class="meta"><b>${esc(comment.name)}</b><span>${esc(comment.date || '')}</span></div><p>${esc(comment.text)}</p></article>`).join('') || '<p class="hint">这条想法还没有评论。</p>'}<div class="section-space">${bookId ? `<button class="text-button" data-action="book" data-id="${esc(bookId)}">回到书籍详情 →</button>` : ''}</div></article>`);
} catch (error) {
showDetail('想法详情', statusBox('error', '暂时无法读取想法详情', error.message, ''));
}
}
async function openHighlightThoughts(bookId, chapterUid, range) {
showDetail('划线下的想法', statusBox('loading', '正在读取他人的想法…', '来自划线定位接口。'));
try {
const data = await WeApi.underlineThoughts(bookId, chapterUid, range);
const items = data.items || [];
showDetail('划线下的想法', `<p class="hint">该划线范围内取到 ${items.length} 条想法</p>` + (items.map(item => `<article class="review-row"><div class="meta"><b>${esc(item.name)}</b><span>${esc(item.date || '')}</span></div><p>${esc(item.text)}</p></article>`).join('') || '<div class="detail-empty">这条划线下暂无他人想法。</div>'));
} catch (error) {
showDetail('划线下的想法', statusBox('error', '暂时无法读取划线下想法', error.message, ''));
}
}
/* ------------------------------- 行为分发 ------------------------------- */
function handleAction(button) {
const action = button.dataset.action, id = button.dataset.id;
if (goalBusy && ['new-goal', 'edit-goal', 'close-editor', 'append-goal', 'new-with-selection', 'import-goals', 'delete-goal', 'confirm-delete-goal'].includes(action)) return;
switch (action) {
case 'page': navigate(id); break;
case 'new-goal': ensureShelf(); openGoalEditor(); break;
case 'edit-goal': openGoalEditor(id); break;
case 'goal-detail': openGoalDetail(id); break;
case 'retry-goals': ensureGoals(); break;
case 'import-goals': importGoals(); break;
case 'delete-goal': confirmGoalDelete(id); break;
case 'confirm-delete-goal': deleteGoal(id); break;
case 'goal-filter': goalFilter = id; renderPage(); break;
case 'close-detail': $('#detail').close(); break;
case 'close-editor': $('#goal-editor').close(); break;
case 'pick-list': {
const list = booklists.find(l => l.id === id);
if (list) { list.ids.forEach(bookId => draftIds.add(bookId)); if (!draftSources.includes(list.name)) draftSources.push(list.name); }
renderPicker(); break;
}
case 'book': openBook(id); break;
case 'list': openList(id); break;
case 'add-book': chooseTarget([id]); 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 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break;
case 'shelf-tab': shelfTab = id; shelfPage = 1; renderPage(); break;
case 'shelf-page': shelfPage = Number(id); renderPage(); break;
case 'period': period = id; renderPage(); break;
case 'discover-tab': discoverTab = id; renderPage(); break;
case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break;
case 'retry-overview': ensureDashboard(period, true); break;
case 'retry-shelf': ensureShelf(true); break;
case 'retry-notes': if (!notebooksData) ensureNotebooks(true); else ensureNotes(noteBook, true); break;
case 'retry-discover': ensureDiscover(true); break;
case 'retry-book': ensureBook(detailBook, true); break;
case 'notebook': noteBook = id; noteType = 'all'; notesLimit = 30; renderPage(); break;
case 'note-type': noteType = id; notesLimit = 30; renderPage(); break;
case 'notes-more': notesLimit += 30; renderPage(); break;
case 'export-notes': exportNotes(); break;
case 'personal-note': {
const note = ((notesData[noteBook] || {}).notes || []).find(n => n.id === id);
if (!note) break;
if (note.type === 'thought') openThoughtDetail(note.id, note.bookId);
else openThought(note.text, `${note.book_title || ''} · ${note.chapter || ''} · ${note.date || ''}`);
break;
}
case 'book-tab': bookTab = id; renderBookDetail(); break;
case 'chapter': selectedChapter = id; bookTab = 'popular'; renderBookDetail(); if (bookData[detailBook] && !numberData[`hl:${detailBook}:${id}`]) WeApi.highlights(detailBook, id).then(res => { numberData[`hl:${detailBook}:${id}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); break;
case 'chapter-more': chapterLimit += 30; renderBookDetail(); break;
case 'review-filter': reviewFilter = Number(id); renderBookDetail(); WeApi.reviews(detailBook, reviewFilter, 10).then(res => { numberData[`rv:${detailBook}:${reviewFilter}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); break;
case 'review-detail': openThought(button.dataset.text, `${button.dataset.name} · 公开点评`); break;
case 'highlight-thoughts': openHighlightThoughts(button.dataset.id, button.dataset.chapter, button.dataset.range); break;
case 'author-books': searchQuery = button.dataset.name; discoverTab = 'books'; searchScope = 'store'; $('#global-search').value = searchQuery; navigate('discover'); break;
case 'article-search': searchQuery = ''; discoverTab = 'article'; navigate('discover'); break;
case 'year-report': {
const year = dashboardData[period] && dashboardData[period].year_summary;
if (!year || !year.duration_str) { toast('年度数据暂时不可用'); break; }
showDetail('年度回顾', `<div class="discovery-hero"><div class="eyebrow">${year.year} / A YEAR IN BOOKS</div><h2>翻过的每一页,都算数。</h2><p>${esc(year.duration_str)} · ${year.read_days} 个阅读日${year.read_count ? ' · 读过 ' + esc(year.read_count) : ''}${year.finish_count ? ' · 读完 ' + esc(year.finish_count) : ''}</p></div><p class="detail-desc">${esc(year.prefer_category || '')}${year.prefer_time ? ',' + esc(year.prefer_time) : ''}。以上来自微信读书阅读统计接口。</p>${(year.top_books || []).map(book => `<div class="insight-line"><b>${esc(book.title)}</b><span>${esc(book.read_time_str)}</span></div>`).join('')}`);
break;
}
}
}
document.addEventListener('click', event => {
const action = event.target.closest('[data-action]');
if (action) { handleAction(action); return; }
const nav = event.target.closest('[data-page]');
if (nav) { searchQuery = ''; $('#global-search').value = ''; navigate(nav.dataset.page); }
});
document.addEventListener('change', event => {
if (event.target.matches('[data-pick-book]')) { const id = event.target.dataset.pickBook; event.target.checked ? draftIds.add(id) : draftIds.delete(id); renderPicker(); }
if (event.target.id === 'chapter-filter') {
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 (event.target.id === 'shelf-sort') { shelfSort = event.target.value; shelfPage = 1; renderPage(); }
if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); }
});
$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; };
$('#goal-name').oninput = () => $('#goal-name').setCustomValidity('');
$('#goal-form').onsubmit = saveGoal;
$('#picker-search').oninput = renderPicker;
function applyGlobalSearch(value) {
searchQuery = value;
if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) discoverTab = 'books';
navigate('discover');
}
$('#global-search').addEventListener('compositionstart', () => { searchComposing = true; });
$('#global-search').addEventListener('compositionend', event => {
searchComposing = false;
applyGlobalSearch(event.target.value.trim());
});
$('#global-search').oninput = event => {
if (searchComposing || event.isComposing) return;
clearTimeout(discoverSearchTimer);
discoverSearchTimer = setTimeout(() => applyGlobalSearch(event.target.value.trim()), 300);
};
document.addEventListener('input', event => {
if (event.target.id === 'discover-search') {
if (event.isComposing || searchComposing) return;
const value = event.target.value;
$('#global-search').value = value;
clearTimeout(discoverSearchTimer);
discoverSearchTimer = setTimeout(() => applyGlobalSearch(value), 300);
}
});
document.addEventListener('compositionstart', event => {
if (event.target.id === 'discover-search') searchComposing = true;
});
document.addEventListener('compositionend', event => {
if (event.target.id === 'discover-search') {
searchComposing = false;
applyGlobalSearch(event.target.value.trim());
}
});
document.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); $('#global-search').focus(); } });
document.querySelectorAll('dialog').forEach(dialog => dialog.addEventListener('click', event => {
if (dialog.id === 'goal-editor' && goalBusy) return;
const rect = dialog.getBoundingClientRect();
if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) dialog.close();
}));
$('#goal-editor').addEventListener('cancel', event => { if (goalBusy) event.preventDefault(); });
/* ------------------------------- 启动 ------------------------------- */
async function boot() {
ensureGoals();
renderPage();
await Promise.all([ensureStatus(), ensureShelf()]);
ensureDashboard(period);
if (page !== 'overview') renderPage();
}
boot();