mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
725 lines
62 KiB
JavaScript
725 lines
62 KiB
JavaScript
/* ==========================================================================
|
||
阅间 · 微信读书看板 v2 —— 交互逻辑
|
||
--------------------------------------------------------------------------
|
||
视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。
|
||
差异只在于数据来源:设计稿读取 demo-data.js 的静态示例,
|
||
这里改为调用 /api/v2/* 实时接口。
|
||
========================================================================== */
|
||
|
||
const $ = selector => document.querySelector(selector);
|
||
const esc = value =>
|
||
String(value == null ? '' : value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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-v2';
|
||
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 shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
|
||
let editingGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
|
||
let toastTimer, storageWarning = '';
|
||
|
||
/* 接口数据缓存 */
|
||
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 = [];
|
||
try {
|
||
const saved = localStorage.getItem(storageKey);
|
||
if (saved) {
|
||
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');
|
||
goals = parsed;
|
||
}
|
||
} catch { storageWarning = '本地目标暂时无法读取,已重置为空白;原存储未修改。'; }
|
||
|
||
/* ------------------------------- 通用组件 ------------------------------- */
|
||
|
||
function toast(message) {
|
||
clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden');
|
||
toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500);
|
||
}
|
||
function persistGoals(next) {
|
||
try { localStorage.setItem(storageKey, JSON.stringify(next)); }
|
||
catch { $('#goal-error').textContent = '浏览器无法保存数据,请允许本地存储后重试。'; return false; }
|
||
goals = next; return true;
|
||
}
|
||
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 === '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() {
|
||
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 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 shown = filtered.slice(0, shelfLimit);
|
||
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 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>`);
|
||
}
|
||
|
||
/* ------------------------------- 阅读目标 ------------------------------- */
|
||
|
||
function goalMetrics(goal, date = today()) {
|
||
const ids = uniqueIds(goal.ids), done = ids.filter(id => findBook(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(findBook(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 (!shelfData) 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>${uniqueIds(goals.flatMap(goal => goal.ids)).length}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>` +
|
||
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(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button></div>`);
|
||
}
|
||
function openGoalEditor(id = null, prefill = [], sources = []) {
|
||
$('#detail').close(); editingGoalId = id;
|
||
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 uniqueIds([...draftIds]); }
|
||
function renderPicker() {
|
||
const query = $('#picker-search').value.trim().toLowerCase();
|
||
if (!shelfData) { $('#goal-picker').innerHTML = '<p class="hint">正在同步书架…</p>'; ensureShelf(); return; }
|
||
const matched = books.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 => findBook(id).status === 'done').length} 本已读完`;
|
||
$('#goal-picker').innerHTML = `<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>` : ''}`;
|
||
}
|
||
function saveGoal(event) {
|
||
event.preventDefault();
|
||
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: editingGoalId || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())), name, start, end, ids, sources: [...new Set(draftSources)] };
|
||
const next = editingGoalId ? goals.map(g => (g.id === editingGoalId ? goal : g)) : [goal, ...goals];
|
||
if (!persistGoals(next)) return;
|
||
$('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存,已读完的书已计入完成。');
|
||
}
|
||
function chooseTarget(ids, sources = []) {
|
||
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;
|
||
}
|
||
function appendGoal(id) {
|
||
const next = goals.map(g => (g.id === id ? { ...g, ids: uniqueIds([...g.ids, ...pendingBooks]), sources: [...new Set([...g.sources, ...pendingSources])] } : g));
|
||
if (!persistGoals(next)) { toast('无法保存到浏览器,请允许本地存储后重试。'); return; }
|
||
renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。');
|
||
}
|
||
|
||
/* ------------------------------- 笔记与划线 ------------------------------- */
|
||
|
||
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, shelfLimit) : 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')} ${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;
|
||
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 '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; shelfLimit = SHELF_PAGE; renderPage(); break;
|
||
case 'shelf-more': shelfLimit += SHELF_PAGE; 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 === '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 => {
|
||
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();
|
||
}));
|
||
|
||
/* ------------------------------- 启动 ------------------------------- */
|
||
|
||
async function boot() {
|
||
renderPage();
|
||
await Promise.all([ensureStatus(), ensureShelf()]);
|
||
ensureDashboard(period);
|
||
if (page !== 'overview') renderPage();
|
||
}
|
||
boot();
|