Files
WeReadBoard/web-demo/app.js
T

274 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = selector => document.querySelector(selector);
const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-demo-goals-v2';
let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week';
let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready';
let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all';
let editingGoalId = null, draftIds = new Set(), draftSources = [], goals = initialGoals.map(goal => ({...goal, ids:[...goal.ids], sources:[...goal.sources]}));
let toastTimer, storageWarning = '';
// 本地数据损坏时保留原存储并恢复示例;保存前仍要求用户通过有效表单提交。
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.map(goal => ({...goal,ids:uniqueIds(goal.ids)}));
}
} 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 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-demo-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); }
$('#theme').onclick = () => {
const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme);
try { localStorage.setItem('weread-demo-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); }
};
function cover(book, mini = false) {
if (mini) return `<div class="mini-cover ${book.cover}" aria-hidden="true"><b>${esc(book.title)}</b><small>${esc(book.author)}</small></div>`;
return `<div class="cover ${book.cover}"><span class="tiny">${book.en}</span><span class="title">${book.short.replaceAll('\n','<br>')}</span><span class="author">${book.author}</span><span class="publisher">READING SPACE · 示意封面</span></div>`;
}
function bookCard(book) {
return `<button class="book" data-action="book" data-id="${book.id}" aria-label="查看${book.title}详情">${cover(book)}<h3>${book.title}</h3><p>${book.author}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div><div class="book-meta"><span>${book.status === 'done' ? '已读完' : book.status === 'wish' ? '想读' : `已读 ${book.progress}%`}</span><span>评分 ${Math.round(Number(book.score)*10)}/100</span></div></button>`;
}
function bookRow(book, source = '') {
return `<div class="book-row">${cover(book,true)}<div class="book-row-text"><b>${book.title}</b><p>${book.author}${source ? ' · '+esc(source) : ''}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div></div><span class="book-row-status">${book.status === 'done' ? '已读完' : `${book.progress}%`}</span><button class="text-button" data-action="book" data-id="${book.id}" aria-label="查看${book.title}详情">详情 ↗</button></div>`;
}
function welcome(title, subtitle, action = '') {
return `<section class="welcome"><div><div class="eyebrow">A LITTLE READING, EVERY DAY</div><h1>${title}</h1><p>${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="${selected === id ? 'active':''}" aria-pressed="${selected === id}" data-action="${action}" data-id="${id}">${label}</button>`).join('')}</div>`;
}
// 已读状态与目标日期无关;先按书籍 ID 去重,再计算完成率。
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">${goal.start} — ${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() {
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(g=>goalMetrics(g).status==='已完成').length}</strong>个已完成</span><span><strong>${uniqueIds(goals.flatMap(g=>g.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 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('')}<button class="text-button section-space" data-action="new-goal">+ 新建阅读目标</button></section>`;
}
function renderOverview() {
const values = {week:[42,65,33,57,86,71,48],month:[48,54,65,38,70,52,64,75,68,42,46],year:[8,11,14,9,18,15,12,14,27.7],overall:[75,96,128.7]}[period];
const labels = {week:['一','二','三','四','五','六','日'],month:['1日','2日','3日','4日','5日','6日','7日','8日','9日','10日','11日'],year:['1月','2月','3月','4月','5月','6月','7月','8月','9月'],overall:['2024','2025','2026']}[period];
const max = Math.ceil(Math.max(...values)/10)*10;
const totalMinutes = values.reduce((a,b)=>a+b,0)*(period==='year'||period==='overall'?60:1);
const duration = `${Math.floor(totalMinutes/60)}小时${Math.round(totalMinutes%60)}分钟`;
const days = {week:7,month:11,year:132,overall:412}[period];
const naturalDays = {week:7,month:11,year:254,overall:985}[period];
const units = period==='year'||period==='overall'?'时':'分';
const stats = [['阅读 / 收听时长',duration,'当前所选周期','clock'],['有效阅读天数',`${days}<small>天</small>`,'单日满 1 分钟计一天','flag'],['自然日均阅读',`${Math.round(totalMinutes/naturalDays)}<small>分钟</small>`,'按周期自然日计算','chart'],['笔记总数','10<small>条</small>','划线 2 · 想法 2 · 书签 6','note']];
return welcome('在字里行间,遇见自己','阅读、目标与灵感,在这里慢慢积累。','<button class="primary" data-action="new-goal">+ 新建目标</button>')+
`<div class="toolbar">${tabs([['week','本周'],['month','本月'],['year','本年'],['overall','总计']],period,'period')}<span class="hint">统计示例 · ${period==='overall'?'2024—2026':'2026 年'}</span></div><section class="stats">${stats.map(([label,value,hint,icon])=>`<article class="card stat"><div class="stat-label">${label}<svg><use href="#${icon}"/></svg></div><div class="stat-value compact-value">${value}</div><div class="stat-foot">${hint}</div></article>`).join('')}</section>
<div class="columns"><div><section class="card trend"><div class="section-head"><h2>阅读的日常 <small>每一天,都算数</small></h2><span class="hint">${period==='year'?'按月':period==='overall'?'按年':'按日'}</span></div><div class="trend-total">${duration}<small>阅读与收听合计</small></div><div class="chart"><div class="gridlines">${[max,Math.round(max*2/3),Math.round(max/3),0].map(n=>`<i data-label="${n}${n?units:''}"></i>`).join('')}</div><div class="bars">${values.map((v,i)=>`<div class="bar" style="--h:${v/max*100}%" data-tip="${v}${units}" role="img" aria-label="${labels[i]} ${v}${units}"><span>${labels[i]}</span></div>`).join('')}</div></div></section><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">${books.filter(b=>b.status==='reading').map(bookCard).join('')}</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>${books.slice(0,3).map((b,i)=>`<div class="insight-line"><b>${i+1}. ${b.title}</b><span>${[2,1,1][i]}小时${[12,35,6][i]}分钟</span></div>`).join('')}</section><section class="card panel insight"><h3>偏爱的阅读方向</h3>${[['文学',80],['心理学',60],['科幻',40]].map(([label,val])=>`<div class="insight-line"><b>${label}</b><span>相对偏好 ${val}</span></div><div class="progress" style="--progress:${val}%"><i></i></div>`).join('')}<p class="hint section-space">相对偏好权重,不是阅读占比</p></section><section class="card panel insight"><h3>留给自己的时段</h3><div class="hour-bars">${Array.from({length:24},(_,i)=>`<i style="--height:${[10,20,15,12,20,35,28,24,12,10,16,20,30,35,40,65,95,85,40,15,6,4,3,5][i]}%"></i>`).join('')}</div><div class="insight-line"><span>06:00</span><span>18:00</span><span>次日 05:00</span></div><p class="hint">偏好夜间阅读 · 示例时段分布</p></section></div>
<details class="insight-detail card panel"><summary>更多阅读画像 · 作者、听书、勋章与年度回顾</summary><div class="insight-grid"><div><h3>偏好作者与出版方</h3><div class="insight-line"><b>刘慈欣</b><span>3 本 · 5小时30分钟</span></div><div class="insight-line"><b>示例出版社</b><span>4 本</span></div><div class="insight-line"><b>示例版权方</b><span>3 本</span></div></div><div><h3>阅读与收听</h3><div class="insight-line"><b>文字阅读</b><span>80%</span></div><div class="progress" style="--progress:80%"><i></i></div><p class="hint section-space">示例分布;正式接入只在接口返回时展示。</p></div><div><h3>阅读足迹</h3><div class="insight-line"><b>好友排行</b><span>本周第 3 · 示例</span></div><div class="insight-line"><b>勋章</b><span>初读 · 笔记 · 坚持</span></div><button class="text-button" data-action="year-report">年度回顾示意 →</button></div></div></details>`;
}
function listCard(list, index) {
return `<button class="card list-card" data-action="list" data-id="${list.id}"><div class="list-art">${list.ids.slice(0,3).map(id=>cover(findBook(id),true)).join('')}</div><h3><span class="list-index">${String(index+1).padStart(2,'0')}</span>${list.name}</h3><p>${list.description}</p><small>${list.ids.length} 本书 · ${list.source}</small></button>`;
}
function renderLists(lists) { return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单,换个关键词试试。</div>'}</div>`; }
function renderShelf() {
let content;
if (shelfTab==='lists') content=renderLists(booklists.filter(l=>l.source==='我的书单'));
else if (shelfTab==='audio') content=`<article class="card media-card"><div class="media-icon">♫</div><div><h3>晚间听书 · 文学选读</h3><p>示例演播者 · 24 集 · 内容已完结</p><p class="hint">内容完结不代表已听完;当前不纳入按本阅读目标。</p></div></article>`;
else if(shelfTab==='articles') content=`<article class="card media-card"><div class="media-icon">▤</div><div><h3>文章收藏</h3><p>这是书架中的收藏入口。</p><p class="hint">当前书架接口不提供入口内文章列表;可在发现页搜索文章。</p><button class="text-button" data-action="article-search">去搜索文章 →</button></div></article>`;
else content=`<div class="card panel"><div class="books full-books">${books.filter(b=>shelfTab==='all'||b.status===shelfTab).map(bookCard).join('')}</div></div>`;
return welcome('我的书架','8 本电子书 · 1 个有声专辑 · 1 个文章收藏入口,共 10 个书架条目。')+tabs([['all','全部电子书 8'],['reading','在读 4'],['done','已读完 2'],['wish','想读 2'],['lists','我的书单 2'],['audio','有声书 1'],['articles','文章收藏']],shelfTab,'shelf-tab')+content;
}
function openList(id) {
const list=booklists.find(l=>l.id===id);
showDetail('书单详情',`<div class="discovery-hero"><div class="eyebrow">A COLLECTION OF GOOD BOOKS</div><h2>${list.name}</h2><p>${list.description}</p><p class="source-tag">${list.source} · ${list.ids.length} 本 · 示例成员</p></div><div class="row-list">${list.ids.map(id=>bookRow(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="add-list" data-id="${id}">加入阅读目标</button></div>${list.source.startsWith('公开')?'<p class="hint section-space">公开书单成员为设计示意,完整成员获取方式待真实接口验证。</p>':''}`);
}
function showDetail(title, html) { $('#detail-title').textContent=title;$('#detail-content').innerHTML=html;if(!$('#detail').open)$('#detail').showModal(); }
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">${goal.start} — ${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();
const list=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(query));
$('#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(b=>b.id===id))).map(l=>`<button type="button" data-action="pick-list" data-id="${l.id}" class="${l.ids.every(id=>draftIds.has(id))?'active':''}">${l.ids.every(id=>draftIds.has(id))?'✓':'+'} ${l.name} · ${l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(b=>`<label class="pick-row"><input type="checkbox" data-pick-book="${b.id}" ${draftIds.has(b.id)?'checked':''}><span><b>${b.title}</b><small>${b.author}</small></span><span class="pill ${b.status==='done'?'green':''}">${b.status==='done'?'已读完':b.status==='wish'?'想读':b.progress+'%'}</span></label>`).join('')||'<div class="empty">没有匹配的书籍</div>'}</div>`;
}
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(),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>${g.start} — ${g.end} · ${g.ids.length} 本</p></div><button class="secondary" data-action="append-goal" data-id="${esc(g.id)}">加入</button></div>`).join('')}</div><div class="goal-actions"><button class="primary" data-action="new-with-selection">+ 新建目标</button></div>`);
pendingBooks=ids;pendingSources=sources;
}
let pendingBooks=[],pendingSources=[];
function appendGoal(id) {
const next=goals.map(g=>g.id===id?{...g,ids:uniqueIds([...g.ids,...pendingBooks]),sources:[...new Set([...g.sources,...pendingSources])]}:g);
try { localStorage.setItem(storageKey,JSON.stringify(next)); } catch { toast('无法保存到浏览器,请允许本地存储后重试。');return; }
goals=next;renderPage();openGoalDetail(id);toast('已加入目标,重复书籍已自动去重。');
}
function renderDiscover() {
const options=[['books','书籍'],['lists','书单'],['recommend','为你推荐'],['all','综合'],['author','作者'],['novel','网文'],['audio','听书'],['fulltext','全文'],['account','公众号'],['article','文章']];
const matched=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(searchQuery.toLowerCase()));
let content='';
if(dataState!=='ready') {
const copy={loading:['正在寻找好书','加载状态示意,点击下方按钮查看结果。'],empty:['暂时没有找到','换个关键词,或看看为你推荐。'],error:['暂时无法获取数据','连接失败示意,你的阅读目标仍保存在本地。']}[dataState];
content=`<div class="card status-box"><div class="eyebrow">${dataState==='loading'?'LOADING':dataState==='error'?'TRY AGAIN':'NOT FOUND'}</div><h3>${copy[0]}</h3><p>${copy[1]}</p><button class="secondary" data-action="retry">${dataState==='error'?'重试':'查看演示结果'}</button></div>`;
} else if(discoverTab==='lists') content=renderLists(booklists.filter(l=>`${l.name}${l.description}`.includes(searchQuery)));
else if(discoverTab==='books'||discoverTab==='recommend') content=`${discoverTab==='recommend'?'<div class="discovery-hero"><div class="eyebrow">PICKED FOR YOUR NEXT CHAPTER</div><h2>读过的书,会带你遇见下一本。</h2><p>根据你对文学与心理学的兴趣推荐 · 推荐理由为示例</p></div>':''}<div class="card panel"><div class="books full-books">${(discoverTab==='recommend'?matched.filter(b=>b.status==='wish'||b.id==='b0'):matched).map(bookCard).join('')||'<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
else if(discoverTab==='author') content=`<div class="card panel">${[...new Set(matched.map(b=>b.author))].map(name=>`<div class="book-row"><div class="avatar">${name.slice(0,1)}</div><div class="book-row-text"><b>${name}</b><p>作者结果 · 示例</p></div><button class="text-button" data-action="author-books" data-name="${esc(name)}">查看书籍 →</button></div>`).join('')||'<div class="empty">没有匹配的作者。</div>'}</div>`;
else if(discoverTab==='all') content=`<div class="section-head"><h2>书籍</h2></div><div class="card panel"><div class="books">${matched.slice(0,4).map(bookCard).join('')||'<div class="empty">没有匹配的书籍。</div>'}</div></div><div class="section-head section-space"><h2>书单</h2></div>${renderLists(booklists.filter(l=>l.name.includes(searchQuery)))}`;
else {
const examples={novel:['星河来信','网文小说 · 示例作品','连载中 · 128 章'],audio:['夜晚的文学课','有声专辑 · 示例演播者','24 集 · 内容已完结'],fulltext:['关于阅读的一段文字','全文搜索 · 原创示例文本','阅读让日常拥有了更宽阔的边界。'],account:['一页书房','公众号 · 示例账号','记录阅读、日常与思考'],article:['如何为自己留一点阅读时间','文章 · 原创示例','2026-09-11 · 一页书房']}[discoverTab];
const matches=!searchQuery||examples.join('').includes(searchQuery);
content=matches?`<div class="card media-card"><div class="media-icon">${discoverTab==='audio'?'♫':'▤'}</div><div><h3>${examples[0]}</h3><p>${examples[1]}</p><p>${examples[2]}</p><button class="text-button" data-action="search-example" data-id="${discoverTab}">查看结果示意 →</button></div></div>`:'<div class="card empty">没有匹配的演示内容,可清空关键词查看该类型示意。</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="data-state" aria-label="预览数据状态">${[['ready','正常'],['loading','加载中'],['empty','空结果'],['error','失败']].map(([id,label])=>`<option value="${id}" ${dataState===id?'selected':''}>${label}</option>`).join('')}</select></label></div>${tabs(options,discoverTab,'discover-tab')}<div id="discover-results">${content}</div><p class="hint section-space">搜索、推荐、书单均为设计样例,未请求微信读书服务。</p>`;
}
function renderNotes() {
const list=personalNotes.filter(note=>(noteBook==='all'||note.bookId===noteBook)&&(noteType==='all'||note.type===noteType));
const counts=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook);
const bookmarks=noteBook==='all'?6:noteBook==='b0'?3:noteBook==='b1'?2:noteBook==='b2'?1:0;
return welcome('把读过的,留在心里','划线、想法与每一次停顿,都有自己的意义。','<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>4 本书 · 10 条笔记</small></button>${books.slice(0,4).map((b,i)=>`<button data-action="notebook" data-id="${b.id}" class="${noteBook===b.id?'active':''}"><b>${b.title}</b><small>${[4,3,2,1][i]} 条 · 含书签数量</small></button>`).join('')}</aside><div><div class="metrics-row"><span><strong>${counts.length+bookmarks}</strong>条笔记</span><span>划线 ${counts.filter(n=>n.type==='highlight').length}</span><span>想法 ${counts.filter(n=>n.type==='thought').length}</span><span>书签 ${bookmarks}</span></div>${tabs([['all','全部内容'],['highlight','划线'],['thought','想法 / 个人点评'],['bookmark','书签数量']],noteType,'note-type')}${noteType==='bookmark'?`<div class="card empty">${bookmarks} 个书签 · 当前接口只提供数量,不提供书签内容。</div>`:list.map(note=>`<article class="note-content"><span class="pill ${note.type==='thought'?'green':''}">${note.type==='thought'?'我的想法':'划线示意'}</span><blockquote>${note.text}</blockquote><small>《${findBook(note.bookId).title}》 · ${note.date}</small><p class="hint">原创示例文本,非书中原文</p><button class="text-button section-space" data-action="personal-note" data-id="${note.id}">查看笔记详情 →</button></article>`).join('')||'<div class="card empty">当前筛选没有笔记。</div>'}<p class="hint">总笔记数包含书签;导出包含划线和个人想法,不含公开点评及书签内容。</p></div></div>`;
}
function exportNotes() {
// 导出按当前选中书目取划线和想法全集,内容类型筛选不影响导出完整性。
const notes=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook);
const text='# 阅间笔记 · 演示数据\n\n原创示例,非书中原文。书签仅提供数量,不导出内容。\n\n'+notes.map(n=>`## ${findBook(n.bookId).title}\n\n${n.type==='thought'?'想法':'划线示意'} · ${n.date}\n\n${n.text}`).join('\n\n');
const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'}));
showDetail('导出笔记', `<p class="hint">已生成当前书目的划线与想法。可下载 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 openBook(id) { detailBook=id;bookTab='info';selectedChapter='all';renderBookDetail(); }
function renderBookDetail() {
const book=findBook(detailBook);
showDetail('书籍详情',`<div class="detail-body">${cover(book)}<div class="detail-info"><div class="eyebrow">READING SPACE / BOOK</div><h3>${book.title}</h3><p>${book.author}</p><p style="color:var(--green)">评分 ${Math.round(Number(book.score)*10)}/100 · 示例评分</p><p>${book.status==='done'?'已读完':`已读 ${book.progress}%`} · 累计 2小时18分钟</p><button class="primary" data-action="add-book" data-id="${book.id}">+ 加入阅读目标</button></div></div><div class="detail-tabs">${[['info','简介'],['chapters','目录'],['notes','我的笔记'],['popular','热门划线'],['reviews','读者点评'],['similar','相似推荐']].map(([id,label])=>`<button data-action="book-tab" data-id="${id}" class="${bookTab===id?'active':''}">${label}</button>`).join('')}</div><div id="book-tab-content">${renderBookTab(book)}</div><p class="hint section-space">全部为设计示例 · 目录、文字和读者资料非真实接口内容。</p>`);
}
function renderBookTab(book) {
if(bookTab==='info')return `<p class="detail-desc">${book.desc}</p><div class="book-details-grid section-space"><div><span>分类:</span>${book.id==='b1'?'科幻':'文学 / 人文'} · 示例</div><div><span>最近阅读:</span>2026-09-11</div><div><span>出版社:</span>示例出版社</div><div><span>译者 / ISBN:</span>暂无演示数据</div><div><span>字数:</span>约 20 万字 · 示例</div><div><span>出版日期:</span>暂无演示数据</div></div>`;
if(bookTab==='chapters')return `<p class="hint">目录结构示意;选择章节可查看该章热门划线与热度。</p>${['序章 · 故事的开始','第一章 · 在旅途中','第二章 · 看见新的世界','第三章 · 留给自己的答案'].map((name,i)=>`<div class="chapter-row"><span>${String(i+1).padStart(2,'0')} &nbsp; ${name}<small style="color:var(--muted)"> · 示例章节</small></span><button class="text-button" data-action="chapter" data-id="${i}">划线热度 →</button></div>`).join('')}`;
if(bookTab==='notes') {
const notes=personalNotes.filter(n=>n.bookId===book.id);
return notes.map(n=>`<div class="note-content"><span class="pill">${n.type==='thought'?'个人想法':'划线示意'}</span><blockquote>${n.text}</blockquote><small>${n.date} · 原创示例文本</small></div>`).join('')||'<div class="detail-empty">这本书暂无个人笔记示例。</div>';
}
if(bookTab==='popular')return `<div class="toolbar"><span class="hint">${selectedChapter==='all'?'全书':`示例章节 ${Number(selectedChapter)+1}`} · 热门划线最多展示 20 条</span><select id="chapter-filter" aria-label="选择热门划线章节"><option value="all">全部章节</option>${[0,1,2,3].map(i=>`<option value="${i}" ${String(i)===selectedChapter?'selected':''}>示例章节 ${i+1}</option>`).join('')}</select></div>${['阅读让我们有机会,在自己的生活之外再生活一次。','那些认真思考过的问题,会在未来的某一天回应你。'].map((text,i)=>`<article class="note-content"><span class="pill">${[1286,862][i]} 人划线 · 示例热度</span><blockquote>${text}</blockquote><small>原创示例文本,非书中原文</small><div class="section-space"><button class="text-button" data-action="highlight-thoughts" data-id="${i}">查看 ${[12,8][i]} 条想法 →</button></div></article>`).join('')}<p class="hint">章节热度提供人数与位置,原文来自热门划线;这里用示例文本说明展示方式。</p>`;
if(bookTab==='reviews')return tabs([['全部','全部'],['推荐','推荐'],['一般','一般'],['不行','不行'],['最新','最新']],reviewFilter,'review-filter')+publicReviews.filter(r=>reviewFilter==='全部'||reviewFilter==='最新'||r.type===reviewFilter).map(review=>`<article class="review-row"><div class="meta"><b>${review.name} · 示例读者</b><span>${'★'.repeat(review.rating)}${'☆'.repeat(5-review.rating)}</span></div><p>${review.text}</p><div class="section-head section-space"><small class="hint">${review.date} · 公开点评示例</small><button class="text-button" data-action="review-detail" data-id="${review.id}">查看详情 →</button></div></article>`).join('');
return `<p class="hint">相似推荐示例 · 根据主题与阅读兴趣推荐</p><div class="books section-space">${books.filter(b=>b.id!==book.id).slice(0,4).map(bookCard).join('')}</div>`;
}
function openThought(text, subtitle) { showDetail('想法详情',`<article class="note-content"><span class="pill">示例内容</span><blockquote>${esc(text)}</blockquote><small>${esc(subtitle)} · 2026-09-11</small></article><p class="hint">这里展示单条想法的完整内容;不生成未经接口提供的评论或点赞列表。</p>`); }
function handleAction(button) {
const action=button.dataset.action,id=button.dataset.id;
switch(action) {
case 'page':navigate(id);break;
case 'new-goal':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);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);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;renderPage();break;
case 'period':period=id;renderPage();break;
case 'discover-tab':discoverTab=id;renderPage();break;
case 'retry':dataState='ready';renderPage();break;
case 'notebook':noteBook=id;renderPage();break;
case 'note-type':noteType=id;renderPage();break;
case 'export-notes':exportNotes();break;
case 'personal-note':{const note=personalNotes.find(n=>n.id===id);openThought(note.text,findBook(note.bookId).title+' · 个人笔记示例');break;}
case 'book-tab':bookTab=id;renderBookDetail();break;
case 'chapter':selectedChapter=id;bookTab='popular';renderBookDetail();break;
case 'review-filter':reviewFilter=id;renderBookDetail();break;
case 'review-detail':{const r=publicReviews.find(r=>r.id===id);openThought(r.text,r.name+' · 公开点评示例');break;}
case 'highlight-thoughts':showDetail('划线下的想法',`<div class="note-original">${id==='0'?'阅读让我们有机会,在自己的生活之外再生活一次。':'那些认真思考过的问题,会在未来的某一天回应你。'}<p class="hint">原创划线示意</p></div><article class="review-row"><div class="meta"><b>山间来信 · 示例读者</b><span>2026-09-11</span></div><p>这句话让我想起,每一段阅读经历都有自己的时间。</p><button class="text-button section-space" data-action="thought-detail">查看完整想法 →</button></article><p class="hint">仅展示一条想法示例,真实内容需分页获取。</p>`);break;
case 'thought-detail':openThought('这句话让我想起,每一段阅读经历都有自己的时间。','山间来信 · 公开想法示例');break;
case 'author-books':searchQuery=button.dataset.name;discoverTab='books';$('#global-search').value=searchQuery;renderPage();break;
case 'article-search':searchQuery='';discoverTab='article';navigate('discover');break;
case 'search-example':openThought('这是此类搜索结果的详情布局示意。实际内容、可跳转地址及字段以未来接口返回为准。','搜索结果 · '+id);break;
case 'year-report':showDetail('年度回顾',`<div class="discovery-hero"><div class="eyebrow">2026 / A YEAR IN BOOKS</div><h2>翻过的每一页,都算数。</h2><p>128小时42分钟 · 132 个阅读日 · 示例年度数据</p></div><p class="detail-desc">偏爱文学,习惯在夜晚阅读。每一次阅读,都是给自己的时间。</p><p class="hint section-space">年报布局示意,真实年度报告入口只使用服务端返回的链接。</p>`);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==='data-state'){dataState=event.target.value;renderPage();}
if(event.target.id==='chapter-filter'){selectedChapter=event.target.value;renderBookDetail();}
});
$('#goal-start').onchange=()=>{$('#goal-end').min=$('#goal-start').value;};
$('#goal-name').oninput=()=>$('#goal-name').setCustomValidity('');
$('#goal-form').onsubmit=saveGoal;$('#picker-search').oninput=renderPicker;
$('#global-search').oninput=event=>{searchQuery=event.target.value.trim();dataState='ready';if(page!=='discover')discoverTab='books';navigate('discover');};
document.addEventListener('input',event=>{
if(event.target.id==='discover-search'){
const position=event.target.selectionStart;searchQuery=event.target.value;$('#global-search').value=searchQuery;renderPage();
$('#discover-search').focus();$('#discover-search').setSelectionRange(position,position);
}
});
document.addEventListener('keydown',event=>{if((event.metaKey||event.ctrlKey)&&event.key==='k'){event.preventDefault();$('#global-search').focus();}});
// 原生 dialog 提供焦点管理与 Escape 关闭;只有点击实际遮罩时才关闭。
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();
}));
navigate('overview');if(storageWarning)toast(storageWarning);