diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..c40ae2d
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,11 @@
+
+:root{--bg:#f7f8fa;--panel:#fff;--text:#242a34;--muted:#9298a4;--line:#edf0f3;--accent:#356ce5;--soft:#edf3ff;--sidebar:#fff;--shadow:0 8px 32px #24355005;--quote:#f8f6f1;--green:#449682}
+[data-theme=dark]{--bg:#14171e;--panel:#1d222c;--text:#e7eaf0;--muted:#8892a4;--line:#2c323f;--accent:#80a5ff;--soft:#26344e;--sidebar:#191d26;--shadow:0 8px 32px #0001;--quote:#292820;--green:#75c6ac;color-scheme:dark}
+*{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);color:var(--accent);box-shadow:0 1px 5px #0001}.trend-total{font-family:Georgia;font-size:25px;margin-bottom:10px}.trend-total small{font-family:"PingFang SC";font-size:10px;color:var(--muted);margin-left:9px}.chart{height:130px;position:relative;padding-left:30px}.gridlines{position:absolute;inset:0 0 15px 30px;display:flex;flex-direction:column;justify-content:space-between}.gridlines i{border-top:1px dashed var(--line);position:relative}.gridlines i:before{content:attr(data-label);position:absolute;left:-29px;top:-6px;color:var(--muted);font-size:9px;font-style:normal}.bars{display:flex;align-items:end;justify-content:space-around;height:100%;gap:14px;position:relative;padding-bottom:15px}.bar{height:var(--h);max-width:33px;flex:1;background:#dbe6fc;border-radius:4px 4px 0 0;position:relative;transition:height .35s}.bar:nth-child(5){background:#4d7ce7}.bar span{position:absolute;top:calc(100% + 9px);left:50%;transform:translateX(-50%);font-size:9px;color:var(--muted);white-space:nowrap}.bar:hover{background:#7199ef}.bar:hover:before{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);white-space:nowrap;background:var(--text);color:var(--panel);padding:5px 7px;font-size:10px;border-radius:4px}.shelf{padding:24px 25px}.tabs{display:flex;gap:25px;border-bottom:1px solid var(--line);margin-top:-4px;margin-bottom:24px}.tabs button{padding:0 0 12px;font-size:11px;color:var(--muted);border-bottom:2px solid transparent}.tabs button.active{color:var(--accent);border-bottom-color:var(--accent)}.books{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:23px}.book{text-align:left;padding:0;min-width:0;transition:transform .2s}.book:hover{transform:translateY(-5px)}.cover{height:160px;width:112px;max-width:100%;border-radius:2px 5px 5px 2px;position:relative;padding:15px 12px;overflow:hidden;box-shadow:5px 8px 13px #17212c20,inset 4px 0 3px #ffffff25;display:flex;flex-direction:column;color:#fff}.cover:after{content:"";position:absolute;inset:0;border-left:4px solid #0002;pointer-events:none}.cover .tiny{font-size:6px;letter-spacing:1px;opacity:.8}.cover .title{font-family:"Songti SC","STSong",serif;line-height:1.3;font-size:22px;margin-top:15px;font-weight:700}.cover .author{font-size:7px;margin-top:10px}.cover .publisher{font-size:6px;margin-top:auto;letter-spacing:1px}.c1{background:#efe9d8;color:#374432}.c1 .title{font-size:19px;letter-spacing:1px;margin-top:15px;z-index:1}.c1:before{content:"";position:absolute;width:130px;height:110px;border:1px solid #73876a66;border-radius:50%;left:-29px;bottom:-24px;box-shadow:0 0 0 8px #72826313,0 0 0 18px #72826313,0 0 0 30px #72826313}.c2{background:#182b3b}.c2 .title{font-size:37px;letter-spacing:6px}.c2:before{content:"";position:absolute;left:20px;bottom:-42px;width:115px;height:115px;border:1px solid #b8855144;border-radius:50%;box-shadow:0 0 0 8px #b8855122,0 0 0 17px #b8855111}.c3{background:#db502e}.c3 .title{font-size:19px;color:#fff3d7}.c3:before{content:"";position:absolute;width:60px;height:60px;background:#f4c66a;bottom:13px;right:-8px;border-radius:50%}.c4{background:#f0eee2;color:#282722}.c4 .title{font-size:24px}.c4:before{content:"";position:absolute;width:90px;height:90px;bottom:-24px;right:-25px;background:repeating-linear-gradient(45deg,#3d6274 0 5px,transparent 5px 10px);transform:rotate(-15deg)}.book h3{font-size:12px;font-weight:500;margin:14px 0 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.book p{font-size:10px}.progress{height:3px;background:var(--line);border-radius:3px;margin-top:13px}.progress i{display:block;height:100%;background:#8aa7d8;width:var(--progress)}.book-meta{font-size:9px;color:var(--muted);display:flex;justify-content:space-between;margin-top:7px}.rightcol{display:flex;flex-direction:column;gap:23px}.goal{padding:24px}.goal-ring{margin:12px auto 16px;width:142px;height:142px;border-radius:50%;background:conic-gradient(#679483 0 216deg,var(--line) 216deg);padding:9px;transform:rotate(-90deg)}.ring-inner{width:100%;height:100%;border-radius:50%;background:var(--panel);display:flex;flex-direction:column;align-items:center;justify-content:center;transform:rotate(90deg)}.ring-inner strong{font-family:Georgia;font-size:36px;font-weight:400}.ring-inner small{color:var(--muted);font-size:10px;margin-top:5px}.goal-caption{text-align:center;font-size:11px;margin-bottom:18px}.goal-caption span{color:var(--green)}.goal-footer{padding-top:15px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font-size:10px;color:var(--muted)}.goal-footer b{color:var(--text);font-weight:500}.notes{padding:24px}.note{border-left:2px solid #d7bd7e;padding-left:13px;margin:0 0 19px}.note blockquote{font-family:"Songti SC","STSong",serif;font-size:13px;line-height:1.9;margin:0 0 11px}.note small{font-size:9px;color:var(--muted)}.note:last-child{margin-bottom:0}.quote{padding:20px 24px;background:var(--quote);border-radius:12px;position:relative}.quote .eyebrow{font-size:9px;letter-spacing:1.5px}.quote p{font-family:"Songti SC",serif;color:var(--text);font-size:13px;line-height:1.9}.quote small{display:block;text-align:right;margin-top:12px;color:var(--muted);font-size:9px}.footer{display:flex;justify-content:space-between;margin-top:24px;font-size:9px;color:var(--muted)}.empty{padding:40px;text-align:center;color:var(--muted);grid-column:1/-1}.notes-page{display:grid;gap:20px}.notes-page .note{padding:20px;margin:0}.hidden{display:none!important}dialog{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:18px;padding:32px;width:520px;max-width:90vw;box-shadow:0 30px 100px #0003}dialog::backdrop{background:#10182877;backdrop-filter:blur(5px)}.dialog-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.dialog-top h2{margin:0;font-size:18px}.detail-body{display:flex;gap:26px;margin-bottom:22px}.detail-info{flex:1}.detail-info h3{font-size:22px;margin:8px 0}.detail-info p{margin:8px 0}.detail-desc{font-size:13px;line-height:2;color:var(--muted)}#toast{position:fixed;bottom:30px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--panel);padding:13px 23px;border-radius:10px;z-index:30;font-size:12px}.fade{animation:fade .5s ease both}@keyframes fade{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}@media(min-width:1500px){.main{padding-left:55px;padding-right:55px}.columns{grid-template-columns:minmax(0,1fr) 330px}.cover{height:185px;width:130px}.chart{height:160px}}@media(max-width:1150px){.sidebar{width:180px;padding-left:16px;padding-right:16px}.main{margin-left:180px;padding:0 24px 24px}.columns{grid-template-columns:minmax(0,1fr) 250px;gap:16px}.books{gap:12px}.cover{height:140px}.stats{gap:12px}.stat{padding:18px 15px}.shelf,.trend{padding:20px}.searchbox input{width:130px}.welcome-right .demo-tag{display:none}}@media(max-width:900px){.stats{grid-template-columns:repeat(2,1fr)}.sidebar{width:76px;padding:28px 12px}.logo{justify-content:center}.logo>div:last-child,.navlabel,.nav button span,.nav em,.sidebar-bottom{display:none}.nav{margin-top:45px}.nav button{padding:14px;justify-content:center}.main{margin-left:76px;padding:0 22px 24px}.columns{grid-template-columns:1fr}.rightcol{display:grid;grid-template-columns:1fr 1fr}.quote{grid-column:1/-1}.cover{height:165px}.welcome-right .primary{font-size:0}.welcome-right .primary svg{margin:0}.stat-value{font-size:28px}.topbar{height:75px}}@media(max-width:600px){.main{padding:0 16px 20px;margin-left:60px}.sidebar{width:60px;padding:25px 6px}.logo-mark{width:33px;height:33px}.nav button{padding:12px}.searchbox{padding:8px}.searchbox input{width:112px}.key,.breadcrumb,.top-actions>.avatar{display:none}.top-actions{width:100%;justify-content:space-between;gap:8px}.welcome{margin-top:25px}h1{font-size:21px}.welcome p{font-size:10px}.eyebrow{font-size:8px}.stats{grid-template-columns:repeat(2,1fr)}.stat{padding:16px}.stat-value{margin:10px 0}.rightcol{display:flex}.books{grid-template-columns:repeat(2,1fr);gap:24px 15px}.cover{height:160px;width:112px}.section-head h2 small{display:none}.tabs{gap:18px}.footer{font-size:8px}.footer span:last-child{display:none}.detail-body{gap:16px}.detail-info h3{font-size:18px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
+/* 阅读目标与内容页:与既有主题变量共用色彩。 */
+: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:10px;margin-top:4px}.book-row .text-button{color:var(--accent)}.book-row-status{font-size:11px;color:var(--green)}.book-row .progress{max-width:200px}.notebook-grid{display:grid;grid-template-columns:230px minmax(0,1fr);gap:24px}.notebook-menu button{display:block;width:100%;text-align:left;border-radius:8px;padding:16px;margin-bottom:7px;border:1px solid transparent}.notebook-menu button.active{background:var(--soft);border-color:var(--line)}.notebook-menu button b{font-size:12px;font-weight:500}.notebook-menu button small{display:block;font-size:10px;margin-top:6px;color:var(--muted)}.note-content{padding:22px;margin:0 0 15px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.note-content blockquote{margin:15px 0;font-family:'Songti SC',serif;font-size:16px;line-height:1.9}.note-content small{color:var(--muted);font-size:10px}.note-original{background:var(--quote);border-left:2px solid #b6a475;padding:12px;font-size:12px;line-height:1.9;margin-top:12px}.detail-tabs{display:flex;gap:19px;border-bottom:1px solid var(--line);margin:25px 0 18px;overflow:auto}.detail-tabs button{white-space:nowrap;font-size:12px;color:var(--muted);padding:0 0 12px;border-bottom:2px solid transparent}.detail-tabs button.active{color:var(--accent);border-color:var(--accent)}#detail{width:760px;max-height:90vh}#goal-editor{width:700px;max-height:90vh}.picker-head{margin:22px 0 10px}.picker-list{max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-top:12px}.pick-row{display:flex;align-items:center;gap:12px;padding:12px 15px;border-bottom:1px solid var(--line);cursor:pointer}.pick-row:last-child{border:0}.pick-row input{accent-color:#356ce5;width:16px;height:16px}.pick-row b{font-size:12px;font-weight:500}.pick-row small{display:block;color:var(--muted);font-size:10px;margin-top:4px}.pick-row .pill{margin-left:auto}.picker-lists{display:flex;gap:7px;flex-wrap:wrap;margin:12px 0}.picker-lists button{border:1px solid var(--line);border-radius:6px;font-size:11px;padding:8px;background:var(--bg)}.picker-lists button.active{background:var(--soft);color:var(--accent)}.editor-footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding-top:20px;margin-top:12px}.editor-footer>span{font-size:10px;color:var(--muted)}.form-error{color:#bd5151;font-size:12px;margin:12px 0}.insight-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-top:22px}.insight h3{margin-bottom:20px}.insight-line{display:flex;justify-content:space-between;gap:12px;padding:10px 0;color:var(--muted);font-size:11px}.insight-line b{color:var(--text);font-weight:500}.insight .progress{margin-top:5px}.section-space{margin-top:24px}.discovery-hero{padding:27px;background:var(--quote);border-radius:10px;margin-bottom:24px}.discovery-hero h2{font-family:'Songti SC',serif;font-size:22px;font-weight:500;margin:0 0 8px}.status-box{text-align:center;padding:65px 20px}.status-box h3{margin:12px}.status-box button{margin-top:18px}.type-chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.type-chips button{padding:8px 11px;font-size:11px;border-radius:7px;border:1px solid var(--line)}.type-chips button.active{background:var(--soft);color:var(--accent);border-color:transparent}.review-row{border-bottom:1px solid var(--line);padding:18px 0}.review-row p{font-size:13px;color:var(--text);margin-top:12px}.review-row .meta{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted)}.detail-subtitle{margin:15px 0}.book-details-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px}.book-details-grid span{color:var(--muted)}.chapter-row{padding:14px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;font-size:12px}.media-card{display:flex;gap:18px;align-items:center;padding:24px}.media-icon{width:65px;height:65px;flex-shrink:0;background:var(--soft);color:var(--accent);border-radius:14px;display:grid;place-items:center;font-size:27px}.hour-bars{height:58px;display:flex;align-items:end;gap:3px}.hour-bars i{height:var(--height);flex:1;background:#84a4d8;border-radius:2px 2px 0 0}.insight-detail{margin-top:20px}.insight-detail summary{cursor:pointer;font-size:12px;color:var(--accent);padding:15px 0}.source-tag{font-size:10px;color:var(--muted);margin:8px 0}.goal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:22px}.detail-empty{padding:35px;color:var(--muted);font-size:12px;text-align:center}.cover .title{flex-shrink:0}.cover .tiny{font-size:5px}.c1 .title,.c3 .title{font-size:17px}.cover .author{font-size:6px}.cover .publisher{font-size:5px}.trend-total small{display:inline-block;font-size:11px}.trend{margin-bottom:0}.shelf{margin-top:22px}.list-index{color:var(--muted);font-size:10px;margin-right:5px}.summary-goals{margin-bottom:0}
+@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}
+.export-preview{width:100%;height:320px;margin-top:20px;border:1px solid var(--line);border-radius:8px;padding:18px;background:var(--bg);color:var(--text);font:12px/1.9 'PingFang SC',sans-serif;resize:vertical}
diff --git a/static/js/app.js b/static/js/app.js
index daf6e68..9a27f00 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,1267 +1,664 @@
-/* ===== 微信读书数据展示 - 前端逻辑 ===== */
+/* ==========================================================================
+ 阅间 · 微信读书看板 v2 —— 交互逻辑
+ --------------------------------------------------------------------------
+ 视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。
+ 差异只在于数据来源:设计稿读取 demo-data.js 的静态示例,
+ 这里改为调用 /api/v2/* 实时接口。
+ ========================================================================== */
-const API_BASE = "";
+const $ = selector => document.querySelector(selector);
+const esc = value =>
+ String(value == null ? '' : value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
+const findBook = id => books.find(book => book.id === id);
+const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
+const today = () => new Date().toLocaleDateString('sv-SE');
+const storageKey = 'weread-goals-v2';
+const PICKER_LIMIT = 60;
+const SHELF_PAGE = 48;
-// ===== 主题管理 =====
+/* ------------------------------- 运行时状态 ------------------------------ */
-function getCurrentTheme() {
- return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
+let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week';
+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 books = [];
+let booklists = [];
+let shelfData = null;
+let notebooksData = null;
+let statusData = null;
+let dashboardData = {};
+let notesData = {};
+let bookData = {};
+let discoverData = {};
+let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存 */
+
+/* 加载状态:idle | loading | ready | error */
+const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
+const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
+
+let goals = [];
+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;
+ }
+} 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 statusBox(kind, title, text, action) {
+ const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO';
+ const button = action ? `` : '';
+ return `
${eyebrow}
${esc(title)}
${esc(text || '')}
${button}
`;
+}
+function setTheme(theme) {
+ document.documentElement.dataset.theme = theme;
+ $('#theme').innerHTML = ``;
+ $('#theme').setAttribute('aria-label', `切换至${theme === 'dark' ? '亮' : '暗'}色主题`);
+}
+try { setTheme(localStorage.getItem('weread-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); }
+$('#theme').onclick = () => {
+ const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme);
+ try { localStorage.setItem('weread-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); }
+};
+
+/* 封面:沿用设计稿的示意封面(4 种底色 + 书名短行) */
+function cover(book, mini = false) {
+ const short = book.short || book.title || '';
+ if (mini) return `${esc(book.title)}${esc(book.author)}
`;
+ return `${esc(book.en || '')}${esc(short).replaceAll('\n', '
')}${esc(book.author)}READING SPACE · 藏书
`;
+}
+function scoreText(book) {
+ return book.score ? `评分 ${Math.round(Number(book.score) * 10)}/100` : '暂无评分';
+}
+function stateText(book) {
+ if (book.status === 'done') return '已读完';
+ if (book.status === 'wish') return '想读';
+ return book.progress > 0 ? `已读 ${book.progress}%` : '在读';
+}
+function bookCard(book) {
+ return ``;
+}
+function bookRow(book, source = '') {
+ return `${cover(book, true)}
${esc(book.title)}${esc(book.author)}${source ? ' · ' + esc(source) : ''}
${esc(stateText(book))} `;
+}
+function welcome(title, subtitle, action = '') {
+ return `A LITTLE READING, EVERY DAY
${esc(title)}
${esc(subtitle)}
${action}
`;
+}
+const pageNames = { overview: '阅读概览', shelf: '我的书架', goals: '阅读目标', notes: '笔记与划线', discover: '发现好书' };
+function navigate(next) {
+ page = next; $('#crumb').textContent = pageNames[page];
+ document.querySelectorAll('[data-page]').forEach(button => { button.classList.toggle('active', button.dataset.page === page); button.setAttribute('aria-current', button.dataset.page === page ? 'page' : 'false'); });
+ renderPage();
+}
+function renderPage() {
+ const views = { overview: renderOverview, shelf: renderShelf, goals: renderGoals, notes: renderNotes, discover: renderDiscover };
+ $('#page-content').innerHTML = views[page]();
+}
+function tabs(items, selected, action) {
+ return `${items.map(([id, label]) => ``).join('')}
`;
}
-function toggleTheme() {
- const newTheme = getCurrentTheme() === "dark" ? "light" : "dark";
- document.documentElement.setAttribute("data-theme", newTheme);
- localStorage.setItem("theme", newTheme);
- refreshChartsForTheme();
+/* ------------------------------- 数据加载 ------------------------------- */
+
+async function ensureStatus() {
+ try {
+ statusData = await WeApi.status();
+ const counts = statusData.counts || {};
+ const shelfNav = document.querySelector('[data-page="shelf"] em');
+ if (shelfNav && counts.ebooks) shelfNav.textContent = counts.ebooks;
+ const conn = $('#conn-state');
+ if (conn) conn.textContent = statusData.ok ? '已连接微信读书 · 数据实时同步' : '微信读书接口暂不可用';
+ const foot = $('#footer-state');
+ if (foot) foot.textContent = statusData.fetch_time ? `数据快照 ${statusData.fetch_time}` : '数据来自微信读书个人账号';
+ } catch (error) {
+ const conn = $('#conn-state');
+ if (conn) conn.textContent = '接口连接失败:' + error.message;
+ }
}
-function getChartColors() {
- const cs = getComputedStyle(document.documentElement);
- return {
- axis: cs.getPropertyValue("--chart-axis").trim(),
- axisLine: cs.getPropertyValue("--chart-axis-line").trim(),
- splitLine: cs.getPropertyValue("--chart-split-line").trim(),
- label: cs.getPropertyValue("--chart-label").trim(),
- borderColor: cs.getPropertyValue("--chart-border-color").trim(),
- tooltipBg: cs.getPropertyValue("--chart-tooltip-bg").trim(),
- tooltipBorder: cs.getPropertyValue("--chart-tooltip-border").trim(),
- tooltipText: cs.getPropertyValue("--chart-tooltip-text").trim(),
- accent: cs.getPropertyValue("--accent").trim(),
- accentLight: cs.getPropertyValue("--accent-light").trim(),
- accentDark: cs.getPropertyValue("--accent-dark").trim(),
- };
+async function ensureShelf(force = false) {
+ if (loadState.shelf === 'loading') return;
+ if (shelfData && !force) return;
+ loadState.shelf = 'loading'; loadError.shelf = '';
+ if (['shelf', 'overview', 'goals'].includes(page)) renderPage();
+ try {
+ const data = await WeApi.shelf('all', { progress: true });
+ shelfData = data;
+ books = data.books || [];
+ booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` }));
+ loadState.shelf = 'ready';
+ } catch (error) {
+ loadError.shelf = error.message; loadState.shelf = 'error';
+ }
+ if (['shelf', 'overview', 'goals'].includes(page)) renderPage();
}
-// ===== 工具函数 =====
-
-/**
- * 升级微信读书封面图 URL 到高清晰度版本。
- * 微信读书 CDN 的封面图通过文件名前缀区分分辨率:
- * t6_ = 低清缩略图(~150px,Retina 屏模糊)
- * s_ = 超低清缩略图(~90px,搜索结果常用,更模糊)
- * t8_ = 高清版(~500px,适合 2x/3x 屏幕)
- * 将 t6_ 和 s_ 前缀统一升级为 t8_ 高清版。
- */
-function hdCover(url) {
- if (!url) return "";
- return url.replace(/\/(t6_|s_)/, "/t8_");
+async function ensureDashboard(target = period, force = false) {
+ if (loadState.dashboard[target] === 'loading') return;
+ if (dashboardData[target] && !force) return;
+ loadState.dashboard[target] = 'loading'; loadError.dashboard[target] = '';
+ if (page === 'overview' && period === target) renderPage();
+ try {
+ dashboardData[target] = await WeApi.dashboard(target);
+ loadState.dashboard[target] = 'ready';
+ } catch (error) {
+ loadError.dashboard[target] = error.message; loadState.dashboard[target] = 'error';
+ }
+ if (page === 'overview' && period === target) renderPage();
}
-async function fetchAPI(path) {
- const resp = await fetch(API_BASE + path);
- if (!resp.ok) throw new Error(`API error: ${resp.status}`);
- return resp.json();
+async function ensureNotebooks(force = false) {
+ if (loadState.notes.__list === 'loading') return;
+ if (notebooksData && !force) return;
+ loadState.notes.__list = 'loading';
+ try { notebooksData = await WeApi.notebooks(); loadState.notes.__list = 'ready'; }
+ catch (error) { loadError.notes.__list = error.message; loadState.notes.__list = 'error'; }
+ if (page === 'notes') renderPage();
}
-function formatHours(seconds) {
- if (!seconds) return "0";
- return (seconds / 3600).toFixed(1);
+async function ensureNotes(bookId, force = false) {
+ if (loadState.notes[bookId] === 'loading') return;
+ if (notesData[bookId] && !force) return;
+ loadState.notes[bookId] = 'loading'; loadError.notes[bookId] = '';
+ if (page === 'notes' && noteBook === bookId) renderPage();
+ try {
+ notesData[bookId] = await WeApi.notes(bookId, 4);
+ loadState.notes[bookId] = 'ready';
+ } catch (error) { loadError.notes[bookId] = error.message; loadState.notes[bookId] = 'error'; }
+ if (page === 'notes' && noteBook === bookId) renderPage();
}
-function escapeHtml(text) {
- if (!text) return "";
- const div = document.createElement("div");
- div.textContent = text;
- return div.innerHTML;
+async function ensureBook(bookId, force = false) {
+ if (!bookId) return;
+ if (loadState.book[bookId] === 'loading') return;
+ if (bookData[bookId] && !force) return;
+ loadState.book[bookId] = 'loading'; loadError.book[bookId] = '';
+ renderBookDetail();
+ try { bookData[bookId] = await WeApi.book(bookId); loadState.book[bookId] = 'ready'; }
+ catch (error) { loadError.book[bookId] = error.message; loadState.book[bookId] = 'error'; }
+ renderBookDetail();
}
-// ===== 数据库健康检查 =====
-
-const chartInstances = {};
-let chartsLoaded = false;
-let currentThoughtsData = null;
-let currentThoughtsCategory = "all";
-let currentThoughtsSort = "time_desc";
-let currentThoughtsSearch = "";
-
-function refreshChartsForTheme() {
- if (!chartsLoaded) return;
- Object.values(chartInstances).forEach((chart) => {
- if (chart && !chart.isDisposed()) chart.dispose();
- });
- loadYearlyTrend();
- loadTimeDistribution();
- loadCategories();
- loadAuthors();
+function discoverKey() {
+ if (discoverTab === 'recommend') return 'recommend';
+ if (discoverTab === 'lists') return `${searchScope}:lists`;
+ return `${searchScope}:${discoverTab}:${searchQuery}`;
}
-
-async function checkDBHealth() {
- try {
- const resp = await fetch(API_BASE + "/api/health");
- const data = await resp.json();
- if (resp.ok && data.database === "connected") {
- document.getElementById("dbAlertBanner").style.display = "none";
- return true;
- } else {
- showDBAlert(data.message || "数据库连接失败");
- return false;
- }
- } catch (error) {
- showDBAlert("无法连接到服务器,请检查后端服务是否运行。");
- return false;
- }
-}
-
-function showDBAlert(message) {
- const banner = document.getElementById("dbAlertBanner");
- const detail = document.getElementById("dbAlertDetail");
- if (banner) banner.style.display = "flex";
- if (detail) detail.textContent = message;
-}
-
-// ===== 标签页切换 =====
-
-function switchTab(tabName) {
- // 如果想法详情页正在显示,先关闭它回到主视图
- const thoughtsPage = document.getElementById("thoughtsPage");
- if (thoughtsPage && thoughtsPage.style.display !== "none") {
- thoughtsPage.style.display = "none";
- document.getElementById("mainView").style.display = "";
- currentThoughtsData = null;
- }
-
- // 关闭可能打开的弹窗
- closeModal("bookDetailModal");
-
- document.querySelectorAll(".tab-panel").forEach((panel) => {
- panel.classList.remove("active");
- });
- document.querySelectorAll(".nav-link").forEach((link) => {
- link.classList.toggle("active", link.dataset.tab === tabName);
- });
-
- const target = document.getElementById(`tab-${tabName}`);
- if (target) {
- target.classList.add("active");
- }
-
- window.scrollTo({ top: 0, behavior: "smooth" });
-
- // 懒加载图表
- if (tabName === "charts" && !chartsLoaded) {
- chartsLoaded = true;
- loadYearlyTrend();
- loadTimeDistribution();
- loadCategories();
- loadAuthors();
- }
-
- // 切换到图表时重绘尺寸
- if (tabName === "charts" && chartsLoaded) {
- setTimeout(() => {
- Object.values(chartInstances).forEach((chart) => {
- if (chart && !chart.isDisposed()) chart.resize();
- });
- }, 100);
- }
-}
-
-// ===== 回到顶部按钮 =====
-
-function initBackToTop() {
- const btn = document.getElementById("backToTop");
- window.addEventListener("scroll", () => {
- btn.classList.toggle("visible", window.scrollY > 400);
- });
-}
-
-function scrollToTop() {
- window.scrollTo({ top: 0, behavior: "smooth" });
-}
-
-/**
- * 在微信读书中打开书籍。
- * 优先使用 API 返回的 deep_link(https://weread.qq.com/... 网页链接),
- * 没有则用 book_id 构造网页链接。先尝试 weread:// 深链接唤起 App,
- * 1.5 秒后无响应则回退到网页版。
- */
-function openInWeRead(bookId, deepLink) {
- const webUrl = deepLink || `https://weread.qq.com/#book/${bookId}`;
- const schemeUrl = `weread://reading?bId=${bookId}`;
-
- // 尝试唤起 App(移动端),同时设置定时器回退到网页版
- let triggered = false;
- const fallbackTimer = setTimeout(() => {
- if (!triggered) window.open(webUrl, "_blank");
- }, 1500);
-
- // 监听页面可见性变化(App 唤起成功会切到后台)
- const onVisChange = () => {
- if (document.hidden) {
- triggered = true;
- clearTimeout(fallbackTimer);
- }
- };
- document.addEventListener("visibilitychange", onVisChange);
-
- // 尝试打开深链接
- const iframe = document.createElement("iframe");
- iframe.style.display = "none";
- iframe.src = schemeUrl;
- document.body.appendChild(iframe);
- setTimeout(() => document.body.removeChild(iframe), 2000);
-
- // 桌面端不会有 visibilitychange,确保 fallback 一定执行
- setTimeout(() => {
- document.removeEventListener("visibilitychange", onVisChange);
- }, 3000);
-}
-
-// ===== 全局搜索(顶部常驻搜索框) =====
-
-function globalSearch() {
- const keyword = document.getElementById("globalSearchInput").value.trim();
- if (!keyword) return;
-
- // 切换到推荐标签页
- switchTab("discover");
- // 切换到搜索子标签
- switchDiscoverTab("search");
- // 填入搜索词并执行搜索
- document.getElementById("searchInput").value = keyword;
- doSearch();
-}
-
-// ===== 书架分类标签(可折叠层级结构) =====
-
-let shelfCategoryTree = {}; // { "历史": {count:114, subs:{"世界史":5,...}}, ... }
-let shelfActiveCategory = ""; // 完整分类名,如 "历史-中国古代"
-let shelfActiveParent = ""; // 父分类名,如 "历史";空表示"全部"
-let shelfExpandedParents = new Set(); // 当前展开的父分类
-let shelfSortMode = "category"; // 始终按分类排序,前端不显示切换按钮
-
-async function loadShelfCategories() {
- try {
- const data = await fetchAPI("/api/shelf?page=1&per_page=1");
- shelfCategoryTree = data.category_tree || {};
- renderShelfCategories();
- } catch (e) {
- console.error("加载分类失败:", e);
- }
-}
-
-function renderShelfCategories() {
- const container = document.getElementById("shelfCategories");
- if (!container) return;
-
- let html = "";
-
- // "全部" 按钮
- const allActive = !shelfActiveParent && !shelfActiveCategory;
- html += ``;
-
- // 父分类按钮(按书籍数量降序)
- const parents = Object.entries(shelfCategoryTree)
- .sort((a, b) => b[1].count - a[1].count);
-
- for (const [parent, info] of parents) {
- const isActive = shelfActiveParent === parent && !shelfActiveCategory;
- const isExpanded = shelfExpandedParents.has(parent);
- const arrow = Object.keys(info.subs).length > 0
- ? `${isExpanded ? "▼" : "▶"}`
- : "";
-
- html += ``;
-
- // 展开的子分类
- if (isExpanded && Object.keys(info.subs).length > 0) {
- html += ``;
- for (const [sub, count] of Object.entries(info.subs).sort((a, b) => b[1] - a[1])) {
- const fullCat = `${parent}-${sub}`;
- const subActive = shelfActiveCategory === fullCat;
- html += ``;
- }
- html += `
`;
- }
- }
-
- container.innerHTML = html;
-}
-
-function filterByParent(parent) {
- if (parent) {
- // 点击父分类:展开/折叠子分类,同时筛选该父分类下的所有书
- if (shelfExpandedParents.has(parent) && shelfActiveParent === parent && !shelfActiveCategory) {
- // 已展开且已选中 → 折叠
- shelfExpandedParents.delete(parent);
- shelfActiveParent = "";
- shelfActiveCategory = "";
- } else {
- // 展开 + 筛选
- shelfExpandedParents.add(parent);
- shelfActiveParent = parent;
- shelfActiveCategory = "";
- }
+async function ensureDiscover(force = false) {
+ const key = discoverKey();
+ if (loadState.discover[key] === 'loading') return;
+ if (discoverData[key] && !force) return;
+ loadState.discover[key] = 'loading'; loadError.discover[key] = '';
+ if (page === 'discover') renderPage();
+ try {
+ if (searchScope === 'shelf' || (!searchQuery && (discoverTab === 'books' || discoverTab === 'all'))) {
+ discoverData[key] = { local: true, books: books, authors: [], lists: [] };
+ } else if (discoverTab === 'recommend') {
+ discoverData[key] = await WeApi.recommend(0);
} else {
- // 点击"全部"
- shelfExpandedParents.clear();
- shelfActiveParent = "";
- shelfActiveCategory = "";
+ discoverData[key] = await WeApi.search(searchQuery || '文学', discoverTab);
}
- renderShelfCategories();
- loadShelf(1);
+ loadState.discover[key] = 'ready';
+ } catch (error) { loadError.discover[key] = error.message; loadState.discover[key] = 'error'; }
+ if (page === 'discover') renderPage();
}
-function filterByCategory(fullCategory) {
- shelfActiveCategory = fullCategory;
- // 确保父分类已展开
- const parent = fullCategory.split("-")[0];
- shelfExpandedParents.add(parent);
- shelfActiveParent = parent;
- renderShelfCategories();
- loadShelf(1);
+/* ------------------------------- 阅读概览 ------------------------------- */
+
+function periodHint(p) {
+ const now = new Date();
+ if (p === 'week') return '本周 · 周一至今';
+ if (p === 'month') return `${now.getFullYear()} 年 ${now.getMonth() + 1} 月`;
+ if (p === 'year') return `${now.getFullYear()} 年 · 按月`;
+ return '全部年份 · 按年';
}
-// ===== 书架刷新 =====
+function renderTrend(data) {
+ const values = data.trend.values || [];
+ const labels = data.trend.labels || [];
+ const unit = data.trend.unit;
+ const max = Math.max(10, Math.ceil(Math.max(...values, 1) / 10) * 10);
+ const gridlines = [max, Math.round((max * 2) / 3), Math.round(max / 3), 0]
+ .map(n => ``).join('');
+ const bars = values.map((v, i) => `${esc(labels[i])}
`).join('');
+ const axis = period === 'year' ? '按月' : period === 'overall' ? '按年' : '按日';
+ return `阅读的日常 每一天,都算数
${axis}${esc(data.stats[0].value)}阅读与收听合计
${gridlines}
${bars || '该周期暂无阅读记录'}
`;
+}
-async function refreshShelf() {
- const btn = document.getElementById("shelfRefreshBtn");
- const textEl = btn.querySelector(".btn-text");
- btn.disabled = true;
- btn.classList.add("loading");
- textEl.textContent = "同步中...";
+function summaryGoals() {
+ return `我的阅读目标
${goals.slice(0, 3).map(goal => { const m = goalMetrics(goal); return ``; }).join('') || '还没有阅读目标,创建一份属于你的清单。
'}`;
+}
- try {
- // Step 1: 从微信读书 API 同步数据到 MySQL
- const resp = await fetch(API_BASE + "/api/sync", { method: "POST" });
- const syncResult = await resp.json();
- if (!syncResult.ok) {
- throw new Error(syncResult.error || "同步失败");
- }
+function renderOverview() {
+ const data = dashboardData[period];
+ if (!data && loadError.dashboard[period]) return statusBox('error', '暂时无法获取阅读统计', loadError.dashboard[period], 'retry-overview');
+ if (!data) { ensureDashboard(period); return statusBox('loading', '正在读取阅读统计…', '数据来自你的微信读书账号。'); }
- // Step 2: 重新加载分类和书架
- textEl.textContent = "加载中...";
- await loadShelfCategories();
- await loadShelf(1);
- } catch (e) {
- console.error("刷新失败:", e);
- alert("刷新失败: " + (e.message || "未知错误"));
- } finally {
- btn.disabled = false;
- btn.classList.remove("loading");
- textEl.textContent = "刷新";
+ const reading = books.filter(book => book.status === 'reading').slice(0, 6);
+ const stats = data.stats.map(s => `${esc(s.label)}
${s.value}
`).join('');
+
+ const topBooks = data.top_books.length
+ ? data.top_books.map((b, i) => `${i + 1}. ${esc(b.title)}${esc(b.read_time_str)}
`).join('')
+ : '该周期还没有阅读时长记录。
';
+
+ const cats = data.categories.items.length
+ ? data.categories.items.map(c => `${esc(c.title)}相对偏好 ${c.val}
`).join('')
+ : '接口未返回该周期的偏好分类。
';
+
+ const hours = data.time_distribution.values || [];
+ const hourMax = data.time_distribution.max || 1;
+ const hourBars = hours.length
+ ? hours.map(v => ``).join('')
+ : '接口未返回时段分布';
+ const peak = data.time_distribution.peak_hour;
+
+ const authorLines = data.authors.length
+ ? data.authors.map(a => `${esc(a.name)}${a.count} 本${a.read_time ? ' · ' + esc(a.read_time) : ''}
`).join('')
+ : '接口未返回该周期的偏好作者。
';
+ const publisherLines = data.publishers.length
+ ? data.publishers.map(p => `${esc(p.name)}${p.count} 本
`).join('')
+ : '接口未返回偏好出版社。
';
+ const cpLines = data.cps.length
+ ? data.cps.map(c => `${esc(c.name)}${c.count} 本
`).join('')
+ : '';
+ const medalLines = data.medals.length
+ ? data.medals.map(m => `${esc(m.name)}${esc(m.detail || m.date || '')}
`).join('')
+ : '勋章接口未返回
';
+
+ return welcome('在字里行间,遇见自己', '阅读、目标与灵感,在这里慢慢积累。', '') +
+ `${tabs([['week', '本周'], ['month', '本月'], ['year', '本年'], ['overall', '总计']], period, 'period')}${esc(periodHint(period))} · 微信读书统计接口
` +
+ `` +
+ `${renderTrend(data)}
继续阅读
${reading.length ? reading.map(bookCard).join('') : '
书架里还没有在读的书。
'}
` +
+ `读得最多
${topBooks}${data.top_books.length ? `按${esc(periodHint(period))}的阅读时长排序
` : ''}偏爱的阅读方向
${cats}相对偏好权重,不是阅读占比
留给自己的时段
${hourBars}
06:0018:00次日 05:00
${esc(data.time_distribution.word || '时段分布')}${peak != null ? ` · 峰值 ${String(peak).padStart(2, '0')}:00` : ''}
` +
+ `更多阅读画像 · 作者、出版社、勋章与年度回顾
偏好作者与出版方
${authorLines}${publisherLines}${cpLines}阅读与收听
微信读书统计接口未返回「文字阅读 / 收听」时长拆分,接入该字段后可直接展示。
阅读总时长${esc(data.stats[0].value)}
有效阅读天数${esc(String(data.stats[1].value).replace(/<[^>]+>/g, ''))}
阅读足迹
好友排行${esc(data.rank.text || '本周暂无排行')}
${medalLines}
`;
+}
+
+/* ------------------------------- 我的书架 ------------------------------- */
+
+function listCard(list, index) {
+ const members = (list.ids || []).map(findBook).filter(Boolean);
+ return ``;
+}
+function renderLists(lists) {
+ return `${lists.map(listCard).join('') || '
没有匹配的书单。
'}
`;
+}
+
+function renderShelf() {
+ if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf');
+ if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); }
+
+ const counts = shelfData.counts || {};
+ const filtered = books.filter(book => shelfTab === 'all' || book.status === shelfTab);
+ let content;
+ if (shelfTab === 'lists') content = renderLists(booklists);
+ else if (shelfTab === 'audio') {
+ const albums = shelfData.albums || [];
+ content = albums.length
+ ? albums.map(album => `♫
${esc(album.title)}
${esc(album.author || '有声专辑')} · ${album.count} 集
${esc(album.intro || '内容完结不代表已听完;当前不纳入按本阅读目标。')}
`).join('')
+ : '书架接口当前没有返回有声书 / 专辑条目。
';
+ } else if (shelfTab === 'articles') {
+ content = `▤
${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}
这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。
书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。
`;
+ } else {
+ const shown = filtered.slice(0, shelfLimit);
+ content = `${shown.map(bookCard).join('') || '
这里还没有对应的书。
'}
${filtered.length > shown.length ? `
` : ''}
`;
+ }
+
+ const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`;
+ return welcome('我的书架', sub) +
+ tabs([['all', `全部电子书 ${counts.ebooks || 0}`], ['reading', `在读 ${counts.reading || 0}`], ['done', `已读完 ${counts.done || 0}`], ['wish', `想读 ${counts.wish || 0}`], ['lists', `我的书单 ${counts.archives || 0}`], ['audio', `有声书 ${counts.albums || 0}`], ['articles', '文章收藏']], shelfTab, 'shelf-tab') + content;
+}
+
+function openList(id) {
+ const list = booklists.find(l => l.id === id);
+ if (!list) return;
+ const members = (list.ids || []).map(findBook).filter(Boolean);
+ showDetail('书单详情', `A COLLECTION OF GOOD BOOKS
${esc(list.name)}
${esc(list.description || '')}
${esc(list.source || '我的书单')} · ${list.book_count || members.length} 本
${members.map(book => bookRow(book)).join('') || '
书单中的书籍当前不在书架上。
'}
`);
+}
+
+/* ------------------------------- 阅读目标 ------------------------------- */
+
+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 `${result.status}
${esc(goal.name)}
${esc(goal.start)} — ${esc(goal.end)}
${goal.ids.slice(0, 4).map(id => cover(findBook(id), true)).join('')}${result.total > 4 ? `+ ${result.total - 4} 本` : ''}
${result.done} / ${result.total} 本已读完${result.percent}%
`;
+}
+function renderGoals() {
+ if (!shelfData) ensureShelf();
+ const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成'));
+ return welcome('把想读,变成读过', '几本书、一份书单,或一段想留给阅读的时间。', '') +
+ `YOUR NEXT CHAPTER
目标不必宏大,开始就很好。
每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。
${goals.length}个目标${goals.filter(goal => goalMetrics(goal).status === '已完成').length}个已完成${uniqueIds(goals.flatMap(goal => goal.ids)).length}本不同的书
❧ ` +
+ tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') +
+ `${list.map(goalCard).join('') || '
这里还没有目标,创建一份属于你的阅读清单。
'}
`;
+}
+function openGoalDetail(id) {
+ const goal = goals.find(g => g.id === id), m = goalMetrics(goal);
+ showDetail('阅读目标', `${m.status}${esc(goal.start)} — ${esc(goal.end)}
${esc(goal.name)}
${m.done} / ${m.total}本已读完${m.percent}%已完成
已读完直接计入完成,不受目标日期影响。
${goal.sources.length ? `加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目
` : ''}${goal.ids.map(id => bookRow(findBook(id))).join('')}
`);
+}
+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();
+ if (!shelfData) { $('#goal-picker').innerHTML = '正在同步书架…
'; ensureShelf(); return; }
+ const matched = books.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 = `快捷加入书单当前书目
${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => ``).join('')}
${list.map(book => `
`).join('') || '
没有匹配的书籍
'}
${matched.length > list.length ? `共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。
` : ''}`;
+}
+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 ? 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('阅读目标已保存,已读完的书已计入完成。');
+}
+function chooseTarget(ids, sources = []) {
+ showDetail('加入阅读目标', `选择已有目标,或为这些书新建目标。重复书籍只加入一次。
${goals.map(g => `
${esc(g.name)}${esc(g.start)} — ${esc(g.end)} · ${g.ids.length} 本
`).join('') || '
还没有目标,先新建一个。
'}
`);
+ 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('已加入目标,重复书籍已自动去重。');
+}
+
+/* ------------------------------- 笔记与划线 ------------------------------- */
+
+function renderNotes() {
+ if (!notebooksData && loadState.notes.__list !== 'error') { ensureNotebooks(); return statusBox('loading', '正在读取笔记本…', '来自微信读书的笔记本概览。'); }
+ if (!notebooksData) return statusBox('error', '暂时无法读取笔记本', loadError.notes.__list || '', 'retry-notes');
+
+ if (!notesData[noteBook] && loadState.notes[noteBook] !== 'loading' && loadState.notes[noteBook] !== 'ready' && loadState.notes[noteBook] !== 'error') ensureNotes(noteBook);
+ const payload = notesData[noteBook];
+ const items = notebooksData.items || [];
+ const menuBooks = items.slice(0, 10);
+ const counts = (payload && payload.counts) || { highlights: 0, thoughts: 0, bookmarks: 0 };
+ const notes = (payload && payload.notes) || [];
+
+ const shownNotes = filteredNotes(notes);
+ const noteSlice = shownNotes.slice(0, notesLimit);
+
+ const body = !payload
+ ? (loadState.notes[noteBook] === 'error'
+ ? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes')
+ : statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。'))
+ : `${counts.highlights + counts.thoughts + counts.bookmarks}条笔记划线 ${counts.highlights}想法 ${counts.thoughts}书签 ${counts.bookmarks}
` +
+ tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
+ (noteType === 'bookmark'
+ ? `${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。
`
+ : (noteSlice.map(noteCard).join('') || '当前筛选没有笔记。
')) +
+ (noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `` : '') +
+ `${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。
`;
+
+ return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '') +
+ ``;
+}
+function filteredNotes(notes) {
+ if (noteType === 'all') return notes;
+ if (noteType === 'highlight') return notes.filter(n => n.type === 'highlight');
+ if (noteType === 'thought') return notes.filter(n => n.type === 'thought');
+ return [];
+}
+function noteCard(note) {
+ return `${note.type === 'thought' ? '我的想法' : '划线'}${esc(note.text)}
《${esc(note.book_title || '')}》 · ${esc(note.date || '')}${esc(note.chapter || '未记录章节')}
${note.id ? `` : ''}`;
+}
+function exportNotes() {
+ const payload = notesData[noteBook] || { notes: [] };
+ const all = filteredNotes(payload.notes || []);
+ const notes = all.slice(0, 300);
+ const scopeName = noteBook === 'all' ? '全部笔记本(聚合)' : (notebooksData.items.find(item => item.id === noteBook) || {}).title || noteBook;
+ const header = `# 阅间笔记 · ${scopeName}\n\n导出时间:${today()} 来源:微信读书个人账号\n书签仅提供数量,不导出内容。${all.length > notes.length ? `\n本次导出前 ${notes.length} 条(共 ${all.length} 条),可切换单本笔记本分批导出。` : ''}\n\n`;
+ const text = header + notes.map(n => `## ${n.book_title || ''}\n\n${n.type === 'thought' ? '想法' : '划线'} · ${n.date || ''}${n.chapter ? ' · ' + n.chapter : ''}\n\n${n.text}`).join('\n\n');
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' }));
+ showDetail('导出笔记', `已生成${esc(scopeName)}的划线与想法,共 ${notes.length} 条。可下载 Markdown,或直接选中下方内容复制;不含书签内容。
`);
+ $('#detail').addEventListener('close', () => URL.revokeObjectURL(url), { once: true });
+}
+
+/* ------------------------------- 发现好书 ------------------------------- */
+
+function renderDiscover() {
+ const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']];
+ if (!shelfData) ensureShelf();
+ const key = discoverKey();
+ const payload = discoverData[key];
+ let content = '';
+ if (loadError.discover[key]) content = statusBox('error', '暂时无法获取数据', loadError.discover[key], 'retry-discover');
+ else if (!payload) { ensureDiscover(); content = statusBox('loading', '正在寻找好书…', searchScope === 'shelf' ? '在你的书架中检索。' : '实时检索微信读书书城。'); }
+ else if (discoverTab === 'recommend') {
+ const list = payload.books || [];
+ const prefer = (dashboardData[period] && dashboardData[period].prefer_words.category) || '你的阅读偏好';
+ content = `PICKED FOR YOUR NEXT CHAPTER
读过的书,会带你遇见下一本。
${esc(prefer)} · 微信读书实时推荐
${list.map(recommendCard).join('') || '
接口暂时没有返回推荐书籍。
'}
`;
+ } else if (discoverTab === 'lists') {
+ const lists = (payload.lists && payload.lists.length) ? payload.lists : booklists;
+ content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || '我的书单' }))) + (payload.lists && payload.lists.length ? '' : '书城书单搜索接口暂未返回结果,以上为你的书架书单。
');
+ } else if (discoverTab === 'author') {
+ const authors = payload.authors || [];
+ content = `${authors.map(author => `
${esc(author.name.slice(0, 1))}
${esc(author.name)}作者 · 书城检索
`).join('') || '
没有匹配的作者,换个关键词试试。
'}
`;
+ } else if (['novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) {
+ const list = payload.books || [];
+ const names = { novel: '网文', audio: '听书', fulltext: '全文', account: '公众号', article: '文章' };
+ content = list.length
+ ? `${list.map(searchCard).join('')}
`
+ : `「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。
`;
+ } else if (discoverTab === 'all') {
+ const list = payload.books || [];
+ content = `书籍
${list.slice(0, 8).map(searchCard).join('') || '
没有匹配的书籍。
'}
书单
${renderLists(booklists.slice(0, 3))}`;
+ } else {
+ const list = payload.books || [];
+ const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
+ content = `${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '
没有找到相关书籍,试试其他书名或作者。
'}
`;
+ }
+
+ return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') +
+ `` +
+ tabs(options, discoverTab, 'discover-tab') + `${content}
${(payload && payload.local) || searchScope === 'shelf' ? '书架检索' : '书城检索'} · 结果实时来自微信读书接口;推荐理由与书单成员以接口返回为准。
`;
+}
+function searchCard(book) {
+ const reason = book.reason ? `${esc(book.reason)}
` : '';
+ return ``;
+}
+function recommendCard(book) {
+ return ``;
+}
+
+/* ------------------------------- 详情弹窗 ------------------------------- */
+
+function showDetail(title, html) { $('#detail-title').textContent = title; $('#detail-content').innerHTML = html; if (!$('#detail').open) $('#detail').showModal(); }
+
+function openBook(id) { detailBook = id; bookTab = 'info'; selectedChapter = 0; reviewFilter = 0; chapterLimit = 30; renderBookDetail(); }
+function renderBookDetail() {
+ const id = detailBook;
+ if (!id) return;
+ if (loadError.book[id]) { showDetail('书籍详情', statusBox('error', '暂时无法读取书籍详情', loadError.book[id], 'retry-book')); return; }
+ const data = bookData[id];
+ if (!data) { ensureBook(id); showDetail('书籍详情', statusBox('loading', '正在读取书籍详情…', '简介、目录、笔记、热门划线与点评。')); return; }
+ const info = data.book;
+ const progress = data.progress.progress;
+ const state = progress >= 100 ? '已读完' : progress > 0 ? `已读 ${progress}%` : '尚未开始阅读';
+ const readTimeText = data.progress.record_reading_time ? ` · 累计 ${esc(data.progress.record_reading_time_str)}` : '';
+ showDetail('书籍详情', `${cover(info)}
READING SPACE / BOOK
${esc(info.title)}
${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}
${info.rating ? `评分 ${Math.round(info.rating)}/100 · ${info.rating_count} 人评价` : '暂无评分'}
${state}${readTimeText}
${info.deep_link ? `
在微信读书中打开 ↗` : ''}
${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => ``).join('')}
${renderBookTab(data)}
`);
+}
+function renderBookTab(data) {
+ const info = data.book;
+ if (bookTab === 'info') return `${esc(info.intro || '接口未返回简介。')}
分类:${esc(info.category || '未分类')}
最近阅读:${esc(data.progress.update_time || '暂无记录')}
出版社:${esc(info.publisher || '未提供')}
出版日期:${esc(info.publish_time || '未提供')}
字数:${info.word_count ? `${Math.round(info.word_count / 10000)} 万字` : '未提供'}
ISBN:${esc(info.isbn || '未提供')}
`;
+ if (bookTab === 'chapters') {
+ const chapters = data.chapters.slice(0, chapterLimit);
+ return `共 ${data.chapter_total} 章,当前显示 ${chapters.length} 章;点击可查看该章热门划线。
${chapters.map(chapter => `${String(chapter.idx).padStart(2, '0')} ${esc(chapter.title)}${chapter.words ? ` · ${Math.max(1, Math.round(chapter.words / 1000))} 千字` : ''}
`).join('') || '接口未返回目录。
'}${data.chapters.length > chapters.length ? `` : ''}`;
+ }
+ if (bookTab === 'notes') {
+ const notes = data.notes.items || [];
+ const counts = data.notes.counts;
+ return `${counts.highlights}条划线${counts.thoughts}条想法${counts.bookmarks}个书签
${notes.map(note => `${note.type === 'thought' ? '个人想法' : '划线'}${esc(note.text)}
${esc(note.date || '')}${note.chapter ? ' · ' + esc(note.chapter) : ''}${note.url ? `
` : ''}
`).join('') || '这本书暂无个人笔记。
'}`;
+ }
+ if (bookTab === 'popular') {
+ const marks = numberData[`hl:${info.id}:${selectedChapter}`] || data.best_bookmarks;
+ const items = (marks.items || []).slice(0, 20);
+ const chapterOptions = [['0', '全部章节']].concat(data.chapters.slice(0, 40).map(chapter => [String(chapter.uid), chapter.title]));
+ return `${selectedChapter ? esc((data.chapters.find(chapter => String(chapter.uid) === String(selectedChapter)) || {}).title || '所选章节') : '全书'} · 热门划线最多展示 20 条
${items.map(item => `${item.count} 人划线${esc(item.text)}
${esc(item.chapter || '')}${marks.total ? ` · 全书共 ${marks.total} 条热门划线` : ''}`).join('') || '接口未返回热门划线。
'}章节热度提供人数与位置;原文来自热门划线接口。
`;
+ }
+ if (bookTab === 'reviews') {
+ const payload = numberData[`rv:${info.id}:${reviewFilter}`] || { items: data.reviews.items, count: data.reviews.count };
+ const list = payload.items || [];
+ return tabs([[0, '全部'], [1, '推荐'], [4, '一般'], [2, '不行'], [3, '最新']], reviewFilter, 'review-filter') +
+ `共 ${payload.count || list.length} 条公开点评
` +
+ (list.map(review => `${esc(review.name)}${review.is_finish ? ' · 已读完' : ''}${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}
${esc(review.text)}
${esc(review.date || '')}${review.chapter ? ' · ' + esc(review.chapter) : ''}
`).join('') || '该筛选下接口没有返回点评。
');
+ }
+ const similar = data.similar || [];
+ return `相似推荐 · 实时来自微信读书「相似书籍」接口
${similar.map(bookCard).join('') || '
接口未返回相似推荐。
'}
`;
+}
+function openThought(text, subtitle) { showDetail('想法详情', `内容${esc(text)}
${esc(subtitle)}`); }
+async function openThoughtDetail(reviewId, bookId) {
+ showDetail('想法详情', statusBox('loading', '正在读取想法详情…', '正文、点赞与评论。'));
+ try {
+ const data = await WeApi.thought(reviewId);
+ showDetail('想法详情', `我的想法${esc(data.text)}
${data.abstract ? `划线原文:${esc(data.abstract)}
` : ''}${esc(data.date || '')}${data.chapter ? ' · ' + esc(data.chapter) : ''}${data.like_count}个赞${data.comment_count}条评论
${(data.comments || []).map(comment => `${esc(comment.name)}${esc(comment.date || '')}
${esc(comment.text)}
`).join('') || '这条想法还没有评论。
'}${bookId ? `` : ''}
`);
+ } catch (error) {
+ showDetail('想法详情', statusBox('error', '暂时无法读取想法详情', error.message, ''));
+ }
+}
+async function openHighlightThoughts(bookId, chapterUid, range) {
+ showDetail('划线下的想法', statusBox('loading', '正在读取他人的想法…', '来自划线定位接口。'));
+ try {
+ const data = await WeApi.underlineThoughts(bookId, chapterUid, range);
+ const items = data.items || [];
+ showDetail('划线下的想法', `该划线范围内取到 ${items.length} 条想法
` + (items.map(item => `${esc(item.name)}${esc(item.date || '')}
${esc(item.text)}
`).join('') || '这条划线下暂无他人想法。
'));
+ } catch (error) {
+ showDetail('划线下的想法', statusBox('error', '暂时无法读取划线下想法', error.message, ''));
+ }
+}
+
+/* ------------------------------- 行为分发 ------------------------------- */
+
+function handleAction(button) {
+ const action = button.dataset.action, id = button.dataset.id;
+ 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 'goal-filter': goalFilter = id; renderPage(); break;
+ case 'close-detail': $('#detail').close(); break;
+ case 'close-editor': $('#goal-editor').close(); break;
+ case 'pick-list': {
+ const list = booklists.find(l => l.id === id);
+ if (list) { list.ids.forEach(bookId => draftIds.add(bookId)); if (!draftSources.includes(list.name)) draftSources.push(list.name); }
+ renderPicker(); break;
}
+ case 'book': openBook(id); break;
+ case 'list': openList(id); break;
+ case 'add-book': chooseTarget([id]); break;
+ case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; }
+ case 'append-goal': appendGoal(id); break;
+ case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break;
+ case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break;
+ case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break;
+ case 'period': period = id; renderPage(); break;
+ case 'discover-tab': discoverTab = id; renderPage(); break;
+ case 'retry-overview': ensureDashboard(period, true); break;
+ case 'retry-shelf': ensureShelf(true); break;
+ case 'retry-notes': if (!notebooksData) ensureNotebooks(true); else ensureNotes(noteBook, true); break;
+ case 'retry-discover': ensureDiscover(true); break;
+ case 'retry-book': ensureBook(detailBook, true); break;
+ case 'notebook': noteBook = id; noteType = 'all'; notesLimit = 30; renderPage(); break;
+ case 'note-type': noteType = id; notesLimit = 30; renderPage(); break;
+ case 'notes-more': notesLimit += 30; renderPage(); break;
+ case 'export-notes': exportNotes(); break;
+ case 'personal-note': {
+ const note = ((notesData[noteBook] || {}).notes || []).find(n => n.id === id);
+ if (!note) break;
+ if (note.type === 'thought') openThoughtDetail(note.id, note.bookId);
+ else openThought(note.text, `${note.book_title || ''} · ${note.chapter || ''} · ${note.date || ''}`);
+ break;
+ }
+ case 'book-tab': bookTab = id; renderBookDetail(); break;
+ case 'chapter': selectedChapter = id; bookTab = 'popular'; renderBookDetail(); if (bookData[detailBook] && !numberData[`hl:${detailBook}:${id}`]) WeApi.highlights(detailBook, id).then(res => { numberData[`hl:${detailBook}:${id}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); break;
+ case 'chapter-more': chapterLimit += 30; renderBookDetail(); break;
+ case 'review-filter': reviewFilter = Number(id); renderBookDetail(); WeApi.reviews(detailBook, reviewFilter, 10).then(res => { numberData[`rv:${detailBook}:${reviewFilter}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); break;
+ case 'review-detail': openThought(button.dataset.text, `${button.dataset.name} · 公开点评`); break;
+ case 'highlight-thoughts': openHighlightThoughts(button.dataset.id, button.dataset.chapter, button.dataset.range); break;
+ case 'author-books': searchQuery = button.dataset.name; discoverTab = 'books'; searchScope = 'store'; $('#global-search').value = searchQuery; navigate('discover'); break;
+ case 'article-search': searchQuery = ''; discoverTab = 'article'; navigate('discover'); break;
+ case 'year-report': {
+ const year = dashboardData[period] && dashboardData[period].year_summary;
+ if (!year || !year.duration_str) { toast('年度数据暂时不可用'); break; }
+ showDetail('年度回顾', `${year.year} / A YEAR IN BOOKS
翻过的每一页,都算数。
${esc(year.duration_str)} · ${year.read_days} 个阅读日${year.read_count ? ' · 读过 ' + esc(year.read_count) : ''}${year.finish_count ? ' · 读完 ' + esc(year.finish_count) : ''}
${esc(year.prefer_category || '')}${year.prefer_time ? ',' + esc(year.prefer_time) : ''}。以上来自微信读书阅读统计接口。
${(year.top_books || []).map(book => `${esc(book.title)}${esc(book.read_time_str)}
`).join('')}`);
+ break;
+ }
+ }
}
-
-// ===== 初始化 =====
-
-document.addEventListener("DOMContentLoaded", async () => {
- const isHealthy = await checkDBHealth();
- if (!isHealthy) return;
-
- loadOverview();
- loadTopBooks();
- loadShelfCategories();
- loadShelf(1);
- loadNotebooks();
- loadRecommend(0);
- initBackToTop();
-
- // 搜索和筛选事件
- document.getElementById("shelfSearch").addEventListener("input", debounce(() => loadShelf(1), 300));
- document.getElementById("finishedOnly").addEventListener("change", () => loadShelf(1));
+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); }
});
-
-// ===== 总览数据 =====
-async function loadOverview() {
- try {
- const data = await fetchAPI("/api/overview");
-
- const heroStats = document.getElementById("heroStats");
- heroStats.innerHTML = `
-
-
${formatHours(data.total_read_time)}
-
累计阅读 (小时)
-
-
-
${data.read_days}
-
阅读天数
-
-
-
${data.shelf_count}
-
书架藏书
-
-
-
${data.total_notes}
-
读书笔记
-
- `;
-
- document.getElementById("heroSubtitle").textContent =
- `自 ${data.regist_time} 加入微信读书,已陪伴你 ${data.read_days} 个阅读的日子`;
-
- const statsGrid = document.getElementById("statsGrid");
- statsGrid.innerHTML = `
-
-
⏱️
-
${data.total_read_time_str}
-
累计阅读时长
-
今年: ${data.year_read_time_str}
-
-
-
📅
-
${data.read_days}
-
累计阅读天数
-
今年: ${data.year_read_days} 天
-
-
-
📚
-
${data.shelf_count}
-
书架书籍
-
已读完: ${data.finished_count} 本
-
-
-
✍️
-
${data.total_notes}
-
读书笔记
-
来自 ${data.notebook_count} 本书
-
- `;
-
- const timeStr = `数据更新: ${data.fetch_time}`;
- document.getElementById("fetchTime").textContent = timeStr;
- document.getElementById("footerTime").textContent = data.fetch_time;
- } catch (e) {
- console.error("加载总览失败:", e);
- }
-}
-
-// ===== 年度趋势图 =====
-async function loadYearlyTrend() {
- try {
- const data = await fetchAPI("/api/yearly-trend");
- const el = document.getElementById("chart-yearly");
- if (chartInstances.yearly) chartInstances.yearly.dispose();
- const chart = echarts.init(el);
- chartInstances.yearly = chart;
-
- const c = getChartColors();
- const years = data.map((d) => d.year);
- const hours = data.map((d) => d.hours);
-
- chart.setOption({
- tooltip: {
- trigger: "axis",
- backgroundColor: c.tooltipBg,
- borderColor: c.tooltipBorder,
- textStyle: { color: c.tooltipText },
- formatter: (params) => {
- const p = params[0];
- return `${p.name}年
阅读: ${p.value} 小时`;
- },
- },
- grid: { left: 50, right: 30, top: 30, bottom: 40 },
- xAxis: {
- type: "category",
- data: years,
- axisLabel: { color: c.axis },
- axisLine: { lineStyle: { color: c.axisLine } },
- },
- yAxis: {
- type: "value",
- name: "小时",
- nameTextStyle: { color: c.axis },
- axisLabel: { color: c.axis },
- splitLine: { lineStyle: { color: c.splitLine } },
- },
- series: [{
- data: hours,
- type: "bar",
- barWidth: "50%",
- itemStyle: {
- borderRadius: [8, 8, 0, 0],
- color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
- colorStops: [
- { offset: 0, color: c.accentLight },
- { offset: 1, color: c.accent },
- ] },
- },
- label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" },
- }],
- });
- window.addEventListener("resize", () => chart.resize());
- } catch (e) {
- console.error("加载年度趋势失败:", e);
- }
-}
-
-// ===== 24小时时段分布 =====
-async function loadTimeDistribution() {
- try {
- const data = await fetchAPI("/api/time-distribution");
- const el = document.getElementById("chart-hours");
- if (chartInstances.hours) chartInstances.hours.dispose();
- const chart = echarts.init(el);
- chartInstances.hours = chart;
-
- const c = getChartColors();
- const hours = data.map((d) => `${d.hour}时`);
- const minutes = data.map((d) => d.duration_min);
-
- chart.setOption({
- tooltip: {
- trigger: "axis",
- backgroundColor: c.tooltipBg,
- borderColor: c.tooltipBorder,
- textStyle: { color: c.tooltipText },
- formatter: (params) => {
- const p = params[0];
- return `${p.name}
阅读: ${p.value} 分钟`;
- },
- },
- grid: { left: 50, right: 20, top: 20, bottom: 40 },
- xAxis: {
- type: "category",
- data: hours,
- axisLabel: { color: c.axis, fontSize: 10, interval: 2 },
- axisLine: { lineStyle: { color: c.axisLine } },
- },
- yAxis: {
- type: "value",
- name: "分钟",
- nameTextStyle: { color: c.axis },
- axisLabel: { color: c.axis },
- splitLine: { lineStyle: { color: c.splitLine } },
- },
- series: [{
- data: minutes,
- type: "line",
- smooth: true,
- symbol: "circle",
- symbolSize: 4,
- lineStyle: { width: 2, color: c.accent },
- itemStyle: { color: c.accent },
- areaStyle: {
- color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
- colorStops: [
- { offset: 0, color: c.accent + "4D" },
- { offset: 1, color: c.accent + "05" },
- ] },
- },
- }],
- });
- window.addEventListener("resize", () => chart.resize());
- } catch (e) {
- console.error("加载时段分布失败:", e);
- }
-}
-
-// ===== 偏好分类 =====
-async function loadCategories() {
- try {
- const data = await fetchAPI("/api/categories");
- const el = document.getElementById("chart-categories");
- if (chartInstances.categories) chartInstances.categories.dispose();
- const chart = echarts.init(el);
- chartInstances.categories = chart;
-
- const c = getChartColors();
- const items = data.slice(0, 8).map((d) => ({
- name: d.category_title,
- value: d.reading_time,
- }));
-
- chart.setOption({
- tooltip: {
- trigger: "item",
- backgroundColor: c.tooltipBg,
- borderColor: c.tooltipBorder,
- textStyle: { color: c.tooltipText },
- formatter: (p) => {
- const hours = (p.value / 3600).toFixed(1);
- return `${p.name}
阅读: ${hours} 小时 (${p.percent}%)`;
- },
- },
- legend: {
- type: "scroll",
- orient: "vertical",
- right: 10,
- top: 20,
- bottom: 20,
- textStyle: { color: c.label, fontSize: 11 },
- },
- series: [{
- type: "pie",
- radius: ["40%", "70%"],
- center: ["35%", "50%"],
- avoidLabelOverlap: false,
- itemStyle: { borderRadius: 6, borderColor: c.borderColor, borderWidth: 2 },
- label: { show: false },
- emphasis: {
- label: { show: true, fontSize: 14, fontWeight: "bold", color: c.tooltipText },
- },
- data: items,
- color: ["#c17b4a", "#e8a87c", "#d4a574", "#b8860b", "#cd853f", "#daa520", "#bc8f8f", "#bdb76b"],
- }],
- });
- window.addEventListener("resize", () => chart.resize());
- } catch (e) {
- console.error("加载分类失败:", e);
- }
-}
-
-// ===== 偏好作者 =====
-async function loadAuthors() {
- try {
- const data = await fetchAPI("/api/authors");
- const el = document.getElementById("chart-authors");
- if (chartInstances.authors) chartInstances.authors.dispose();
- const chart = echarts.init(el);
- chartInstances.authors = chart;
-
- const c = getChartColors();
- const top10 = data.slice(0, 10);
- const names = top10.map((d) => d.name);
- const counts = top10.map((d) => d.count);
-
- chart.setOption({
- tooltip: {
- trigger: "axis",
- axisPointer: { type: "shadow" },
- backgroundColor: c.tooltipBg,
- borderColor: c.tooltipBorder,
- textStyle: { color: c.tooltipText },
- formatter: (params) => {
- const p = params[0];
- return `${p.name}
阅读: ${p.value} 本`;
- },
- },
- grid: { left: 100, right: 40, top: 20, bottom: 20 },
- xAxis: {
- type: "value",
- axisLabel: { color: c.axis },
- splitLine: { lineStyle: { color: c.splitLine } },
- },
- yAxis: {
- type: "category",
- data: names.reverse(),
- axisLabel: { color: c.axis, fontSize: 12 },
- axisLine: { lineStyle: { color: c.axisLine } },
- },
- series: [{
- type: "bar",
- data: counts.reverse(),
- barWidth: "50%",
- itemStyle: {
- borderRadius: [0, 6, 6, 0],
- color: { type: "linear", x: 0, y: 0, x2: 1, y2: 0,
- colorStops: [
- { offset: 0, color: c.accentLight },
- { offset: 1, color: c.accent },
- ] },
- },
- label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" },
- }],
- });
- window.addEventListener("resize", () => chart.resize());
- } catch (e) {
- console.error("加载作者失败:", e);
- }
-}
-
-// ===== 读书排行(竖向列表) =====
-async function loadTopBooks() {
- try {
- const data = await fetchAPI("/api/top-books");
- const list = document.getElementById("topBooksGrid");
-
- list.innerHTML = data
- .map((book, i) => {
- const tagsHtml = book.tags
- .map((t) => `${t}`)
- .join("");
- return `
-
-
${i + 1}
-
})
-
-
⏱ ${book.read_time_str}
-
`;
- })
- .join("");
- } catch (e) {
- console.error("加载排行失败:", e);
- }
-}
-
-// ===== 书架 =====
-let shelfCurrentPage = 1;
-
-async function loadShelf(page) {
- shelfCurrentPage = page;
- const keyword = document.getElementById("shelfSearch").value.trim();
- const finished = document.getElementById("finishedOnly").checked ? "1" : "0";
- const params = new URLSearchParams({ page: page, per_page: 60, finished: finished });
- if (keyword) params.set("keyword", keyword);
- if (shelfActiveCategory) {
- params.set("category", shelfActiveCategory);
- } else if (shelfActiveParent) {
- params.set("parent_category", shelfActiveParent);
- }
- if (shelfSortMode === "category") params.set("sort", "category");
-
- try {
- const data = await fetchAPI(`/api/shelf?${params}`);
- const grid = document.getElementById("bookGrid");
-
- document.getElementById("shelfInfo").textContent =
- `共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`;
-
- grid.innerHTML = data.books.map((book) => renderBookCard(book)).join("");
-
- renderPagination(page, data.total_pages);
- } catch (e) {
- console.error("加载书架失败:", e);
- }
-}
-
-function renderBookCard(book) {
- const badges = [];
- if (book.is_top) badges.push(`置顶`);
- if (book.finish_reading) badges.push(`已读完`);
- return `
-
-
-
})
- ${badges.join("")}
-
-
${book.title}
-
${book.author}
-
`;
-}
-
-function renderPagination(current, total) {
- const pag = document.getElementById("pagination");
- if (total <= 1) { pag.innerHTML = ""; return; }
-
- let html = ``;
- const maxButtons = 7;
- let start = Math.max(1, current - 3);
- let end = Math.min(total, start + maxButtons - 1);
- start = Math.max(1, end - maxButtons + 1);
-
- if (start > 1) {
- html += ``;
- if (start > 2) html += `...`;
- }
- for (let i = start; i <= end; i++) {
- html += ``;
- }
- if (end < total) {
- if (end < total - 1) html += `...`;
- html += ``;
- }
- html += ``;
- pag.innerHTML = html;
-}
-
-function debounce(fn, delay) {
- let timer;
- return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
-}
-
-// ===== 笔记概览 =====
-async function loadNotebooks() {
- try {
- const data = await fetchAPI("/api/notebooks");
- const summary = document.getElementById("notesSummary");
-
- const totalHighlights = data.reduce((s, d) => s + d.note_count, 0);
- const totalReviews = data.reduce((s, d) => s + d.review_count, 0);
- const totalBookmarks = data.reduce((s, d) => s + d.bookmark_count, 0);
-
- summary.innerHTML = `
-
-
${data.length}
-
有笔记的书
-
-
-
${totalHighlights}
-
划线
-
-
-
${totalReviews}
-
想法/点评
-
-
-
${totalBookmarks}
-
书签
-
- `;
-
- const grid = document.getElementById("notebooksGrid");
- grid.innerHTML = data
- .map((nb) => {
- const progress = nb.reading_progress || 0;
- return `
-
-
})
-
-
${nb.title}
-
${nb.author}
-
- 划线 ${nb.note_count}
- 想法 ${nb.review_count}
- 书签 ${nb.bookmark_count}
-
-
-
-
`;
- })
- .join("");
- } catch (e) {
- console.error("加载笔记失败:", e);
- }
-}
-
-// ===== 书籍详情弹窗 =====
-function openBook(bookId) {
- if (!bookId) return;
- showModal("bookDetailModal");
- const body = document.getElementById("bookDetailBody");
- body.innerHTML = ``;
-
- fetchAPI(`/api/book/detail?book_id=${bookId}`)
- .then((data) => {
- const p = data.progress || {};
- const progress = p.progress || 0;
- const rating = data.new_rating > 0 ? (data.new_rating / 10).toFixed(1) : null;
- const tags = [];
- if (data.category) tags.push(data.category);
- if (rating) tags.push(`${rating}分`);
- if (data.new_rating_count) tags.push(`${data.new_rating_count}人评分`);
-
- body.innerHTML = `
-
- ${data.intro ? `${data.intro}
` : ''}
-
- ${data.publisher ? `
出版社: ${data.publisher}
` : ''}
- ${data.publish_time ? `
出版时间: ${data.publish_time}
` : ''}
- ${data.isbn ? `
ISBN: ${data.isbn}
` : ''}
- ${data.word_count ? `
字数: ${(data.word_count / 10000).toFixed(1)}万字
` : ''}
-
-
-
-
-
- `;
- })
- .catch((e) => {
- body.innerHTML = `加载失败: ${e.message}
`;
- });
-}
-
-// ===== 想法详情独立页面 =====
-
-function openThoughtsPage(bookId, title, author, cover) {
- if (!bookId) return;
-
- // 隐藏主视图,显示想法页面
- document.getElementById("mainView").style.display = "none";
- document.getElementById("thoughtsPage").style.display = "block";
- window.scrollTo({ top: 0, behavior: "auto" });
-
- // 渲染头部
- const header = document.getElementById("thoughtsBookHeader");
- header.innerHTML = `
-
-
-
${title}
-
${author || ''}
-
加载中...
-
- `;
-
- const content = document.getElementById("thoughtsContent");
- content.innerHTML = ``;
-
- fetchAPI(`/api/book/notes?book_id=${bookId}`)
- .then((data) => {
- currentThoughtsData = data;
- renderThoughtsPage(data);
- })
- .catch((e) => {
- content.innerHTML = `加载失败: ${e.message}
`;
- });
-}
-
-function closeThoughtsPage() {
- document.getElementById("thoughtsPage").style.display = "none";
- document.getElementById("mainView").style.display = "";
- currentThoughtsData = null;
-}
-
-function renderThoughtsPage(data) {
- // 更新统计
- const stats = document.getElementById("thoughtsBookStats");
-
- // 分类:划线 / 书评 / 想法
- const highlights = data.highlights || [];
- const bookReviews = (data.reviews || []).filter((r) => r.is_finish);
- const thoughts = (data.reviews || []).filter((r) => !r.is_finish);
-
- stats.innerHTML = `
- 划线 ${highlights.length}
- 书评 ${bookReviews.length}
- 想法 ${thoughts.length}
- `;
-
- const content = document.getElementById("thoughtsContent");
-
- if (highlights.length === 0 && bookReviews.length === 0 && thoughts.length === 0) {
- content.innerHTML = `暂无笔记数据
`;
- return;
- }
-
- let html = `
-
-
- `;
-
- content.innerHTML = html;
- currentThoughtsCategory = "all";
- currentThoughtsSort = "time_desc";
- currentThoughtsSearch = "";
- renderThoughtsList("all");
-}
-
-function filterThoughts(category) {
- document.querySelectorAll(".thought-filter-btn").forEach((btn) => {
- btn.classList.remove("active");
- });
- event.target.closest(".thought-filter-btn").classList.add("active");
- currentThoughtsCategory = category;
- renderThoughtsList(category);
-}
-
-function onThoughtsSearch(val) {
- currentThoughtsSearch = val.trim().toLowerCase();
- renderThoughtsList(currentThoughtsCategory);
-}
-
-function onThoughtsSort(val) {
- currentThoughtsSort = val;
- renderThoughtsList(currentThoughtsCategory);
-}
-
-/** 解析日期字符串为时间戳,无效则返回 0 */
-function parseDate(str) {
- if (!str) return 0;
- const d = new Date(str.replace(/-/g, "/"));
- return isNaN(d.getTime()) ? 0 : d.getTime();
-}
-
-/** 对数组按排序模式排序,返回新数组 */
-function sortByMode(arr) {
- const sorted = [...arr];
- if (currentThoughtsSort === "time_desc") {
- sorted.sort((a, b) => parseDate(b.create_time) - parseDate(a.create_time));
- } else if (currentThoughtsSort === "time_asc") {
- sorted.sort((a, b) => parseDate(a.create_time) - parseDate(b.create_time));
- } else if (currentThoughtsSort === "chapter") {
- sorted.sort((a, b) => {
- const ua = parseInt(a.chapter_uid) || 0;
- const ub = parseInt(b.chapter_uid) || 0;
- if (ua !== ub) return ua - ub;
- // 同章节内按 range 起始位置排序
- const ra = a.range ? parseInt(a.range.split("-")[0]) : 0;
- const rb = b.range ? parseInt(b.range.split("-")[0]) : 0;
- return ra - rb;
- });
- }
- return sorted;
-}
-
-/** 过滤搜索关键词 */
-function matchSearch(text) {
- if (!currentThoughtsSearch) return true;
- return (text || "").toLowerCase().includes(currentThoughtsSearch);
-}
-
-function renderThoughtsList(category) {
- const data = currentThoughtsData;
- if (!data) return;
-
- // 原始分类
- let highlights = data.highlights || [];
- let bookReviews = (data.reviews || []).filter((r) => r.is_finish);
- let thoughts = (data.reviews || []).filter((r) => !r.is_finish);
-
- // 搜索过滤
- highlights = highlights.filter((h) => matchSearch(h.mark_text));
- bookReviews = bookReviews.filter((r) => matchSearch(r.content) || matchSearch(r.abstract));
- thoughts = thoughts.filter((r) => matchSearch(r.content) || matchSearch(r.abstract));
-
- // 排序
- highlights = sortByMode(highlights);
- bookReviews = sortByMode(bookReviews);
- thoughts = sortByMode(thoughts);
-
- const list = document.getElementById("thoughtsList");
- let html = "";
-
- // 划线区
- if (category === "all" || category === "highlight") {
- if (highlights.length > 0) {
- html += `📌 划线 ${highlights.length}
`;
- if (currentThoughtsSort === "chapter") {
- // 按章节分组展示
- const grouped = {};
- highlights.forEach((h) => {
- const ch = h.chapter_title || "未知章节";
- if (!grouped[ch]) grouped[ch] = [];
- grouped[ch].push(h);
- });
- for (const [chTitle, items] of Object.entries(grouped)) {
- html += ``;
- html += `
${chTitle}
`;
- items.forEach((h) => {
- html += `
-
- ${escapeHtml(h.mark_text)}
-
- ${h.chapter_title ? `${h.chapter_title}` : ""}
- ${h.create_time ? `${h.create_time}` : ""}
-
-
- `;
- });
- html += `
`;
- }
- } else {
- // 按时间排序,平铺展示(标注章节)
- highlights.forEach((h) => {
- html += `
-
- ${escapeHtml(h.mark_text)}
-
- ${h.chapter_title ? `${h.chapter_title}` : ""}
- ${h.create_time ? `${h.create_time}` : ""}
-
-
- `;
- });
- }
- }
- }
-
- // 书评区
- if (category === "all" || category === "review") {
- if (bookReviews.length > 0) {
- html += `⭐ 书评 ${bookReviews.length}
`;
- bookReviews.forEach((r) => {
- const starVal = Math.max(0, Math.min(5, parseInt(r.star) || 0));
- const stars = starVal > 0 ? `${"★".repeat(starVal)}${"☆".repeat(5 - starVal)}
` : "";
- html += `
-
- ${stars}
- ${escapeHtml(r.content)}
-
- ${r.chapter_name ? `${r.chapter_name}` : ""}
- ${r.create_time || ""}
-
-
- `;
- });
- }
- }
-
- // 想法区
- if (category === "all" || category === "thought") {
- if (thoughts.length > 0) {
- html += `💭 想法 ${thoughts.length}
`;
- thoughts.forEach((r) => {
- const abstract = r.abstract ? `${escapeHtml(r.abstract)}
` : "";
- html += `
-
- ${abstract}
-
${escapeHtml(r.content)}
-
- ${r.chapter_name ? `${r.chapter_name}` : ""}
- ${r.create_time || ""}
-
-
- `;
- });
- }
- }
-
- if (!html) {
- if (currentThoughtsSearch) {
- html = `未找到包含"${escapeHtml(currentThoughtsSearch)}"的内容
`;
- } else {
- html = `该分类暂无内容
`;
- }
- }
-
- list.innerHTML = html;
-}
-
-// ===== 弹窗控制 =====
-function showModal(id) {
- document.getElementById(id).classList.add("active");
- document.body.style.overflow = "hidden";
-}
-
-function closeModal(id) {
- document.getElementById(id).classList.remove("active");
- document.body.style.overflow = "";
-}
-
-document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") {
- closeModal("bookDetailModal");
- if (document.getElementById("thoughtsPage").style.display !== "none") {
- closeThoughtsPage();
- }
- }
+document.addEventListener('change', event => {
+ if (event.target.matches('[data-pick-book]')) { const id = event.target.dataset.pickBook; event.target.checked ? draftIds.add(id) : draftIds.delete(id); renderPicker(); }
+ if (event.target.id === 'chapter-filter') {
+ selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail();
+ if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message));
+ }
+ if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); }
});
+$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; };
+$('#goal-name').oninput = () => $('#goal-name').setCustomValidity('');
+$('#goal-form').onsubmit = saveGoal;
+$('#picker-search').oninput = renderPicker;
+$('#global-search').oninput = event => {
+ searchQuery = event.target.value.trim();
+ if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) 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(); } });
+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();
+}));
-// ===== 推荐发现 =====
-let recommendMaxIdx = 0;
+/* ------------------------------- 启动 ------------------------------- */
-function switchDiscoverTab(tab) {
- const recPanel = document.getElementById("recommendPanel");
- const searchPanel = document.getElementById("searchPanel");
- const tabRec = document.getElementById("tabRecommend");
- const tabSearch = document.getElementById("tabSearch");
-
- if (tab === "recommend") {
- recPanel.style.display = "";
- searchPanel.style.display = "none";
- tabRec.classList.add("active");
- tabSearch.classList.remove("active");
- } else {
- recPanel.style.display = "none";
- searchPanel.style.display = "";
- tabRec.classList.remove("active");
- tabSearch.classList.add("active");
- }
-}
-
-async function loadRecommend(maxIdx) {
- const grid = document.getElementById("recommendGrid");
- grid.innerHTML = ``;
-
- try {
- const data = await fetchAPI(`/api/recommend?maxIdx=${maxIdx}`);
- recommendMaxIdx = data.last_search_idx || 0;
- renderDiscoverGrid(grid, data.books, true);
- } catch (e) {
- grid.innerHTML = `加载推荐失败: ${e.message}
`;
- }
-}
-
-async function loadMoreRecommend() {
- loadRecommend(recommendMaxIdx);
-}
-
-async function doSearch() {
- const keyword = document.getElementById("searchInput").value.trim();
- if (!keyword) return;
-
- const grid = document.getElementById("searchGrid");
- grid.innerHTML = ``;
-
- try {
- const data = await fetchAPI(`/api/search?keyword=${encodeURIComponent(keyword)}`);
- if (data.books.length === 0) {
- grid.innerHTML = `未找到相关书籍
`;
- return;
- }
- renderDiscoverGrid(grid, data.books, false);
- } catch (e) {
- grid.innerHTML = `搜索失败: ${e.message}
`;
- }
-}
-
-function renderDiscoverGrid(grid, books, showReason) {
- grid.innerHTML = books
- .map((book) => {
- const rating = book.new_rating > 0 ? (book.new_rating / 10).toFixed(1) : null;
- const ratingLabel = book.rating_title ? `${book.rating_title}` : "";
- const ratingHtml = rating ? `${rating}` : "";
- const readingHtml = book.reading_count ? `${book.reading_count}人在读` : "";
- const reasonHtml = (showReason && book.reason) ? `${book.reason}
` : "";
- const categoryHtml = book.category ? `${book.category}
` : "";
-
- return `
-
-
})
-
-
${book.title}
-
${book.author || ''}
-
${ratingHtml}${ratingLabel}${readingHtml}
- ${categoryHtml}
- ${reasonHtml}
-
-
- `;
- })
- .join("");
+async function boot() {
+ renderPage();
+ await Promise.all([ensureStatus(), ensureShelf()]);
+ ensureDashboard(period);
+ if (page !== 'overview') renderPage();
}
+boot();
diff --git a/static/js/demo-data.js b/static/js/demo-data.js
new file mode 100644
index 0000000..ea9740a
--- /dev/null
+++ b/static/js/demo-data.js
@@ -0,0 +1,25 @@
+// 所有数据均为设计示例,不来自真实账号。
+const books = [{title:'也许你该找个人聊聊',short:'也许你该\n找个人\n聊聊',author:'洛莉·戈特利布',en:'MAYBE YOU SHOULD TALK TO SOMEONE',cover:'c1',progress:68,status:'reading',score:'9.1',desc:'一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。'},{title:'三体',short:'三体',author:'刘慈欣',en:'THE THREE-BODY PROBLEM',cover:'c2',progress:42,status:'reading',score:'9.4',desc:'从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。'},{title:'蛤蟆先生去看心理医生',short:'蛤蟆先生\n去看\n心理医生',author:'罗伯特·戴博德',en:'COUNSELLING FOR TOADS',cover:'c3',progress:85,status:'reading',score:'8.8',desc:'借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。'},{title:'人生海海',short:'人生\n海海',author:'麦家',en:'LIFE IS A VAST SEA',cover:'c4',progress:26,status:'reading',score:'9.0',desc:'在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。'},{title:'活着',short:'活着',author:'余华',en:'TO LIVE',cover:'c3',progress:100,status:'done',score:'9.5',desc:'一个人与时代相遇的故事,关于生命的韧性与日常的重量。'},{title:'被讨厌的勇气',short:'被讨厌\n的勇气',author:'岸见一郎 / 古贺史健',en:'THE COURAGE TO BE DISLIKED',cover:'c1',progress:100,status:'done',score:'8.6',desc:'以对话的方式讨论人际关系、自我接纳与选择的自由。'},{title:'悉达多',short:'悉达多',author:'赫尔曼·黑塞',en:'SIDDHARTHA',cover:'c4',progress:0,status:'wish',score:'9.2',desc:'一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。'},{title:'置身事内',short:'置身\n事内',author:'兰小欢',en:'INSIDE THE SYSTEM',cover:'c2',progress:0,status:'wish',score:'9.3',desc:'从具体的经济生活出发,理解政府与经济发展的关系。'}];
+books.forEach((book, index) => { book.id = `b${index}`; });
+const booklists = [
+ {id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']},
+ {id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']},
+ {id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']},
+ {id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']}
+];
+const initialGoals = [
+ {id:'g1',name:'秋季文学计划',start:'2026-09-01',end:'2026-11-30',ids:['b3','b4','b6','b1'],sources:['在故事里,读懂人生']},
+ {id:'g2',name:'给内心一点生长的空间',start:'2026-09-01',end:'2026-10-31',ids:['b0','b2','b5'],sources:['心理学,向内走一程']},
+ {id:'g3',name:'读完的书,成为自己的底气',start:'2026-08-01',end:'2026-08-31',ids:['b4','b5'],sources:[]}
+];
+const personalNotes = [
+ {id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'},
+ {id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'},
+ {id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'},
+ {id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'}
+];
+const publicReviews = [
+ {id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'},
+ {id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'},
+ {id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'}
+];
diff --git a/static/js/weapi.js b/static/js/weapi.js
new file mode 100644
index 0000000..3f7a98d
--- /dev/null
+++ b/static/js/weapi.js
@@ -0,0 +1,125 @@
+/* ==========================================================================
+ 阅间 · 数据层(v2)
+ --------------------------------------------------------------------------
+ 把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。
+ 设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据,
+ 这里替换为真实接口调用,字段名与设计稿保持一致。
+ ========================================================================== */
+(function (global) {
+ 'use strict';
+
+ const BASE = '/api/v2';
+
+ async function request(path, params) {
+ 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' } });
+ let payload = null;
+ try {
+ payload = await response.json();
+ } catch (error) {
+ payload = null;
+ }
+ if (!response.ok) {
+ const message = (payload && (payload.error || payload.detail)) || `请求失败(${response.status})`;
+ const err = new Error(message);
+ err.status = response.status;
+ err.payload = payload;
+ throw err;
+ }
+ return payload;
+ }
+
+ /* ------------------------------ 工具函数 ------------------------------ */
+
+ function durationText(seconds) {
+ const total = Math.max(0, Math.round(Number(seconds) || 0));
+ const hours = Math.floor(total / 3600);
+ const minutes = Math.round((total % 3600) / 60);
+ return hours ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
+ }
+
+ function percentText(value) {
+ const num = Number(value) || 0;
+ return `${num}%`;
+ }
+
+ /* ------------------------------ 接口封装 ------------------------------ */
+
+ const WeApi = {
+ durationText,
+ percentText,
+
+ status() {
+ return request('/status');
+ },
+
+ /**
+ * 书架数据
+ * @param {string} tab all|reading|done|wish
+ * @param {{keyword?:string, limit?:number, offset?:number, progress?:boolean}} options
+ */
+ shelf(tab, options) {
+ const opts = options || {};
+ return request('/shelf', {
+ tab: tab || 'all',
+ keyword: opts.keyword || '',
+ limit: opts.limit,
+ offset: opts.offset,
+ progress: opts.progress ? 1 : 0,
+ });
+ },
+
+ dashboard(period) {
+ return request('/dashboard', { period: period || 'week' });
+ },
+
+ notebooks() {
+ return request('/notebooks');
+ },
+
+ /** bookId 传 'all' 时聚合笔记最多的前 limit 本 */
+ notes(bookId, limit) {
+ return request('/notes', { book_id: bookId, limit: limit });
+ },
+
+ book(bookId) {
+ return request('/book/' + encodeURIComponent(bookId));
+ },
+
+ reviews(bookId, type, count) {
+ return request(`/book/${encodeURIComponent(bookId)}/reviews`, { type: type || 0, count: count || 10 });
+ },
+
+ highlights(bookId, chapterUid) {
+ return request(`/book/${encodeURIComponent(bookId)}/highlights`, { chapter_uid: chapterUid || 0 });
+ },
+
+ underlineThoughts(bookId, chapterUid, range) {
+ return request(`/book/${encodeURIComponent(bookId)}/underline-thoughts`, {
+ chapter_uid: chapterUid,
+ range: range,
+ });
+ },
+
+ thought(reviewId) {
+ return request('/thought/' + encodeURIComponent(reviewId));
+ },
+
+ archives() {
+ return request('/archives');
+ },
+
+ recommend(maxIdx) {
+ return request('/recommend', { maxIdx: maxIdx || 0 });
+ },
+
+ search(keyword, scope) {
+ return request('/search', { keyword: keyword, scope: scope || 'books' });
+ },
+ };
+
+ global.WeApi = WeApi;
+})(window);
diff --git a/templates/index.html b/templates/index.html
index 803e317..b7e4462 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,211 +1,16 @@
-
-
+
+
-
-
- 我的读书数据
-
-
-
-
+
+阅间 · 微信读书看板
+
-
-
- ⚠️
- MySQL 数据库连接失败,数据可能无法正常加载。请检查 MySQL 服务是否已启动。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
阅读,是一场漫长的旅行
-
正在加载数据...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
🔍 推荐发现
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+正在连接微信读书…阅读目标保存在当前浏览器
+
+
+