feat(reading-goals): 持久化阅读目标并收紧看板布局

This commit is contained in:
admin committed 2026-09-12 17:16:40 +08:00
1 parent 61d3951905
commit 094e1aca3d
13 files changed
+834 -39

No files matched your search

+119 -30
View File
@@ -24,8 +24,8 @@ let recommendOffset = 0;
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
let editingGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '';
let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '', goalBusy = false;
/* 接口数据缓存 */
let books = [];
@@ -43,15 +43,15 @@ let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存
const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
let goals = [];
let goals = [], legacyGoals = [];
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
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');
goals = parsed;
legacyGoals = parsed;
}
} catch { storageWarning = '本地目标暂时无法读取,已重置为空白;原存储未修改。'; }
} catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; }
/* ------------------------------- 通用组件 ------------------------------- */
@@ -59,11 +59,6 @@ function toast(message) {
clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden');
toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500);
}
function persistGoals(next) {
try { localStorage.setItem(storageKey, JSON.stringify(next)); }
catch { $('#goal-error').textContent = '浏览器无法保存数据,请允许本地存储后重试。'; return false; }
goals = next; return true;
}
function statusBox(kind, title, text, action) {
const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO';
const button = action ? `<button class="secondary" data-action="${action}">${kind === 'error' ? '重试' : '继续'}</button>` : '';
@@ -97,6 +92,7 @@ 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}%` : '在读';
@@ -274,6 +270,7 @@ function renderTrend(data) {
}
function summaryGoals() {
if (loadState.goals !== 'ready') return goalLoadMessage();
return `<section class="card panel summary-goals"><div class="section-head"><h2>我的阅读目标</h2><button class="text-button" data-action="page" data-id="goals">全部 ↗</button></div>${goals.slice(0, 3).map(goal => { const m = goalMetrics(goal); return `<button class="goal-summary-row" data-action="goal-detail" data-id="${esc(goal.id)}"><strong>${esc(goal.name)}</strong><small>${m.done} / ${m.total} 本已读完 · ${m.status}</small><div class="progress" style="--progress:${m.percent}%"><i></i></div></button>`; }).join('') || '<p class="hint">还没有阅读目标,创建一份属于你的清单。</p>'}<button class="text-button section-space" data-action="new-goal">+ 新建阅读目标</button></section>`;
}
@@ -365,29 +362,64 @@ function openList(id) {
/* ------------------------------- 阅读目标 ------------------------------- */
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 = uniqueIds(goal.ids), done = ids.filter(id => findBook(id).status === 'done').length;
const ids = [...new Set(goal.ids)], done = ids.filter(id => goalBook(id).status === 'done').length;
const status = ids.length && done === ids.length ? '已完成' : date < goal.start ? '未开始' : date > goal.end ? '已到期' : '进行中';
return { total: ids.length, done, percent: ids.length ? Math.round((done / ids.length) * 100) : 0, status };
}
function goalCard(goal) {
const result = goalMetrics(goal);
return `<article class="card goal-card"><div class="section-head"><span class="pill ${result.status === '已完成' ? 'green' : result.status === '已到期' ? 'amber' : ''}">${result.status}</span><button class="text-button" data-action="edit-goal" data-id="${esc(goal.id)}" aria-label="编辑${esc(goal.name)}">编辑 ↗</button></div><h3>${esc(goal.name)}</h3><div class="goal-date">${esc(goal.start)} — ${esc(goal.end)}</div><div class="cover-stack">${goal.ids.slice(0, 4).map(id => cover(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>`;
return `<article class="card goal-card"><div class="section-head"><span class="pill ${result.status === '已完成' ? 'green' : result.status === '已到期' ? 'amber' : ''}">${result.status}</span><button class="text-button" data-action="edit-goal" data-id="${esc(goal.id)}" aria-label="编辑${esc(goal.name)}">编辑 ↗</button></div><h3>${esc(goal.name)}</h3><div class="goal-date">${esc(goal.start)} — ${esc(goal.end)}</div><div class="cover-stack">${goal.ids.slice(0, 4).map(id => cover(goalBook(id), true)).join('')}${result.total > 4 ? `<span class="stack-more">+ ${result.total - 4} 本</span>` : ''}</div><div class="goal-progress"><span><strong>${result.done}</strong> / ${result.total} 本已读完</span><span>${result.percent}%</span></div><div class="progress" style="--progress:${result.percent}%"><i></i></div><div class="goal-card-footer"><small>${goal.sources.length ? `来自 ${goal.sources.length} 个书单 · 可混合单书` : '按本选书 · 自己定义节奏'}</small><button data-action="goal-detail" data-id="${esc(goal.id)}">查看清单 →</button></div></article>`;
}
function renderGoals() {
if (!shelfData) ensureShelf();
if (loadState.goals !== 'ready') return goalLoadMessage();
if (!shelfData && loadState.shelf !== 'error') ensureShelf();
const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成'));
return welcome('把想读,变成读过', '几本书、一份书单,或一段想留给阅读的时间。', '<button class="primary" data-action="new-goal">+ 新建阅读目标</button>') +
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(goal => goalMetrics(goal).status === '已完成').length}</strong>个已完成</span><span><strong>${uniqueIds(goals.flatMap(goal => goal.ids)).length}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>` +
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(goal => goalMetrics(goal).status === '已完成').length}</strong>个已完成</span><span><strong>${new Set(goals.flatMap(goal => goal.ids)).size}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>` +
`${storageWarning ? `<p class="hint">${esc(storageWarning)}</p>` : ''}${legacyGoals.length ? `<p class="hint">发现此浏览器的 ${legacyGoals.length} 个旧目标。<button class="text-button" data-action="import-goals">导入已有目标</button> · 数据库已有目标会保留,原备份也会保留</p>` : ''}` +
tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') +
`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
}
function openGoalDetail(id) {
const goal = goals.find(g => g.id === id), m = goalMetrics(goal);
showDetail('阅读目标', `<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${esc(goal.start)} — ${esc(goal.end)}</span></div><h2>${esc(goal.name)}</h2><div class="metrics-row"><span><strong>${m.done} / ${m.total}</strong>本已读完</span><span><strong>${m.percent}%</strong>已完成</span></div><p class="hint">已读完直接计入完成,不受目标日期影响。</p>${goal.sources.length ? `<p class="source-tag">加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目</p>` : ''}<div class="row-list">${goal.ids.map(id => bookRow(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button></div>`);
showDetail('阅读目标', `<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${esc(goal.start)} — ${esc(goal.end)}</span></div><h2>${esc(goal.name)}</h2><div class="metrics-row"><span><strong>${m.done} / ${m.total}</strong>本已读完</span><span><strong>${m.percent}%</strong>已完成</span></div><p class="hint">已读完直接计入完成,不受目标日期影响。</p>${goal.sources.length ? `<p class="source-tag">加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目</p>` : ''}<div class="row-list">${goal.ids.map(id => bookRow(goalBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button><button class="secondary" data-action="delete-goal" data-id="${esc(id)}">删除目标</button></div>`);
}
function openGoalEditor(id = null, prefill = [], sources = []) {
if (goalBusy) return;
if (!requireGoalsReady()) return;
$('#detail').close(); editingGoalId = id;
// 新建重试沿用同一个 ID,防止响应丢失时重复创建目标。
draftGoalId = id || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
const goal = id ? goals.find(g => g.id === id) : null;
draftIds = new Set(goal ? goal.ids : prefill); draftSources = [...(goal ? goal.sources : sources)];
$('#editor-title').textContent = goal ? '编辑阅读目标' : '新建阅读目标'; $('#goal-name').value = goal ? goal.name : '';
@@ -395,34 +427,83 @@ function openGoalEditor(id = null, prefill = [], sources = []) {
$('#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 selectedGoalBooks() { return [...draftIds]; }
function renderPicker() {
const query = $('#picker-search').value.trim().toLowerCase();
if (!shelfData) { $('#goal-picker').innerHTML = '<p class="hint">正在同步书架…</p>'; ensureShelf(); return; }
const matched = books.filter(book => `${book.title}${book.author}${book.category}`.toLowerCase().includes(query));
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 => findBook(id).status === 'done').length} 本已读完`;
$('#goal-picker').innerHTML = `<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => `<button type="button" data-action="pick-list" data-id="${esc(l.id)}" class="${l.ids.every(id => draftIds.has(id)) ? 'active' : ''}">${l.ids.every(id => draftIds.has(id)) ? '✓' : '+'} ${esc(l.name)} · ${l.book_count || l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(book => `<label class="pick-row"><input type="checkbox" data-pick-book="${esc(book.id)}" ${draftIds.has(book.id) ? 'checked' : ''}><span><b>${esc(book.title)}</b><small>${esc(book.author)}</small></span><span class="pill ${book.status === 'done' ? 'green' : ''}">${esc(stateText(book))}</span></label>`).join('') || '<div class="empty">没有匹配的书籍</div>'}</div>${matched.length > list.length ? `<p class="hint">共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。</p>` : ''}`;
$('#selection-count').textContent = `已选 ${draftIds.size} 本 · ${[...draftIds].filter(id => goalBook(id).status === 'done').length} 本已读完`;
$('#goal-picker').innerHTML = `${!shelfData ? '<p class="hint">书架尚未同步,已选书籍仍会保留。</p>' : ''}<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => `<button type="button" data-action="pick-list" data-id="${esc(l.id)}" class="${l.ids.every(id => draftIds.has(id)) ? 'active' : ''}">${l.ids.every(id => draftIds.has(id)) ? '✓' : '+'} ${esc(l.name)} · ${l.book_count || l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(book => `<label class="pick-row"><input type="checkbox" data-pick-book="${esc(book.id)}" ${draftIds.has(book.id) ? 'checked' : ''}><span><b>${esc(book.title)}</b><small>${esc(book.author)}</small></span><span class="pill ${book.status === 'done' ? 'green' : ''}">${esc(stateText(book))}</span></label>`).join('') || '<div class="empty">没有匹配的书籍</div>'}</div>${matched.length > list.length ? `<p class="hint">共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。</p>` : ''}`;
}
function saveGoal(event) {
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: editingGoalId || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())), name, start, end, ids, sources: [...new Set(draftSources)] };
const next = editingGoalId ? goals.map(g => (g.id === editingGoalId ? goal : g)) : [goal, ...goals];
if (!persistGoals(next)) return;
$('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存,已读完的书已计入完成。');
const goal = { id: draftGoalId, name, start, end, ids, sources: [...new Set(draftSources)] };
goalBusy = true; $('#goal-form').inert = true; $('#goal-error').textContent = '正在保存…';
try {
const result = await WeApi.saveGoal(goal, Boolean(editingGoalId));
goals = editingGoalId ? goals.map(g => g.id === goal.id ? result.goal : g) : [result.goal, ...goals];
$('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存。');
} catch (error) {
$('#goal-error').textContent = error.message;
} finally {
goalBusy = false; $('#goal-form').inert = false;
}
}
function chooseTarget(ids, sources = []) {
if (loadState.goals !== 'ready') { navigate('goals'); return; }
showDetail('加入阅读目标', `<p class="detail-subtitle">选择已有目标,或为这些书新建目标。重复书籍只加入一次。</p><div class="row-list">${goals.map(g => `<div class="book-row"><div class="book-row-text"><b>${esc(g.name)}</b><p>${esc(g.start)} — ${esc(g.end)} · ${g.ids.length} 本</p></div><button class="secondary" data-action="append-goal" data-id="${esc(g.id)}">加入</button></div>`).join('') || '<div class="empty">还没有目标,先新建一个。</div>'}</div><div class="goal-actions"><button class="primary" data-action="new-with-selection">+ 新建目标</button></div>`);
pendingBooks = ids; pendingSources = sources;
}
function appendGoal(id) {
const next = goals.map(g => (g.id === id ? { ...g, ids: uniqueIds([...g.ids, ...pendingBooks]), sources: [...new Set([...g.sources, ...pendingSources])] } : g));
if (!persistGoals(next)) { toast('无法保存到浏览器,请允许本地存储后重试。'); return; }
renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。');
async function appendGoal(id) {
if (goalBusy) return;
const goal = goals.find(g => g.id === id);
const next = { ...goal, ids: [...new Set([...goal.ids, ...pendingBooks])], sources: [...new Set([...goal.sources, ...pendingSources])] };
goalBusy = true;
try {
const result = await WeApi.saveGoal(next, true);
goals = goals.map(g => g.id === id ? result.goal : g);
renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。');
} catch (error) {
toast(error.message);
} finally { goalBusy = false; }
}
function confirmGoalDelete(id) {
const goal = goals.find(g => g.id === id);
showDetail('删除阅读目标', `<p>确定删除“${esc(goal.name)}”?删除后无法恢复,书架中的书籍不受影响。</p><div class="goal-actions"><button class="secondary" data-action="goal-detail" data-id="${esc(id)}">取消</button><button class="primary" data-action="confirm-delete-goal" data-id="${esc(id)}">确认删除</button></div>`);
}
async function deleteGoal(id) {
if (goalBusy) return;
goalBusy = true;
try {
await WeApi.deleteGoal(id);
goals = goals.filter(g => g.id !== id);
$('#detail').close(); renderPage(); toast('阅读目标已删除。');
} catch (error) {
toast(error.message);
} finally { goalBusy = false; }
}
async function importGoals() {
if (goalBusy || !legacyGoals.length) return;
goalBusy = true;
try {
const result = await WeApi.importGoals(legacyGoals);
// 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。
legacyGoals = [];
try { localStorage.setItem(`${storageKey}-imported`, '1'); }
catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; }
await ensureGoals();
toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`);
} catch (error) {
toast(error.message);
} finally { goalBusy = false; if (page === 'goals') renderPage(); }
}
/* ------------------------------- 笔记与划线 ------------------------------- */
@@ -600,11 +681,16 @@ async function openHighlightThoughts(bookId, chapterUid, range) {
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;
@@ -709,13 +795,16 @@ document.addEventListener('compositionend', event => {
});
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);
+22 -2
View File
@@ -10,12 +10,16 @@
const BASE = '/api/v2';
async function request(path, params) {
async function request(path, params, method = 'GET', body) {
const url = new URL(BASE + path, global.location.origin);
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
});
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
const response = await fetch(url.toString(), {
method,
headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
let payload = null;
try {
payload = await response.json();
@@ -56,6 +60,22 @@
return request('/status');
},
goals() {
return request('/goals');
},
saveGoal(goal, editing = false) {
return request(editing ? `/goals/${encodeURIComponent(goal.id)}` : '/goals', null, editing ? 'PUT' : 'POST', goal);
},
deleteGoal(id) {
return request(`/goals/${encodeURIComponent(id)}`, null, 'DELETE');
},
importGoals(goals) {
return request('/goals/import', null, 'POST', { goals });
},
/**
* 书架数据
* @param {string} tab all|reading|done|wish