/* ========================================================================== 阅间 · 微信读书看板 —— 交互逻辑 -------------------------------------------------------------------------- 视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。 差异只在于数据来源:设计稿读取 demo-data.js 的静态示例, 这里改为调用 /api/reading/* 实时接口。 ========================================================================== */ 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'; const legacyStorageKeys = [storageKey, 'weread-goals-' + 'v' + '2']; const PICKER_LIMIT = 60; const SHELF_PAGE = 48; /* ------------------------------- 运行时状态 ------------------------------ */ let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week'; let searchComposing = false, discoverSearchTimer = null; let recommendOffset = 0; let noteBook = 'all', noteType = 'all', goalFilter = 'all'; let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0; let 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 = [], legacyGoalStorageKey = ''; try { for (const key of legacyStorageKeys) { const saved = localStorage.getItem(key); if (!saved || localStorage.getItem(`${key}-imported`)) continue; const parsed = JSON.parse(saved); if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid'); legacyGoals = parsed; legacyGoalStorageKey = key; break; } } catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; } /* ------------------------------- 通用组件 ------------------------------- */ function toast(message) { clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden'); toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500); } function statusBox(kind, title, text, action) { const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO'; const button = action ? `` : ''; 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.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 `${esc(book.title)}`; } 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) => ``).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}
${esc(s.hint)}
`).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))} · 微信读书统计接口
` + `
${stats}
` + `
${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(`${legacyGoalStorageKey || storageKey}-imported`, '1'); } catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; } await ensureGoals(); toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`); } catch (error) { toast(error.message); } finally { goalBusy = false; if (page === 'goals') renderPage(); } } /* ------------------------------- 笔记与划线 ------------------------------- */ function renderNotes() { if (!notebooksData && loadState.notes.__list !== 'error') { ensureNotebooks(); return statusBox('loading', '正在读取笔记本…', '来自微信读书的笔记本概览。'); } if (!notebooksData) return statusBox('error', '暂时无法读取笔记本', loadError.notes.__list || '', 'retry-notes'); if (!notesData[noteBook] && loadState.notes[noteBook] !== 'loading' && loadState.notes[noteBook] !== 'ready' && loadState.notes[noteBook] !== 'error') ensureNotes(noteBook); const payload = notesData[noteBook]; const items = notebooksData.items || []; const menuBooks = items.slice(0, 10); const counts = (payload && payload.counts) || { highlights: 0, thoughts: 0, bookmarks: 0 }; const notes = (payload && payload.notes) || []; const shownNotes = filteredNotes(notes); const noteSlice = shownNotes.slice(0, notesLimit); const body = !payload ? (loadState.notes[noteBook] === 'error' ? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes') : statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。')) : `
${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} 条。`, '') + `
${body}
`; } 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,或直接选中下方内容复制;不含书签内容。

下载 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();