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 ``; + 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) => ``).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}
${esc(s.hint)}
`).join(''); + + const topBooks = data.top_books.length + ? data.top_books.map((b, i) => `
${i + 1}. ${esc(b.title)}${esc(b.read_time_str)}
`).join('') + : '

该周期还没有阅读时长记录。

'; + + const cats = data.categories.items.length + ? data.categories.items.map(c => `
${esc(c.title)}相对偏好 ${c.val}
`).join('') + : '

接口未返回该周期的偏好分类。

'; + + const hours = data.time_distribution.values || []; + const hourMax = data.time_distribution.max || 1; + const hourBars = hours.length + ? hours.map(v => ``).join('') + : '接口未返回时段分布'; + const peak = data.time_distribution.peak_hour; + + const authorLines = data.authors.length + ? data.authors.map(a => `
${esc(a.name)}${a.count} 本${a.read_time ? ' · ' + esc(a.read_time) : ''}
`).join('') + : '

接口未返回该周期的偏好作者。

'; + const publisherLines = data.publishers.length + ? data.publishers.map(p => `
${esc(p.name)}${p.count} 本
`).join('') + : '

接口未返回偏好出版社。

'; + const cpLines = data.cps.length + ? data.cps.map(c => `
${esc(c.name)}${c.count} 本
`).join('') + : ''; + const medalLines = data.medals.length + ? data.medals.map(m => `
${esc(m.name)}${esc(m.detail || m.date || '')}
`).join('') + : '
勋章接口未返回
'; + + return welcome('在字里行间,遇见自己', '阅读、目标与灵感,在这里慢慢积累。', '') + + `
${tabs([['week', '本周'], ['month', '本月'], ['year', '本年'], ['overall', '总计']], period, 'period')}${esc(periodHint(period))} · 微信读书统计接口
` + + `
${stats}
` + + `
${renderTrend(data)}

继续阅读

${reading.length ? reading.map(bookCard).join('') : '
书架里还没有在读的书。
'}
` + + `

读得最多

${topBooks}${data.top_books.length ? `

按${esc(periodHint(period))}的阅读时长排序

` : ''}

偏爱的阅读方向

${cats}

相对偏好权重,不是阅读占比

留给自己的时段

${hourBars}
06:0018:00次日 05:00

${esc(data.time_distribution.word || '时段分布')}${peak != null ? ` · 峰值 ${String(peak).padStart(2, '0')}:00` : ''}

` + + `
更多阅读画像 · 作者、出版社、勋章与年度回顾

偏好作者与出版方

${authorLines}${publisherLines}${cpLines}

阅读与收听

微信读书统计接口未返回「文字阅读 / 收听」时长拆分,接入该字段后可直接展示。

阅读总时长${esc(data.stats[0].value)}
有效阅读天数${esc(String(data.stats[1].value).replace(/<[^>]+>/g, ''))}

阅读足迹

好友排行${esc(data.rank.text || '本周暂无排行')}
${medalLines}
`; +} + +/* ------------------------------- 我的书架 ------------------------------- */ + +function listCard(list, index) { + const members = (list.ids || []).map(findBook).filter(Boolean); + return ``; +} +function renderLists(lists) { + return `
${lists.map(listCard).join('') || '
没有匹配的书单。
'}
`; +} + +function renderShelf() { + if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf'); + if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); } + + const counts = shelfData.counts || {}; + const filtered = books.filter(book => shelfTab === 'all' || book.status === shelfTab); + let content; + if (shelfTab === 'lists') content = renderLists(booklists); + else if (shelfTab === 'audio') { + const albums = shelfData.albums || []; + content = albums.length + ? albums.map(album => `
♫

${esc(album.title)}

${esc(album.author || '有声专辑')} · ${album.count} 集

${esc(album.intro || '内容完结不代表已听完;当前不纳入按本阅读目标。')}

`).join('') + : '
书架接口当前没有返回有声书 / 专辑条目。
'; + } else if (shelfTab === 'articles') { + content = `
▤

${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}

这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。

书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。

`; + } else { + const shown = filtered.slice(0, shelfLimit); + content = `
${shown.map(bookCard).join('') || '
这里还没有对应的书。
'}
${filtered.length > shown.length ? `` : ''}
`; + } + + const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`; + return welcome('我的书架', sub) + + tabs([['all', `全部电子书 ${counts.ebooks || 0}`], ['reading', `在读 ${counts.reading || 0}`], ['done', `已读完 ${counts.done || 0}`], ['wish', `想读 ${counts.wish || 0}`], ['lists', `我的书单 ${counts.archives || 0}`], ['audio', `有声书 ${counts.albums || 0}`], ['articles', '文章收藏']], shelfTab, 'shelf-tab') + content; +} + +function openList(id) { + const list = booklists.find(l => l.id === id); + if (!list) return; + const members = (list.ids || []).map(findBook).filter(Boolean); + showDetail('书单详情', `
A COLLECTION OF GOOD BOOKS

${esc(list.name)}

${esc(list.description || '')}

${esc(list.source || '我的书单')} · ${list.book_count || members.length} 本

${members.map(book => bookRow(book)).join('') || '
书单中的书籍当前不在书架上。
'}
`); +} + +/* ------------------------------- 阅读目标 ------------------------------- */ + +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} 条。`, '') + + `
${body}
`; +} +function filteredNotes(notes) { + if (noteType === 'all') return notes; + if (noteType === 'highlight') return notes.filter(n => n.type === 'highlight'); + if (noteType === 'thought') return notes.filter(n => n.type === 'thought'); + return []; +} +function noteCard(note) { + return `
${note.type === 'thought' ? '我的想法' : '划线'}
${esc(note.text)}
《${esc(note.book_title || '')}》 · ${esc(note.date || '')}

${esc(note.chapter || '未记录章节')}

${note.id ? `` : ''}
`; +} +function exportNotes() { + const payload = notesData[noteBook] || { notes: [] }; + const all = filteredNotes(payload.notes || []); + const notes = all.slice(0, 300); + const scopeName = noteBook === 'all' ? '全部笔记本(聚合)' : (notebooksData.items.find(item => item.id === noteBook) || {}).title || noteBook; + const header = `# 阅间笔记 · ${scopeName}\n\n导出时间:${today()} 来源:微信读书个人账号\n书签仅提供数量,不导出内容。${all.length > notes.length ? `\n本次导出前 ${notes.length} 条(共 ${all.length} 条),可切换单本笔记本分批导出。` : ''}\n\n`; + const text = header + notes.map(n => `## ${n.book_title || ''}\n\n${n.type === 'thought' ? '想法' : '划线'} · ${n.date || ''}${n.chapter ? ' · ' + n.chapter : ''}\n\n${n.text}`).join('\n\n'); + const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' })); + showDetail('导出笔记', `

已生成${esc(scopeName)}的划线与想法,共 ${notes.length} 条。可下载 Markdown,或直接选中下方内容复制;不含书签内容。

下载 Markdown
`); + $('#detail').addEventListener('close', () => URL.revokeObjectURL(url), { once: true }); +} + +/* ------------------------------- 发现好书 ------------------------------- */ + +function renderDiscover() { + const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']]; + if (!shelfData) ensureShelf(); + const key = discoverKey(); + 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.title} -
-
${book.title}
-
- ${book.author} - ${tagsHtml ? `
${tagsHtml}
` : ""} -
-
-
⏱ ${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 ` -
-
- ${book.title} - ${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.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.title} -
-
${data.title}
-
${data.author || ''}${data.translator ? ' / 译: ' + data.translator : ''}
-
${tags.map(t => `${t}`).join('')}
- ${p.is_start_reading ? ` -
-
- 阅读进度: ${p.progress}% - ${p.record_reading_time_str ? ' · 累计 ' + p.record_reading_time_str : ''} - ${p.finish_time ? ' · 读完于 ' + p.finish_time : ''} -
-
-
-
-
` : '
尚未开始阅读
'} -
-
- ${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} -
-
${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.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 @@ - - + + - - - 我的读书数据 - - - - + +阅间 · 微信读书看板 + - - - - - - - - - - -
- - -
-
-
-
-

阅读,是一场漫长的旅行

-

正在加载数据...

-
-
-
-
-
-
-
-
-
- - -
-
-

📊 阅读数据可视化

-
-
-

年度阅读时长趋势

-
-
-
-

24小时阅读时段分布

-
-
-
-

偏好阅读分类

-
-
-
-

偏好作者 TOP 10

-
-
-
-
-
- - -
-
-

🏆 阅读时长排行

-
-
-
- - -
-
-

📚 我的书架

-
-
- - - -
-
-
-
-
- -
-
- - -
-
-

✍️ 笔记概览

-
-
-
-
- - -
-
-

🔍 推荐发现

-
- - -
-
-
- - -
-
-
- -
-
- -
- - - - - - - - - - - - - - - - + + +
L
正在连接微信读书…阅读目标保存在当前浏览器
+

书籍详情

+
MAKE ROOM FOR READING

新建阅读目标

想读些什么?

选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。

+ \ No newline at end of file