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

+80 -1
View File
@@ -4,7 +4,7 @@
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;transition:background .25s,color .25s}button,input{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:4px}button{border:0;background:none}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}.sidebar{position:fixed;inset:0 auto 0 0;width:218px;padding:39px 23px 24px;background:var(--sidebar);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:5}.logo{display:flex;align-items:center;gap:11px;font-size:24px;font-weight:650;letter-spacing:2px}.logo-mark{background:#356ce5;color:white;width:37px;height:37px;display:grid;place-items:center;border-radius:12px}.logo-mark svg{width:25px;height:25px}.logo small{display:block;letter-spacing:2px;font-size:9px;color:var(--muted);margin-top:3px;font-weight:500}.navlabel{font-size:10px;color:var(--muted);letter-spacing:1.7px;margin:53px 14px 15px}.nav{display:flex;flex-direction:column;gap:9px}.nav button{text-align:left;padding:14px 17px;border-radius:9px;display:flex;align-items:center;gap:13px;color:#818997}.nav button.active{color:var(--accent);background:var(--soft);font-weight:600}.nav button:hover{background:var(--soft)}.nav em{margin-left:auto;font-size:10px;font-style:normal;background:var(--line);padding:2px 6px;border-radius:4px}.sidebar-bottom{margin-top:auto}.connection{padding:16px 12px;border:1px solid var(--line);border-radius:10px;font-size:12px;line-height:1.9}.dot{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:7px}.connection small{display:block;font-size:10px;color:var(--muted)}.profile{display:flex;align-items:center;gap:10px;margin-top:24px;padding:0 8px}.avatar{width:33px;height:33px;border-radius:50%;display:grid;place-items:center;background:#e4eadf;color:#5e7252;font-family:Georgia;font-size:18px}.profile small{display:block;font-size:10px;color:var(--muted);margin-top:4px}.main{margin-left:218px;padding:0 40px 32px;max-width:1800px}.topbar{height:91px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.breadcrumb{font-size:12px;color:var(--muted)}.breadcrumb span{color:var(--text);margin-left:12px}.top-actions{display:flex;align-items:center;gap:20px}.searchbox{display:flex;align-items:center;gap:10px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:9px 12px}.searchbox input{background:none;border:0;outline:none;width:180px;color:var(--text);font-size:11px}.key{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:2px 4px}.iconbtn{padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.welcome{display:flex;justify-content:space-between;align-items:center;margin:33px 0 27px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);margin-bottom:10px}h1{font-size:27px;letter-spacing:.5px;margin:0 0 10px;font-weight:600}h1 span{font-family:Georgia;font-size:25px;color:#c3a977}p{margin:0;color:var(--muted);font-size:12px;line-height:1.8}.welcome-right{display:flex;gap:12px;align-items:center}.demo-tag{font-size:10px;padding:6px 9px;background:var(--quote);border:1px solid var(--line);border-radius:5px;color:var(--muted)}.primary{background:#356ce5;color:white;padding:11px 16px;border-radius:7px;font-size:12px}.primary svg{width:15px;height:15px;margin-right:6px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:23px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stat{padding:21px 23px}.stat-label{display:flex;justify-content:space-between;align-items:center;color:var(--muted);font-size:12px}.stat-label svg{width:17px;height:17px;color:#9aa8bf}.stat-value{font-family:Georgia,serif;font-size:34px;letter-spacing:-1px;margin:14px 0 12px}.stat-value small{font-family:"PingFang SC",sans-serif;font-size:11px;color:var(--muted);letter-spacing:0;margin-left:5px}.stat-foot{font-size:10px;color:var(--muted)}.stat-foot b{color:var(--green);font-weight:500;margin-right:7px}.columns{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:23px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.section-head h2{font-size:15px;font-weight:600;margin:0}.section-head h2 small{font-weight:400;color:var(--muted);font-size:10px;margin-left:10px}.text-button{font-size:11px;color:var(--muted);padding:3px}.text-button:hover{color:var(--accent)}.trend{padding:24px 25px;margin-bottom:23px}.segmented{background:var(--bg);padding:3px;display:flex;border-radius:6px}.segmented button{font-size:10px;padding:5px 12px;color:var(--muted);border-radius:4px}.segmented button.active{background:var(--panel);coloLine truncated
/* 阅读目标与内容页:与既有主题变量共用色彩。 */
:root{--muted:#717b8b}[data-theme=dark]{--muted:#a2adbf}h3{font-size:14px;margin:0}button:disabled{opacity:.45;cursor:not-allowed}.demo-banner{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:18px}.welcome{margin-top:26px}.page-actions{display:flex;align-items:center;gap:10px}.secondary{padding:10px 15px;border:1px solid var(--line);background:var(--panel);border-radius:7px;font-size:12px}.pill{display:inline-block;padding:5px 8px;font-size:10px;border-radius:5px;background:var(--soft);color:var(--accent);white-space:nowrap}.pill.green{background:#6a9a8219;color:var(--green)}.pill.amber{background:#c3923919;color:#b78a3f}.hint{font-size:11px;color:var(--muted);line-height:1.8}.panel{padding:24px}.panel+.panel{margin-top:20px}.toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:0 0 22px}.toolbar .tabs{margin:0;flex-wrap:wrap}.toolbar input,.toolbar select,.field input,select{border:1px solid var(--line);border-radius:7px;padding:10px 12px;background:var(--panel);color:var(--text);font:inherit;font-size:12px}.toolbar select{max-width:100%}.field{display:flex;flex-direction:column;gap:8px;font-size:12px;margin-bottom:18px}.field input{width:100%;background:var(--bg)}.date-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px}.metrics-row{display:flex;gap:24px;padding:18px 0;margin-bottom:10px;color:var(--muted);font-size:11px}.metrics-row strong{font-family:Georgia;font-size:28px;color:var(--text);margin-right:6px;font-weight:400}.goals-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.goal-card{padding:25px;position:relative;overflow:hidden}.goal-card:before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--green)}.goal-card h3{font-size:19px;margin:17px 0 8px;word-break:break-word}.goal-card .section-head{margin-bottom:0}.goal-date{font-size:11px;color:var(--muted)}.cover-stack{display:flex;gap:0;margin:24px 0 20px;height:92px;padding-left:4px}.mini-cover{width:66px;height:92px;padding:10px 8px;border-radius:2px 4px 4px 2px;box-shadow:4px 5px 10px #0002;display:flex;flex-direction:column;flex-shrink:0;position:relative;overflow:hidden;border-left:3px solid #0002}.cover-stack .mini-cover{margin-left:-4px;transform:rotate(-3deg);transform-origin:bottom}.cover-stack .mini-cover:nth-child(2n){transform:rotate(4deg)}.mini-cover b{font-family:'Songti SC',serif;font-size:12px;position:relative;z-index:1;line-height:1.5}.mini-cover small{font-size:6px;position:relative;margin-top:auto}.stack-more{align-self:center;margin-left:18px;color:var(--muted);font-size:12px}.goal-progress{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--muted);margin:12px 0 9px}.goal-progress strong{font-size:22px;color:var(--text);font-family:Georgia;font-weight:400}.goal-card .progress{height:5px;margin:0 0 19px}.goal-card .progress i{background:var(--green)}.goal-card-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding-top:16px}.goal-card-footer small{font-size:10px;color:var(--muted)}.goal-card-footer button{font-size:11px;color:var(--accent)}.goal-intro{display:grid;grid-template-columns:1fr auto;gap:28px;background:var(--quote);padding:26px 30px;margin-bottom:24px;border:1px solid var(--line);border-radius:12px}.goal-intro h2{font-family:'Songti SC',serif;font-size:23px;font-weight:500;margin:0 0 10px}.goal-intro p{max-width:500px}.goal-intro .symbol{font-family:Georgia;font-size:65px;align-self:center;color:#819583}.goal-summary-row{display:block;text-align:left;width:100%;padding:16px 0;border-top:1px solid var(--line)}.goal-summary-row strong{font-size:12px;font-weight:500}.goal-summary-row small{display:block;margin-top:7px;color:var(--muted);font-size:10px}.goal-summary-row .progress{margin-top:10px}.list-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.list-card{padding:21px;display:block;text-align:left;transition:transform .2s}.list-card:hover{transform:translateY(-3px)}.list-art{height:117px;display:flex;align-items:center;justify-content:center;background:var(--quote);border-radius:7px;margin-bottom:18px}.list-art .mini-cover:nth-child(2){transform:translateY(-5px);margin:0 -4px;z-index:1}.list-card h3{font-size:15px;margin-bottom:8px}.list-card p{font-size:11px}.list-card>small{display:block;color:var(--muted);font-size:10px;margin-top:15px}.full-books{grid-template-columns:repeat(5,minmax(0,1fr));gap:26px}.full-books .cover{height:168px;width:118px}.row-list{display:flex;flex-direction:column}.book-row{display:flex;align-items:center;gap:15px;padding:14px 0;border-bottom:1px solid var(--line)}.book-row:last-child{border:0}.book-row .mini-cover{width:42px;height:58px;padding:5px}.book-row .mini-cover b{font-size:8px}.book-row .mini-cover small{display:none}.book-row-text{flex:1;min-width:0}.book-row-text b{font-size:12px;font-weight:500}.book-row-text p{font-size:10pLine truncated
@media(max-width:1200px){.full-books{grid-template-columns:repeat(4,minmax(0,1fr))}.list-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.goals-grid{grid-template-columns:1fr}.columns>.rightcol{display:flex}.notebook-grid{grid-template-columns:1fr}.notebook-menu{display:flex;overflow:auto}.notebook-menu button{min-width:160px}.full-books{grid-template-columns:repeat(3,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sidebar .nav{gap:9px}.welcome{align-items:flex-start;gap:15px}.welcome h1{line-height:1.4}.welcome .primary{white-space:nowrap}.goal-intro{padding:22px}.demo-banner span:last-child{display:none}}@media(max-width:600px){.full-books{grid-template-columns:repeat(2,minmax(0,1fr))}.list-grid,.insight-grid{grid-template-columns:1fr}.goal-intro{grid-template-columns:1fr}.goal-intro .symbol{display:none}.goal-card{padding:20px}.welcome{flex-direction:column}.welcome .page-actions{align-self:flex-end}.panel{padding:18px}.date-fields{grid-template-columns:1fr;gap:0}.detail-body>.cover{width:95px;flex-shrink:0}.detail-body{gap:15px}.detail-info h3{font-size:18px}.book-details-grid{grid-template-columns:1fr}.toolbar input{width:100%}.metrics-row{gap:16px;flex-wrap:wrap}.metrics-row strong{font-size:23px}.goal-intro h2{font-size:20px}.cover-stack .mini-cover{width:57px}.stack-more{font-size:10px;margin-left:10px}.demo-banner{font-size:9px}.goal-card h3{font-size:17px}dialog{padding:20px}.editor-footer{gap:10px}.book-row{gap:9px}.book-row-text b{font-size:11px}}
@media(max-width:1200px){.full-books{grid-template-columns:repeat(4,minmax(0,1fr))}.list-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.goals-grid{grid-template-columns:1fr}.columns>.rightcol{display:flex}.notebook-grid{grid-template-columns:1fr}.notebook-menu{display:flex;overflow:auto}.notebook-menu button{min-width:160px}.full-books{grid-template-columns:repeat(3,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sidebar .nav{gap:9px}.welcome{align-items:flex-start;gap:15px}.welcome h1{line-height:1.4}.welcome .primary{white-space:nowrap}.goal-intro{padding:22px}.demo-banner>span:last-child{display:none}}@media(max-width:600px){.full-books{grid-template-columns:repeat(2,minmax(0,1fr))}.list-grid,.insight-grid{grid-template-columns:1fr}.goal-intro{grid-template-columns:1fr}.goal-intro .symbol{display:none}.goal-card{padding:20px}.welcome{flex-direction:column}.welcome .page-actions{align-self:flex-end}.panel{padding:18px}.date-fields{grid-template-columns:1fr;gap:0}.detail-body>.cover{width:95px;flex-shrink:0}.detail-body{gap:15px}.detail-info h3{font-size:18px}.book-details-grid{grid-template-columns:1fr}.toolbar input{width:100%}.metrics-row{gap:16px;flex-wrap:wrap}.metrics-row strong{font-size:23px}.goal-intro h2{font-size:20px}.cover-stack .mini-cover{width:57px}.stack-more{font-size:10px;margin-left:10px}.demo-banner{font-size:9px}.goal-card h3{font-size:17px}dialog{padding:20px}.editor-footer{gap:10px}.book-row{gap:9px}.book-row-text b{font-size:11px}}
.compact-value{font-family:Georgia,'PingFang SC',sans-serif;font-size:25px;line-height:1.5;letter-spacing:-.5px}.stat-foot{line-height:1.7}.insight-detail .insight-grid{margin-top:0}.sidebar .nav button[aria-current=page]{color:var(--accent)}
@media(max-width:600px){.compact-value{font-size:20px}.list-art .mini-cover{width:56px}.full-books .cover{width:105px;height:159px}.goal-card .section-head{gap:10px}.book-row .mini-cover{display:none}.detail-tabs{gap:16px}.date-fields input{min-height:40px}.detail-info .primary{font-size:11px;padding:9px}}
.mini-cover.c2,.mini-cover.c3{color:#fff5df}
@@ -12,3 +12,82 @@
.real-cover{display:block;width:118px;height:168px;object-fit:cover;border-radius:4px;box-shadow:4px 5px 10px #0002;background:var(--quote)}
.mini-real-cover{width:66px;height:92px;flex-shrink:0}
.book-row .mini-real-cover{width:42px;height:58px}
/* 紧凑内容布局:压缩页面介绍,保留正文、书封和操作的可读尺寸。 */
.topbar{height:58px}
.top-actions{gap:16px}
.demo-banner{margin-top:10px;gap:16px;line-height:1.5}
.welcome{margin:8px 0 10px;gap:14px;align-items:center}
.welcome h1{font-size:23px;line-height:1.35;margin-bottom:3px}
.welcome p{line-height:1.55}
.welcome .eyebrow,.goal-intro .eyebrow,.goal-intro .symbol{display:none}
.page-actions{flex-shrink:0}
.page-actions:empty{display:none}
.toolbar{margin-bottom:10px;gap:8px 16px}
#page-content>.type-chips{margin:0 0 10px;gap:8px}
.toolbar .type-chips{margin:0}
#page-content>.type-chips button,.toolbar .type-chips button{min-height:36px;padding:8px 12px}
.stats{gap:14px;margin-bottom:14px}
.stat{padding:14px 18px;min-width:0}
.stat-value{margin:8px 0}
.compact-value{font-size:24px;overflow-wrap:anywhere}
.columns,.rightcol{gap:16px}
.shelf,.trend,.summary-goals{padding:18px}
.panel{padding:18px}
.section-head{margin-bottom:14px;gap:12px}
.shelf{margin-top:16px}
.books{gap:18px}
.full-books{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:22px 18px}
.book .real-cover{max-width:100%;height:auto;aspect-ratio:118/168}
.book h3{margin:10px 0 4px}
.book p{line-height:1.45}
.book .progress{margin-top:9px}
.summary-goals .status-box{padding:18px 10px;text-align:left}
.summary-goals .status-box h3{margin:6px 0;font-size:13px}
.summary-goals .status-box button{margin-top:10px}
.goal-intro{display:block;padding:12px 16px;margin-bottom:14px}
.goal-intro>div{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:20px;align-items:center}
.goal-intro h2{font-size:16px;margin:0 0 2px}
.goal-intro p{font-size:10px;line-height:1.6}
.goal-intro .metrics-row{grid-column:2;grid-row:1/3;margin:0;padding:0;gap:16px;white-space:nowrap}
.goal-intro .metrics-row span{display:flex;flex-direction:column;gap:2px}
.goal-intro .metrics-row strong{font-size:20px;line-height:1.2;margin:0}
.goals-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;align-items:start}
.goal-card{padding:18px}
.goal-card h3{font-size:17px;margin:10px 0 5px}
.goal-card .cover-stack{margin:14px 0;height:66px}
.goal-card .mini-cover,.goal-card .mini-real-cover{width:47px;height:66px}
.goal-card .progress{margin-bottom:12px}
.goal-card-footer{padding-top:12px;gap:12px;flex-wrap:wrap}
.goal-card-footer button{min-height:32px}
.goal-summary-row{padding:12px 0}
@media(max-width:1500px){.full-books{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:20px 16px}}
@media(max-width:1200px){.full-books{grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:18px 14px}}
@media(max-width:900px){.full-books{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:16px 12px}}
@media(max-width:1150px){
.goal-intro>div{grid-template-columns:1fr;gap:4px}
.goal-intro .metrics-row{grid-column:auto;grid-row:auto;margin-top:8px;flex-wrap:wrap}
.goal-intro .metrics-row span{display:block}
.goal-intro .metrics-row strong{margin-right:6px}
}
@media(max-width:600px){
.topbar{height:56px}
.demo-banner{margin-top:8px}
.welcome{align-items:flex-start;gap:12px}
.welcome h1{font-size:20px}
.welcome p{font-size:11px}
.welcome .page-actions{align-self:flex-start}
.primary,.secondary,.iconbtn{min-height:40px}
.stats{gap:12px}
.stat{padding:12px}
.compact-value{font-size:20px}
.shelf,.trend,.summary-goals,.goal-card,.panel{padding:14px}
.full-books{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 12px}
.goal-intro{padding:16px}
.goal-intro .metrics-row{gap:12px}
.goal-intro .metrics-row strong{font-size:22px}
.goal-card .section-head{flex-wrap:wrap}
.goal-card .cover-stack{gap:0}
.goal-card .mini-cover,.goal-card .mini-real-cover{width:44px;height:62px}
.goal-card .cover-stack{height:62px}
.stack-more{margin-left:8px;white-space:nowrap}
}
+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