/* ==========================================================================
阅间 · 微信读书看板 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, 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 = [];
try {
const saved = localStorage.getItem(storageKey);
if (saved && !localStorage.getItem(`${storageKey}-imported`)) {
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;
}
} 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 ? `` : '';
return `
${eyebrow}
${esc(title)}
${esc(text || '')}
${button}
`;
}
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
$('#theme').innerHTML = ``;
$('#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
? `${esc(book.title)}${esc(book.author)}
`
: `${esc(book.en || '')}${esc(short).replaceAll('\n', '
')}${esc(book.author)}READING SPACE · 藏书
`;
if (!coverUrl) return fallback;
const className = mini ? 'real-cover mini-real-cover' : 'real-cover';
return `
`;
}
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 ``;
}
function bookRow(book, source = '') {
return `${cover(book, true)}
${esc(book.title)}${esc(book.author)}${source ? ' · ' + esc(source) : ''}
${esc(stateText(book))} `;
}
function welcome(title, subtitle, action = '') {
return `A LITTLE READING, EVERY DAY
${esc(title)}
${esc(subtitle)}
${action}
`;
}
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 `${items.map(([id, label]) => ``).join('')}
`;
}
/* ------------------------------- 数据加载 ------------------------------- */
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 => ``).join('');
const bars = values.map((v, i) => `${esc(labels[i])}
`).join('');
const axis = period === 'year' ? '按月' : period === 'overall' ? '按年' : '按日';
return `阅读的日常 每一天,都算数
${axis}${esc(data.stats[0].value)}阅读与收听合计
${gridlines}
${bars || '该周期暂无阅读记录'}
`;
}
function summaryGoals() {
if (loadState.goals !== 'ready') return goalLoadMessage();
return `我的阅读目标
${goals.slice(0, 3).map(goal => { const m = goalMetrics(goal); return ``; }).join('') || '还没有阅读目标,创建一份属于你的清单。
'}`;
}
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 => `${esc(s.label)}
${s.value}
`).join('');
const topBooks = data.top_books.length
? data.top_books.map((b, i) => `${i + 1}. ${esc(b.title)}${esc(b.read_time_str)}
`).join('')
: '该周期还没有阅读时长记录。
';
const cats = data.categories.items.length
? data.categories.items.map(c => `${esc(c.title)}相对偏好 ${c.val}
`).join('')
: '接口未返回该周期的偏好分类。
';
const hours = data.time_distribution.values || [];
const hourMax = data.time_distribution.max || 1;
const hourBars = hours.length
? hours.map(v => ``).join('')
: '接口未返回时段分布';
const peak = data.time_distribution.peak_hour;
const authorLines = data.authors.length
? data.authors.map(a => `${esc(a.name)}${a.count} 本${a.read_time ? ' · ' + esc(a.read_time) : ''}
`).join('')
: '接口未返回该周期的偏好作者。
';
const publisherLines = data.publishers.length
? data.publishers.map(p => `${esc(p.name)}${p.count} 本
`).join('')
: '接口未返回偏好出版社。
';
const cpLines = data.cps.length
? data.cps.map(c => `${esc(c.name)}${c.count} 本
`).join('')
: '';
const medalLines = data.medals.length
? data.medals.map(m => `${esc(m.name)}${esc(m.detail || m.date || '')}
`).join('')
: '勋章接口未返回
';
return welcome('在字里行间,遇见自己', '阅读、目标与灵感,在这里慢慢积累。', '') +
`${tabs([['week', '本周'], ['month', '本月'], ['year', '本年'], ['overall', '总计']], period, 'period')}${esc(periodHint(period))} · 微信读书统计接口
` +
`` +
`${renderTrend(data)}
继续阅读
${reading.length ? reading.map(bookCard).join('') : '
书架里还没有在读的书。
'}
` +
`读得最多
${topBooks}${data.top_books.length ? `按${esc(periodHint(period))}的阅读时长排序
` : ''}偏爱的阅读方向
${cats}相对偏好权重,不是阅读占比
留给自己的时段
${hourBars}
06:0018:00次日 05:00
${esc(data.time_distribution.word || '时段分布')}${peak != null ? ` · 峰值 ${String(peak).padStart(2, '0')}:00` : ''}
` +
`更多阅读画像 · 作者、出版社、勋章与年度回顾
偏好作者与出版方
${authorLines}${publisherLines}${cpLines}阅读与收听
微信读书统计接口未返回「文字阅读 / 收听」时长拆分,接入该字段后可直接展示。
阅读总时长${esc(data.stats[0].value)}
有效阅读天数${esc(String(data.stats[1].value).replace(/<[^>]+>/g, ''))}
阅读足迹
好友排行${esc(data.rank.text || '本周暂无排行')}
${medalLines}
`;
}
/* ------------------------------- 我的书架 ------------------------------- */
function listCard(list, index) {
const members = (list.ids || []).map(findBook).filter(Boolean);
return ``;
}
function renderLists(lists) {
return `${lists.map(listCard).join('') || '
没有匹配的书单。
'}
`;
}
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 => `♫
${esc(album.title)}
${esc(album.author || '有声专辑')} · ${album.count} 集
${esc(album.intro || '内容完结不代表已听完;当前不纳入按本阅读目标。')}
`).join('')
: '书架接口当前没有返回有声书 / 专辑条目。
';
} else if (shelfTab === 'articles') {
content = `▤
${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}
这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。
书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。
`;
} else {
const shown = filtered.slice(0, shelfLimit);
content = `${shown.map(bookCard).join('') || '
这里还没有对应的书。
'}
${filtered.length > shown.length ? `
` : ''}
`;
}
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('书单详情', `A COLLECTION OF GOOD BOOKS
${esc(list.name)}
${esc(list.description || '')}
${esc(list.source || '我的书单')} · ${list.book_count || members.length} 本
${members.map(book => bookRow(book)).join('') || '
书单中的书籍当前不在书架上。
'}
`);
}
/* ------------------------------- 阅读目标 ------------------------------- */
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 `${result.status}
${esc(goal.name)}
${esc(goal.start)} — ${esc(goal.end)}
${goal.ids.slice(0, 4).map(id => cover(goalBook(id), true)).join('')}${result.total > 4 ? `+ ${result.total - 4} 本` : ''}
${result.done} / ${result.total} 本已读完${result.percent}%
`;
}
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('把想读,变成读过', '几本书、一份书单,或一段想留给阅读的时间。', '') +
`YOUR NEXT CHAPTER
目标不必宏大,开始就很好。
每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。
${goals.length}个目标${goals.filter(goal => goalMetrics(goal).status === '已完成').length}个已完成${new Set(goals.flatMap(goal => goal.ids)).size}本不同的书
❧ ` +
`${storageWarning ? `${esc(storageWarning)}
` : ''}${legacyGoals.length ? `发现此浏览器的 ${legacyGoals.length} 个旧目标。 · 数据库已有目标会保留,原备份也会保留
` : ''}` +
tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') +
`${list.map(goalCard).join('') || '
这里还没有目标,创建一份属于你的阅读清单。
'}
`;
}
function openGoalDetail(id) {
const goal = goals.find(g => g.id === id), m = goalMetrics(goal);
showDetail('阅读目标', `${m.status}${esc(goal.start)} — ${esc(goal.end)}
${esc(goal.name)}
${m.done} / ${m.total}本已读完${m.percent}%已完成
已读完直接计入完成,不受目标日期影响。
${goal.sources.length ? `加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目
` : ''}${goal.ids.map(id => bookRow(goalBook(id))).join('')}
`);
}
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 ? '书架尚未同步,已选书籍仍会保留。
' : ''}快捷加入书单当前书目
${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => ``).join('')}
${list.map(book => `
`).join('') || '
没有匹配的书籍
'}
${matched.length > list.length ? `共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。
` : ''}`;
}
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('加入阅读目标', `选择已有目标,或为这些书新建目标。重复书籍只加入一次。
${goals.map(g => `
${esc(g.name)}${esc(g.start)} — ${esc(g.end)} · ${g.ids.length} 本
`).join('') || '
还没有目标,先新建一个。
'}
`);
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('删除阅读目标', `确定删除“${esc(goal.name)}”?删除后无法恢复,书架中的书籍不受影响。
`);
}
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(`${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', '正在读取笔记内容…', '划线、想法与书签数量。'))
: `${counts.highlights + counts.thoughts + counts.bookmarks}条笔记划线 ${counts.highlights}想法 ${counts.thoughts}书签 ${counts.bookmarks}
` +
tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
(noteType === 'bookmark'
? `${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。
`
: (noteSlice.map(noteCard).join('') || '当前筛选没有笔记。
')) +
(noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `` : '') +
`${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。
`;
return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '') +
``;
}
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 `${note.type === 'thought' ? '我的想法' : '划线'}${esc(note.text)}
《${esc(note.book_title || '')}》 · ${esc(note.date || '')}${esc(note.chapter || '未记录章节')}
${note.id ? `` : ''}`;
}
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('导出笔记', `已生成${esc(scopeName)}的划线与想法,共 ${notes.length} 条。可下载 Markdown,或直接选中下方内容复制;不含书签内容。
`);
$('#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 = `PICKED FOR YOUR NEXT CHAPTER
读过的书,会带你遇见下一本。
${esc(prefer)} · 微信读书实时推荐
${list.map(recommendCard).join('') || '
接口暂时没有返回推荐书籍。
'}
`;
} 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' ? '腾讯书城返回了书单数量,但当前接口没有返回具体书单条目。
' : '');
} else if (discoverTab === 'author') {
const authors = payload.authors || [];
content = `${authors.map(author => `
${esc(author.name.slice(0, 1))}
${esc(author.name)}作者 · 书城检索
`).join('') || '
没有匹配的作者,换个关键词试试。
'}
`;
} else if (['novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) {
const list = payload.books || [];
const names = { novel: '网文', audio: '听书', fulltext: '全文', account: '公众号', article: '文章' };
content = list.length
? `${list.map(searchCard).join('')}
`
: `「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。
`;
} else if (discoverTab === 'all') {
const list = payload.books || [];
const remoteLists = payload.lists || [];
content = `书籍
${list.slice(0, 8).map(searchCard).join('') || '
没有匹配的书籍。
'}
书单
${renderLists(remoteLists)}${!remoteLists.length ? '腾讯书城未返回具体书单条目。
' : ''}`;
} else {
const list = payload.books || [];
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
content = `${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '
没有找到相关书籍,试试其他书名或作者。
'}
`;
}
return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') +
`` +
tabs(options, discoverTab, 'discover-tab') + `${content}
${(payload && payload.local) || searchScope === 'shelf' ? '书架检索' : '书城检索'} · 结果实时来自微信读书接口;推荐理由与书单成员以接口返回为准。
`;
}
function searchCard(book) {
const reason = book.reason ? `${esc(book.reason)}
` : '';
return ``;
}
function recommendCard(book) {
return ``;
}
/* ------------------------------- 详情弹窗 ------------------------------- */
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('书籍详情', `${cover(info)}
READING SPACE / BOOK
${esc(info.title)}
${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}
${info.rating ? `评分 ${(Number(info.rating) / 10).toFixed(2)}% · ${info.rating_count} 人评价` : '暂无评分'}
${state}${readTimeText}
${info.deep_link ? `
在微信读书中打开 ↗` : ''}
${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => ``).join('')}
${renderBookTab(data)}
`);
}
function renderBookTab(data) {
const info = data.book;
if (bookTab === 'info') return `${esc(info.intro || '接口未返回简介。')}
分类:${esc(info.category || '未分类')}
最近阅读:${esc(data.progress.update_time || '暂无记录')}
出版社:${esc(info.publisher || '未提供')}
出版日期:${esc(info.publish_time || '未提供')}
字数:${info.word_count ? `${Math.round(info.word_count / 10000)} 万字` : '未提供'}
ISBN:${esc(info.isbn || '未提供')}
`;
if (bookTab === 'chapters') {
const chapters = data.chapters.slice(0, chapterLimit);
return `共 ${data.chapter_total} 章,当前显示 ${chapters.length} 章;点击可查看该章热门划线。
${chapters.map(chapter => `${String(chapter.idx).padStart(2, '0')} ${esc(chapter.title)}${chapter.words ? ` · ${Math.max(1, Math.round(chapter.words / 1000))} 千字` : ''}
`).join('') || '接口未返回目录。
'}${data.chapters.length > chapters.length ? `` : ''}`;
}
if (bookTab === 'notes') {
const notes = data.notes.items || [];
const counts = data.notes.counts;
return `${counts.highlights}条划线${counts.thoughts}条想法${counts.bookmarks}个书签
${notes.map(note => `${note.type === 'thought' ? '个人想法' : '划线'}${esc(note.text)}
${esc(note.date || '')}${note.chapter ? ' · ' + esc(note.chapter) : ''}${note.url ? `
` : ''}
`).join('') || '这本书暂无个人笔记。
'}`;
}
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 `${selectedChapter ? esc((data.chapters.find(chapter => String(chapter.uid) === String(selectedChapter)) || {}).title || '所选章节') : '全书'} · 热门划线最多展示 20 条
${items.map(item => `${item.count} 人划线${esc(item.text)}
${esc(item.chapter || '')}${marks.total ? ` · 全书共 ${marks.total} 条热门划线` : ''}`).join('') || '接口未返回热门划线。
'}章节热度提供人数与位置;原文来自热门划线接口。
`;
}
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') +
`共 ${payload.count || list.length} 条公开点评
` +
(list.map(review => `${esc(review.name)}${review.is_finish ? ' · 已读完' : ''}${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}
${esc(review.text)}
${esc(review.date || '')}${review.chapter ? ' · ' + esc(review.chapter) : ''}
`).join('') || '该筛选下接口没有返回点评。
');
}
const similar = data.similar || [];
return `相似推荐 · 实时来自微信读书「相似书籍」接口
${similar.map(bookCard).join('') || '
接口未返回相似推荐。
'}
`;
}
function openThought(text, subtitle) { showDetail('想法详情', `内容${esc(text)}
${esc(subtitle)}`); }
async function openThoughtDetail(reviewId, bookId) {
showDetail('想法详情', statusBox('loading', '正在读取想法详情…', '正文、点赞与评论。'));
try {
const data = await WeApi.thought(reviewId);
showDetail('想法详情', `我的想法${esc(data.text)}
${data.abstract ? `划线原文:${esc(data.abstract)}
` : ''}${esc(data.date || '')}${data.chapter ? ' · ' + esc(data.chapter) : ''}${data.like_count}个赞${data.comment_count}条评论
${(data.comments || []).map(comment => `${esc(comment.name)}${esc(comment.date || '')}
${esc(comment.text)}
`).join('') || '这条想法还没有评论。
'}${bookId ? `` : ''}
`);
} 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('划线下的想法', `该划线范围内取到 ${items.length} 条想法
` + (items.map(item => `${esc(item.name)}${esc(item.date || '')}
${esc(item.text)}
`).join('') || '这条划线下暂无他人想法。
'));
} 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; 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('年度回顾', `${year.year} / A YEAR IN BOOKS
翻过的每一页,都算数。
${esc(year.duration_str)} · ${year.read_days} 个阅读日${year.read_count ? ' · 读过 ' + esc(year.read_count) : ''}${year.finish_count ? ' · 读完 ' + esc(year.finish_count) : ''}
${esc(year.prefer_category || '')}${year.prefer_time ? ',' + esc(year.prefer_time) : ''}。以上来自微信读书阅读统计接口。
${(year.top_books || []).map(book => `${esc(book.title)}${esc(book.read_time_str)}
`).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 => {
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();