// ==UserScript==
// @name LinuxDo 增强阅读
// @namespace https://linux.do/
// @version 1.8.3
// @license MIT
// @description 在 LINUX DO 列表页点击标题即可弹窗预览整帖,楼中楼展示、点赞、回复、收藏、原图灯箱一应俱全,并按真实阅读节奏上报已读进度——无需离开列表页,也无需反复返回。
// @author Fashion
// @match https://linux.do/*
// @icon https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_180x180.png
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const BASE = location.origin;
const PAGE_SIZE = 20; // 每次请求帖子数(与 Discourse 默认一致),也是 loadUp/loadDown 每次追加的数量
const READ_THRESHOLD = 1500;
const FLUSH_INTERVAL = 5000;
let ME_USERNAME = null;
// --- 嵌套树 API 状态管理 ---
const idToPost = new Map(); // postId → post 数据(混合索引)
let nestedTreeRoots = []; // 顶层回复数组
let nestedHasMoreRoots = false; // 是否有更多顶层回复
let nestedRootsPage = 0; // 当前已加载的分页号
const MENU_PANEL_SEL = '.menu-panel, .user-menu, .quick-access-panel, .notifications';
const SEARCH_SEL = '.search-results, .fps-result, .search-menu, .search-menu-container, .search-result-topic';
/* ============ 1. 样式 ============ */
const style = document.createElement('style');
style.textContent = `
.ldp-overlay{position:fixed;inset:0;z-index:2147483000;display:flex;
align-items:center;justify-content:center;background:rgba(0,0,0,.55);}
.ldp-modal{position:relative;display:flex;flex-direction:column;
width:90%;max-width:1000px;height:90vh;
border-radius:12px;overflow:hidden;font-size:16px;
line-height:1.65;background:var(--secondary,#fff);color:var(--primary,#222);
box-shadow:0 16px 50px rgba(0,0,0,.4);}
.ldp-header{display:flex;align-items:flex-start;gap:10px;padding:16px 20px;
border-bottom:1px solid var(--primary-low,#e5e5e5);}
.ldp-title{margin:0;font-size:18px;font-weight:700;}
.ldp-meta{font-size:12px;opacity:.7;margin-top:4px;}
.ldp-head-btns{display:flex;gap:8px;align-items:center;}
.ldp-close{cursor:pointer;border:none;background:transparent;font-size:22px;
line-height:1;color:inherit;padding:0 4px;}
.ldp-body{flex:1;min-height:0;position:relative;
padding:8px 20px 20px;overflow-y:auto;overscroll-behavior:contain;}
/* 楼层滑动跳转条(仿 L 站原生) */
.ldp-slider{position:absolute;top:0;right:0;bottom:0;width:22px;z-index:8;
display:flex;flex-direction:column;align-items:center;}
.ldp-slider[hidden]{display:none;}
.ldp-slider-track{position:absolute;top:8px;bottom:8px;left:50%;
width:4px;transform:translateX(-50%);border-radius:2px;
background:var(--primary-low,#ddd);}
.ldp-slider-thumb{position:absolute;left:50%;width:auto;min-width:18px;
height:18px;padding:0 5px;border-radius:9px;background:var(--tertiary,#08c);
transform:translate(-50%,-50%);cursor:grab;pointer-events:auto;
box-shadow:0 1px 4px rgba(0,0,0,.3);transition:transform .1s;
border:2px solid var(--secondary,#fff);color:#fff;font-size:10px;font-weight:700;
line-height:18px;text-align:center;white-space:nowrap;user-select:none;}
.ldp-slider-thumb:active{cursor:grabbing;transform:translate(-50%,-50%) scale(1.1);}
.ldp-slider-tip{position:absolute;right:28px;background:var(--primary,#222);
color:var(--secondary,#fff);font-size:12px;font-weight:600;padding:3px 8px;
border-radius:4px;white-space:nowrap;transform:translateY(-50%);opacity:0;
transition:opacity .15s;pointer-events:none;}
.ldp-slider-tip.show{opacity:1;}
.ldp-slider-tip::after{content:"";position:absolute;right:-5px;top:50%;
transform:translateY(-50%);border:4px solid transparent;
border-left-color:var(--primary,#222);}
/* 底部悬浮操作栏 */
.ldp-footer{flex:none;display:flex;align-items:center;justify-content:space-around;
padding:12px 24px;border-top:1px solid var(--primary-low,#eee);
background:var(--secondary,#fff);}
.ldp-fbtn{background:transparent;border:none;cursor:pointer;display:flex;
align-items:center;gap:8px;font-size:.95rem;color:var(--primary-medium,#666);
padding:8px 16px;border-radius:6px;transition:all .2s ease;font-weight:600;
white-space:nowrap;text-decoration:none;}
.ldp-fbtn:hover{background:var(--primary-low,#f0f0f0);color:var(--tertiary,#3b82f6);}
.ldp-fbtn svg{width:18px;height:18px;fill:currentColor;flex:none;}
.ldp-fbtn:disabled{cursor:default;opacity:.5;pointer-events:none;}
.ldp-fbtn.loading{opacity:.6;pointer-events:none;}
.ldp-fbtn.liked{color:#e74c3c;}
.ldp-fbtn.liked svg{fill:#e74c3c;}
.ldp-fbtn.bookmarked{color:var(--tertiary,#3b82f6);}
.ldp-fbtn.bookmarked svg{fill:var(--tertiary,#3b82f6);}
/* 楼主帖自身的点赞/回复按钮已挪到底部操作栏,这里隐藏原位置 */
.ldp-topic > .ldp-post > .ldp-actions{display:none;}
/* 骨架屏 */
.ldp-loadmask{position:absolute;inset:0;z-index:5;
padding:8px 20px 20px;overflow:hidden;
background:var(--secondary,#fff);color:inherit;}
.ldp-loadmask.hide{opacity:0;pointer-events:none;transition:opacity .25s ease;}
.ldp-sk{position:relative;overflow:hidden;border-radius:6px;
background:var(--primary-low,#e9e9e9);}
.ldp-sk::after{content:"";position:absolute;inset:0;
transform:translateX(-100%);
background:linear-gradient(90deg,transparent,rgba(255,255,255,.55),transparent);
animation:ldp-shimmer 1.2s infinite;}
@keyframes ldp-shimmer{100%{transform:translateX(100%);}}
.ldp-sk-title{height:18px;width:55%;border-radius:6px;
display:inline-block;vertical-align:middle;}
.ldp-sk-meta{height:11px;width:35%;border-radius:5px;
display:inline-block;}
.ldp-sk-head{display:flex;align-items:center;gap:10px;margin:12px 0 10px;}
.ldp-sk-avatar{width:32px;height:32px;border-radius:50%;flex:none;}
.ldp-sk-line{height:12px;}
.ldp-sk-w30{width:30%;}.ldp-sk-w40{width:40%;}.ldp-sk-w60{width:60%;}
.ldp-sk-w80{width:80%;}.ldp-sk-w90{width:90%;}.ldp-sk-w100{width:100%;}
.ldp-sk-para .ldp-sk-line{margin-bottom:8px;}
.ldp-sk-divider{height:1px;background:var(--primary-low,#e0e0e0);margin:16px 0 12px;}
.ldp-sk-comment{display:flex;gap:10px;margin-bottom:18px;}
.ldp-sk-comment .ldp-sk-avatar{width:28px;height:28px;}
.ldp-sk-cbody{flex:1;}
/* 楼主帖区块 */
.ldp-topic{padding:4px 0 14px;}
.ldp-topic .ldp-post{border-bottom:none;}
/* 评论区分隔 + 左上角"评论"标题 */
.ldp-comments-header{display:flex;align-items:center;gap:8px;
margin:6px 0 2px;padding-top:14px;border-top:2px solid var(--primary-low,#e0e0e0);
font-size:16px;font-weight:700;letter-spacing:.5px;}
.ldp-comments-header::before{content:"💬";font-size:14px;}
.ldp-comments-count{font-size:12px;font-weight:500;opacity:.6;}
.ldp-comments{padding-top:4px;}
.ldp-comments-empty{padding:18px 0;text-align:center;opacity:.5;font-size:13px;}
.ldp-post{padding:12px 0 12px 12px;border-bottom:1px solid var(--primary-low,#eee);}
.ldp-post.ldp-flash{animation:ldp-flash-bg 1.6s ease;}
@keyframes ldp-flash-bg{
0%{background:rgba(8,132,255,.16);}
100%{background:transparent;}
}
.ldp-post-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;}
.ldp-avatar{width:28px;height:28px;border-radius:50%;}
.ldp-user-link{display:inline-flex;align-items:center;gap:8px;color:inherit;
text-decoration:none;min-width:0;}
.ldp-user-link:hover{color:var(--tertiary,#08c);}
.ldp-user-link .ldp-avatar{transition:opacity .15s,transform .15s;}
.ldp-user-link:hover .ldp-avatar{opacity:.82;transform:scale(1.05);}
.ldp-boost-avatar-link{display:inline-flex;align-items:center;flex:none;
border-radius:50%;line-height:0;}
.ldp-boost-avatar-link:hover .ldp-b-avatar{opacity:.82;transform:scale(1.08);}
.ldp-b-avatar{transition:opacity .15s,transform .15s;}
.ldp-author{font-weight:600;}
.ldp-op{font-size:11px;font-weight:700;color:#fff;background:var(--tertiary,#08c);
border-radius:4px;padding:1px 6px;letter-spacing:.5px;}
.ldp-me{font-size:11px;font-weight:700;color:#fff;background:#3ea66b;
border-radius:4px;padding:1px 6px;letter-spacing:.5px;}
.ldp-user{font-size:12px;opacity:.6;}
.ldp-level{font-size:11px;opacity:.5;}
.ldp-time{font-size:12px;opacity:.55;}
.ldp-floor{font-size:12px;opacity:.5;margin-left:auto;
padding-left:8px;white-space:nowrap;}
.ldp-content img{max-width:100%;height:auto;cursor:zoom-in;border-radius:4px;}
.ldp-content .ldp-deleted{color:var(--primary-medium,#888);}
.ldp-content .video-placeholder-container{max-width:100%;overflow:hidden;border-radius:6px;}
.ldp-content .video-placeholder-container[data-video-src]:not(.ldp-video-ready){
position:relative;background:#000;cursor:pointer;}
.ldp-content .video-placeholder-container[data-video-src]:not(.ldp-video-ready)::before{
content:"";position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;
transform:translate(-50%,-50%);border:2px solid rgba(255,255,255,.92);
border-radius:50%;background:rgba(0,0,0,.58);pointer-events:none;
box-shadow:0 4px 18px rgba(0,0,0,.35);transition:background .18s,transform .18s;}
.ldp-content .video-placeholder-container[data-video-src]:not(.ldp-video-ready)::after{
content:"";position:absolute;z-index:4;left:50%;top:50%;
transform:translate(-35%,-50%);border-top:11px solid transparent;
border-bottom:11px solid transparent;border-left:18px solid #fff;
pointer-events:none;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
.ldp-content .video-placeholder-container[data-video-src]:not(.ldp-video-ready):hover::before{
background:rgba(8,132,255,.82);transform:translate(-50%,-50%) scale(1.06);}
.ldp-content .video-placeholder-container[data-video-src]:not(.ldp-video-ready)
.video-placeholder-overlay{display:none;}
.ldp-content .video-placeholder-container.ldp-video-ready{cursor:default!important;background:#000;}
.ldp-content video{display:block;width:100%;max-width:100%;max-height:70vh;background:#000;}
.ldp-content .ldp-spoiler{cursor:pointer;}
.ldp-content .ldp-spoiler.ldp-spoiler-blurred{
filter:blur(8px)!important;-webkit-filter:blur(8px)!important;
user-select:none;}
.ldp-content .ldp-spoiler.ldp-spoiler-expanded{cursor:default;}
.ldp-content pre{overflow:auto;background:var(--primary-very-low,#f6f6f6);
padding:10px;border-radius:6px;}
.ldp-children{margin-left:22px;
border-left:1px solid var(--tertiary,#08c);}
.ldp-actions{display:flex;gap:14px;margin-top:8px;font-size:12px;align-items:center;}
.ldp-btn{cursor:pointer;border:none;background:transparent;color:inherit;
opacity:.7;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;}
.ldp-btn:hover{opacity:1;}
.ldp-btn:disabled{cursor:default;opacity:.4;}
.ldp-like.liked{color:var(--love,#e25822);opacity:1;font-weight:600;}
.ldp-replybox{margin-top:8px;display:none;position:relative;}
.ldp-replybox.open{display:block;}
.ldp-replybox textarea{width:100%;min-height:90px;box-sizing:border-box;margin-bottom:0px;
border:1px solid var(--primary-low,#ccc);border-radius:6px;padding:8px;
font:inherit;background:var(--secondary,#fff);color:inherit;resize:vertical;}
.ldp-replybox textarea.uploading{opacity:0.6;pointer-events:none;}
.ldp-reply-tools{display:flex;align-items:center;gap:6px;margin-top:6px;}
.ldp-insert-wrap{position:relative;}
.ldp-insert-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;
min-width:150px;padding:4px 0;border:1px solid var(--primary-low,#ddd);
border-radius:6px;background:var(--secondary,#fff);
box-shadow:0 6px 24px rgba(0,0,0,.18);list-style:none;margin:0;}
.ldp-insert-menu[hidden]{display:none;}
.ldp-insert-item{display:flex;align-items:center;gap:8px;width:100%;
padding:7px 10px;border:none;background:transparent;color:inherit;
font-size:13px;text-align:left;cursor:pointer;white-space:nowrap;}
.ldp-insert-item:hover{background:var(--primary-low,#f0f0f0);}
.ldp-insert-item svg{width:14px;height:14px;flex:none;fill:currentColor;}
.ldp-send{margin-top:6px;background:var(--tertiary,#08c);color:#fff;border:none;
border-radius:6px;padding:6px 14px;cursor:pointer;}
.ldp-reply-tools .ldp-send{margin-top:0;}
.ldp-reply-tip{margin-left:10px;font-size:12px;color:#3ea66b;opacity:0;
transition:opacity .25s ease;}
.ldp-reply-tip.show{opacity:1;}
.ldp-loading-tip{padding:14px 0;text-align:center;font-size:13px;
color:var(--primary-medium,#888);display:none;user-select:none;}
.ldp-loading-tip.show{display:block;}
.ldp-loading-tip .ldp-tip-icon{display:inline-block;margin-right:6px;
animation:ldp-spin .9s linear infinite;}
@keyframes ldp-spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}
/* 上方/下方懒加载触发提示 */
.ldp-load-up-tip,.ldp-load-down-tip{
padding:10px 0;text-align:center;font-size:12px;
color:var(--primary-medium,#999);user-select:none;display:none;}
.ldp-load-up-tip.show,.ldp-load-down-tip.show{display:block;}
.ldp-load-up-tip .ldp-tip-icon,.ldp-load-down-tip .ldp-tip-icon{
display:inline-block;margin-right:4px;animation:ldp-spin .9s linear infinite;}
.ldp-bottom-tip{padding:16px 0;text-align:center;font-size:13px;
color:var(--primary-medium,#888);user-select:none;}
.ldp-top-tip{padding:10px 0;text-align:center;font-size:13px;
color:var(--primary-medium,#888);user-select:none;}
/* 灯箱 */
.ldp-lightbox{position:fixed;inset:0;z-index:2147483600;display:flex;
flex-direction:column;background:rgba(0,0,0,.9);}
.ldp-lb-stage{flex:1;overflow:auto;display:flex;align-items:center;
justify-content:center;padding:20px;}
.ldp-lb-stage img{display:block;max-width:94vw;max-height:88vh;
width:auto;height:auto;border-radius:4px;cursor:zoom-out;
box-shadow:0 10px 40px rgba(0,0,0,.6);}
.ldp-lb-x{position:fixed;top:14px;right:18px;z-index:1;cursor:pointer;border:none;
background:transparent;color:#fff;font-size:30px;line-height:1;}
/* 楼中楼"展示更多回复"按钮 */
.ldp-sub-actions{margin-left:22px;padding-left:14px;margin-top:2px;display:none;}
.ldp-load-more-replies{font-size:12px;color:var(--tertiary,#08c);font-weight:600;
opacity:.9;padding:4px 0;}
.ldp-load-more-replies:hover{opacity:1;text-decoration:underline;}
.ldp-sub-loading{font-size:12px;opacity:.5;margin-left:22px;padding-left:14px;
margin-top:2px;display:none;}
/* ============ Boost 样式 ============ */
.ldp-boosts-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin-top:6px;min-height:0;}
.ldp-boost-bubble{display:inline-flex;align-items:center;gap:4px;
padding:3px 8px 3px 4px;border:none;
background:rgba(128,128,128,.1);border-radius:50px;
font-size:14px;line-height:1.4;cursor:default;position:relative;
transition:background .15s;}
.ldp-boost-bubble:hover{background:rgba(128,128,128,.18);}
.ldp-b-avatar{width:18px;height:18px;border-radius:50%;flex:none;display:block;}
.ldp-boost-bubble p{margin:0;display:inline-flex;gap:2px;align-items:center;flex-wrap:wrap;}
.ldp-boost-bubble p img.emoji{width:14px;height:14px;margin:0;vertical-align:middle;}
.ldp-boost-del{cursor:pointer;margin-left:2px;opacity:0;font-size:13px;
color:var(--danger,#cc4b4b);line-height:1;border:none;background:transparent;
padding:0 2px;transition:opacity .15s;flex:none;}
.ldp-boost-bubble:hover .ldp-boost-del{opacity:.65;}
.ldp-boost-del:hover{opacity:1!important;}
.ldp-boost-input-wrap{display:none;align-items:center;gap:5px;margin-top:6px;
padding:4px 6px;border-radius:8px;
border:1px solid var(--primary-low,#ddd);
background:var(--secondary,#fff);}
.ldp-boost-input-wrap.open{display:flex;}
.ldp-boost-input{flex:1;border:none;background:transparent;outline:none;
font-size:13px;padding:2px 4px;color:inherit;min-width:0;}
.ldp-boost-input::placeholder{color:var(--primary-medium,#999);font-size:12px;}
.ldp-boost-submit{width:22px;height:22px;padding:0;display:flex;flex:none;
align-items:center;justify-content:center;border-radius:50%;
border:1px solid #3ea66b;background:transparent;color:#3ea66b;
cursor:pointer;font-size:14px;line-height:1;transition:all .15s;}
.ldp-boost-submit:hover{background:#3ea66b;color:#fff;}
.ldp-boost-submit:disabled{opacity:.5;cursor:default;pointer-events:none;}
.ldp-boost-cancel{width:22px;height:22px;padding:0;display:flex;flex:none;
align-items:center;justify-content:center;border-radius:50%;
border:1px solid var(--danger,#cc4b4b);background:transparent;
color:var(--danger,#cc4b4b);cursor:pointer;font-size:16px;line-height:1;
transition:all .15s;}
.ldp-boost-cancel:hover{background:var(--danger,#cc4b4b);color:#fff;}
.ldp-btn.ldp-boost-btn{font-size:12px;}
.ldp-btn.ldp-boost-btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
/* ============ Emoji Picker 样式 ============ */
.ldp-emoji-btn{width:22px;height:22px;padding:0;display:flex;flex:none;
align-items:center;justify-content:center;border-radius:50%;
border:1px solid var(--primary-low,#ccc);background:transparent;
color:var(--primary-medium,#666);cursor:pointer;font-size:14px;line-height:1;
transition:all .15s;position:relative;}
.ldp-emoji-btn:hover{background:var(--primary-low,#f0f0f0);color:var(--tertiary,#3b82f6);}
.ldp-emoji-btn svg{width:14px;height:14px;fill:currentColor;display:block;}
.ldp-emoji-panel{position:fixed;z-index:2147483500;width:320px;height:360px;
background:var(--secondary,#fff);border:1px solid var(--primary-low,#ddd);
border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.18);
display:flex;flex-direction:column;overflow:hidden;font-size:13px;}
.ldp-emoji-search{flex:none;padding:6px 8px;border-bottom:1px solid var(--primary-low,#eee);}
.ldp-emoji-search input{width:100%;box-sizing:border-box;border:1px solid var(--primary-low,#ccc);
border-radius:6px;padding:5px 8px;font-size:12px;outline:none;
background:var(--secondary,#fff);color:inherit;}
.ldp-emoji-tabs{flex:none;display:flex;border-bottom:1px solid var(--primary-low,#eee);
overflow-x:auto;padding:0 4px;}
.ldp-emoji-tabs::-webkit-scrollbar{display:none;}
.ldp-emoji-tab{flex:none;width:30px;height:30px;padding:0;cursor:pointer;
display:flex;align-items:center;justify-content:center;font-size:16px;
opacity:.5;border-bottom:2px solid transparent;transition:all .15s;
background:none;border-top:none;border-left:none;border-right:none;}
.ldp-emoji-tab img{width:20px;height:20px;display:block;}
.ldp-emoji-tab:hover{opacity:.8;}
.ldp-emoji-tab.active{opacity:1;border-bottom-color:var(--tertiary,#08c);}
.ldp-emoji-grid{flex:1;overflow-y:auto;padding:6px;display:grid;
grid-template-columns:repeat(8,1fr);gap:2px;align-content:start;}
.ldp-emoji-grid::-webkit-scrollbar{width:5px;}
.ldp-emoji-grid::-webkit-scrollbar-thumb{background:rgba(128,128,128,.3);border-radius:3px;}
.ldp-emoji-item{width:100%;aspect-ratio:1;display:flex;align-items:center;
justify-content:center;border-radius:6px;cursor:pointer;border:none;
background:transparent;padding:2px;transition:background .1s;}
.ldp-emoji-item:hover{background:var(--primary-low,#f0f0f0);}
.ldp-emoji-item img{width:24px;height:24px;display:block;}
.ldp-emoji-empty{grid-column:1/-1;text-align:center;padding:20px 0;
color:var(--primary-medium,#999);font-size:12px;}
`;
document.head.appendChild(style);
/* 图标 */
const ICONS = {
like: '
(帖子已被作者删除)
' : p.cooked || ''; cooked = (() => { const tmp = document.createElement('div'); tmp.innerHTML = cooked; tmp.querySelectorAll('a[href]').forEach((a) => { const href = a.getAttribute('href') || ''; const isImageLink = /\.(png|jpe?g|gif|webp|bmp|avif)(\?|#|$)/i.test(href); const isLightbox = a.classList.contains('lightbox'); if (!isImageLink && !isLightbox) { if (!a.getAttribute('target')) a.setAttribute('target', '_blank'); } }); return tmp.innerHTML; })(); const boostsHtml = isDeleted ? '' : renderBoosts(p.boosts || []); const canBoost = !isDeleted && p.can_boost === true; const canDelete = !isDeleted && isME && p.post_number !== 1; const actionsHtml = isDeleted ? '' : `(帖子已被作者删除)
'; postNode.style.opacity = ''; postNode.querySelectorAll( ':scope > .ldp-boosts-list, :scope > .ldp-boost-input-wrap, :scope > .ldp-actions' ).forEach((el) => el.remove()); } /* ============ 7.1 公式渲染(KaTeX) ============ */ /** * Discourse 的 cooked 中公式只是 /${res.cooked || ''}
` + ``; listEl.appendChild(newBubble); } input.value = ''; wrap.classList.remove('open'); const btn = postNode.querySelector(':scope > .ldp-actions > .ldp-boost-btn'); if (btn) btn.disabled = true; if (postNumber === 1) { const fBoost = ctx.scrollRoot.closest('.ldp-modal').querySelector('.ldp-f-boost'); if (fBoost) { fBoost.disabled = true; fBoost.style.opacity = '0.4'; } } } } catch (err) { alert('发射失败:' + err.message); } finally { boostSubmit.disabled = false; } return; } const boostDel = e.target.closest('.ldp-boost-del'); if (boostDel) { const bubble = boostDel.closest('.ldp-boost-bubble'); const boostId = bubble && bubble.dataset.boostId; if (!boostId) return; try { await apiSend(`${BASE}/discourse-boosts/boosts/${boostId}`, 'DELETE'); bubble.remove(); const btn = postNode.querySelector(':scope > .ldp-actions > .ldp-boost-btn'); if (btn) btn.disabled = false; if (postNumber === 1) { const fBoost = ctx.scrollRoot.closest('.ldp-modal').querySelector('.ldp-f-boost'); if (fBoost) { fBoost.disabled = false; fBoost.style.opacity = '1'; } } } catch (err) { alert('删除失败:' + err.message); } return; } const sendBtn = e.target.closest('.ldp-send'); if (sendBtn) { const box = sendBtn.closest('.ldp-replybox'), textarea = box.querySelector('textarea'), raw = textarea.value.trim(); if (!raw) return; if (raw.length < 16) { alert('帖子必须至少为16个字符'); return; } sendBtn.disabled = true; sendBtn.textContent = '发送中…'; try { const data = await apiSend(`${BASE}/posts`, 'POST', { raw, topic_id: ctx.topicId, reply_to_post_number: postNumber, nested_post: true, }); const postData = data && data.post ? data.post : data; if (postData && postData.cooked) { const isTopLevel = postNumber === 1; const newNode = renderPost({ id: postData.id, post_number: postData.post_number, username: postData.username || ME_USERNAME, name: postData.name, avatar_template: postData.avatar_template, cooked: postData.cooked, created_at: postData.created_at || new Date().toISOString(), reply_to_post_number: postNumber, actions_summary: [], boosts: [], can_boost: true, }, !isTopLevel, ctx, isTopLevel ? 0 : 1); newNode.classList.add('ldp-flash'); if (isTopLevel) { ctx.commentsEl.prepend(newNode); newNode.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { const childrenContainer = postNode.querySelector(':scope > .ldp-children'); if (childrenContainer) { childrenContainer.prepend(newNode); } else { console.warn('Parent post has no .ldp-children container'); } } ctx.nodeMap.set(postData.post_number, newNode); ctx.tracker.observe(newNode); ctx.totalComments = (ctx.totalComments || 0) + 1; updateCommentsHeader(ctx); const tip = box.querySelector('.ldp-reply-tip'); if (tip) { tip.classList.add('show'); setTimeout(() => tip.classList.remove('show'), 1500); } box.classList.remove('open'); textarea.value = ''; } } catch (err) { alert('回复失败:' + err.message); } finally { sendBtn.disabled = false; sendBtn.textContent = '发送'; } return; } }); modal.addEventListener('keydown', (e) => { const spoiler = e.target.closest('.ldp-spoiler'); if (!spoiler || (e.key !== 'Enter' && e.key !== ' ')) return; e.preventDefault(); spoiler.click(); }); } /* ============ 12. 收藏 ============ */ function bindBookmark(btn, topic) { let bookmarked = !!topic.bookmarked, bookmarkId = topic.bookmark_id || null; const sync = () => { btn.classList.toggle('bookmarked', bookmarked); }; sync(); btn.addEventListener('click', async () => { btn.disabled = true; try { if (!bookmarked) { const data = await apiSend(`${BASE}/bookmarks`, 'POST', { bookmarkable_id: topic.id, bookmarkable_type: 'Topic' }); bookmarkId = data && data.id ? data.id : bookmarkId; bookmarked = true; } else if (bookmarkId) { await apiSend(`${BASE}/bookmarks/${bookmarkId}`, 'DELETE'); bookmarked = false; bookmarkId = null; } else { await apiSend(`${BASE}/t/${topic.id}/remove_bookmarks`, 'PUT'); bookmarked = false; } sync(); } catch (err) { alert('收藏操作失败:' + err.message); } finally { btn.disabled = false; } }); } function bindFooterLike(btn, countEl, opPost) { if (!opPost) { btn.disabled = true; return; } const { count, acted, canAct } = likeInfo(opPost); let liked = acted; countEl.textContent = count; btn.classList.toggle('liked', liked); btn.disabled = !(canAct || acted); btn.addEventListener('click', async () => { if (btn.disabled) return; btn.disabled = true; try { if (!liked) { await apiSend(`${BASE}/post_actions`, 'POST', { id: opPost.id, post_action_type_id: 2, flag_topic: false }); liked = true; btn.classList.add('liked'); countEl.textContent = (+countEl.textContent) + 1; } else { await apiSend(`${BASE}/post_actions/${opPost.id}?post_action_type_id=2`, 'DELETE'); liked = false; btn.classList.remove('liked'); countEl.textContent = Math.max(0, (+countEl.textContent) - 1); } } catch (err) { alert('操作失败:' + err.message); } finally { btn.disabled = false; } }); } function updateCommentsHeader(ctx) { if (ctx.countEl) ctx.countEl.textContent = ctx.totalComments ? `(${ctx.totalComments})` : ''; if (ctx.emptyEl) ctx.emptyEl.style.display = ctx.totalComments ? 'none' : ''; if (ctx.footerReplyCountEl) ctx.footerReplyCountEl.textContent = ctx.totalComments || 0; } /* ============ 13. 定位:递归展开楼中楼并高亮目标 ============ */ function waitForScrollEnd(el, timeoutMs = 1200) { const IDLE_MS = 120; return new Promise((resolve) => { let done = false; let idleTimer = null; const finish = () => { if (done) return; done = true; el.removeEventListener('scroll', onScroll); clearTimeout(idleTimer); clearTimeout(safety); resolve(); }; const onScroll = () => { clearTimeout(idleTimer); idleTimer = setTimeout(finish, IDLE_MS); }; el.addEventListener('scroll', onScroll, { passive: true }); idleTimer = setTimeout(finish, IDLE_MS); const safety = setTimeout(finish, timeoutMs); }); } function resolveTargetPostNumber(targetPostNumber, posts) { const target = Number(targetPostNumber); if (!Number.isFinite(target) || target <= 1) return targetPostNumber; const availableNumbers = (posts || []) .map((post) => Number(post?.post_number)) .filter((postNumber) => Number.isFinite(postNumber) && postNumber >= 1) .sort((a, b) => a - b); if (!availableNumbers.length || availableNumbers.includes(target)) return target; // Discourse 删除或隐藏回复后楼号可能不连续。优先定位到下一个 // 实际可见的楼层;如果已经到末尾,则回退到最近的前一楼。 return availableNumbers.find((postNumber) => postNumber > target) ?? availableNumbers[availableNumbers.length - 1]; } async function fetchPostForLocate(topicId, postNumber, ctx) { const cached = ctx.postMap?.get(postNumber); if (cached) return cached; const data = await fetchJSON(`${BASE}/t/${topicId}.json?post_number=${postNumber}`); const posts = data.post_stream?.posts || []; posts.forEach((post) => { if (ctx.postMap) ctx.postMap.set(post.post_number, post); idToPost.set(post.id, post); }); return posts.find((post) => post.post_number === postNumber) || null; } function ensurePostPlacement(post, ctx) { if (!post || post.post_number === 1) return ctx.nodeMap.get(1) || null; if (ctx.postMap) ctx.postMap.set(post.post_number, post); idToPost.set(post.id, post); let node = ctx.nodeMap.get(post.post_number); if (!node) { attachPost(post, ctx); reflowPending(ctx); node = ctx.nodeMap.get(post.post_number); } if (!node) return null; const parentNumber = +post.reply_to_post_number || 1; if (parentNumber > 1) { const parentNode = ctx.nodeMap.get(parentNumber); const childrenEl = parentNode?.querySelector(':scope > .ldp-children'); if (childrenEl && node.parentElement !== childrenEl) childrenEl.appendChild(node); } else if (node.parentElement !== ctx.commentsEl) { ctx.commentsEl.appendChild(node); } return node; } async function expandTargetAncestorChain(targetPostNumber, ctx) { const targetPost = await fetchPostForLocate(ctx.topicId, targetPostNumber, ctx); if (!targetPost) throw new Error(`未找到目标楼层 #${targetPostNumber}`); const chain = [targetPost]; const seen = new Set([targetPostNumber]); let current = targetPost; while (+current.reply_to_post_number > 1) { const parentNumber = +current.reply_to_post_number; if (seen.has(parentNumber)) throw new Error('楼中楼祖先链存在循环引用'); seen.add(parentNumber); const parent = await fetchPostForLocate(ctx.topicId, parentNumber, ctx); if (!parent) throw new Error(`未找到父楼层 #${parentNumber}`); chain.unshift(parent); current = parent; } // 从最外层父回复向目标逐层展开,确保多层楼中楼的每一级 DOM 都已就位。 for (let i = 0; i < chain.length; i++) { const post = ctx.postMap?.get(chain[i].post_number) || chain[i]; ensurePostPlacement(post, ctx); const childOnPath = chain[i + 1]; if (!childOnPath) continue; await loadAllChildren(post.id, post.post_number, null, ctx, true); const refreshedChild = ctx.postMap?.get(childOnPath.post_number) || childOnPath; ensurePostPlacement(refreshedChild, ctx); reflowPending(ctx); } return ctx.nodeMap.get(targetPostNumber) || null; } async function locatePost(targetPostNumber, ctx) { if (!targetPostNumber || targetPostNumber <= 1) return false; await new Promise((resolve) => requestAnimationFrame(resolve)); let targetNode = ctx.nodeMap.get(targetPostNumber); let locateError = null; try { targetNode = await expandTargetAncestorChain(targetPostNumber, ctx) || targetNode; } catch (error) { // 嵌套接口不可用时保留 1.3.0 原有的扁平定位作为降级路径。 locateError = error; targetNode = ctx.nodeMap.get(targetPostNumber) || (targetNode?.isConnected ? targetNode : null); } if (!targetNode || targetNode.isConnected === false) { const reason = locateError?.message ? `:${locateError.message}` : ''; console.warn(`[LinuxDo 增强阅读] 目标楼层 #${targetPostNumber} 不可见,无法定位${reason}`); return false; } await new Promise((resolve) => requestAnimationFrame(resolve)); targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' }); await waitForScrollEnd(ctx.scrollRoot); const flashObserver = new IntersectionObserver((entries, observer) => { const visibleEntry = entries.find((entry) => entry.isIntersecting && entry.intersectionRatio >= 0.5 ); if (!visibleEntry) return; observer.disconnect(); const node = visibleEntry.target; node.classList.remove('ldp-flash'); void node.offsetWidth; node.classList.add('ldp-flash'); setTimeout(() => node.classList.remove('ldp-flash'), 1700); }, { root: ctx.scrollRoot, threshold: [0.5] }); flashObserver.observe(targetNode); setTimeout(() => flashObserver.disconnect(), 1500); return true; } function createFloorNavigator(nav, scrollRoot, topicId) { const thumb = nav.querySelector('.ldp-slider-thumb'); const tip = nav.querySelector('.ldp-slider-tip'); const track = nav.querySelector('.ldp-slider-track'); const modal = nav.parentElement; let currentFloor = 1; let totalFloors = 1; let dragging = false; let scrollRaf = 0; let suppressClick = false; let resizeHandler = null; let mutationObserver = null; let mutationTimer = null; let geometryTimer = null; const updateGeometry = () => { const bodyRect = scrollRoot.getBoundingClientRect(); const modalRect = modal.getBoundingClientRect(); nav.style.top = `${bodyRect.top - modalRect.top}px`; nav.style.bottom = `${modalRect.bottom - bodyRect.bottom}px`; }; const getRenderedFloors = () => Array.from( scrollRoot.querySelectorAll('.ldp-post[data-post-number]') ).map((node) => Number(node.dataset.postNumber)) .filter((floor) => Number.isFinite(floor) && floor >= 1) .filter((floor, index, floors) => floors.indexOf(floor) === index) .sort((a, b) => a - b); const currentFloorAt = () => { if (scrollRoot.scrollTop <= 2) return 1; const rootRect = scrollRoot.getBoundingClientRect(); const probeY = rootRect.top + Math.min(rootRect.height * 0.35, 260); const heads = Array.from(scrollRoot.querySelectorAll('.ldp-post-head')) .map((head) => ({ top: head.getBoundingClientRect().top, floor: Number(head.closest('.ldp-post')?.dataset.postNumber), })) .filter((item) => Number.isFinite(item.floor)) .sort((a, b) => a.top - b.top); if (!heads.length) return 1; let floor = heads[0].floor; heads.forEach((item) => { if (item.top <= probeY) floor = item.floor; }); return floor; }; const floorFromRatio = (ratio) => Math.max( 1, Math.min(totalFloors, Math.round(1 + ratio * (totalFloors - 1))) ); const paint = (floor, showTip = false) => { currentFloor = Math.max(1, Math.min(totalFloors, Number(floor) || 1)); const ratio = totalFloors > 1 ? (currentFloor - 1) / (totalFloors - 1) : 0; const trackHeight = track.offsetHeight || nav.offsetHeight; const y = 8 + Math.max(0, Math.min(1, ratio)) * trackHeight; thumb.style.top = `${y}px`; thumb.textContent = String(currentFloor); tip.textContent = `#${currentFloor}`; tip.style.top = `${y}px`; if (showTip) tip.classList.add('show'); }; const updateThumbPosition = () => { scrollRaf = 0; if (dragging || nav.hidden) return; const renderedFloors = getRenderedFloors(); if (!renderedFloors.length) return; paint(currentFloorAt()); }; const onScroll = () => { if (!scrollRaf) scrollRaf = requestAnimationFrame(updateThumbPosition); }; const navigateToFloor = (floor) => { const targetFloor = floorFromRatio(Math.max(0, Math.min(1, (floor - 1) / Math.max(1, totalFloors - 1)))); if (targetFloor === currentFloor) { paint(currentFloor); return; } // 复用现有目标楼层逻辑:窗口加载、楼号修正、祖先链展开和高亮均由 openModal 完成。 openModal(topicId, targetFloor); }; let lastTargetFloor = 1; const onPointerMove = (event) => { if (!dragging) return; event.preventDefault(); const rect = track.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); lastTargetFloor = floorFromRatio(ratio); const y = 8 + ratio * rect.height; thumb.style.top = `${y}px`; thumb.textContent = String(lastTargetFloor); tip.textContent = `#${lastTargetFloor}`; tip.style.top = `${y}px`; tip.classList.add('show'); }; const onPointerUp = () => { if (!dragging) return; dragging = false; suppressClick = true; thumb.style.cursor = 'grab'; tip.classList.remove('show'); document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); navigateToFloor(lastTargetFloor); }; thumb.addEventListener('pointerdown', (event) => { event.preventDefault(); event.stopPropagation(); dragging = true; lastTargetFloor = currentFloor; thumb.style.cursor = 'grabbing'; tip.classList.add('show'); document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); }); nav.addEventListener('click', (event) => { if (suppressClick) { suppressClick = false; return; } if (event.target === thumb) return; const rect = track.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); navigateToFloor(floorFromRatio(ratio)); }); scrollRoot.addEventListener('scroll', onScroll, { passive: true }); mutationObserver = new MutationObserver(() => { clearTimeout(mutationTimer); mutationTimer = setTimeout(() => { updateGeometry(); updateThumbPosition(); }, 100); }); mutationObserver.observe(scrollRoot, { childList: true, subtree: true }); resizeHandler = updateGeometry; window.addEventListener('resize', resizeHandler); updateGeometry(); geometryTimer = setTimeout(updateGeometry, 100); return { setTotal(total, initialFloor = 1) { totalFloors = Math.max(1, Number(total) || 1); nav.hidden = totalFloors <= 1; paint(initialFloor); }, refresh: updateGeometry, destroy() { scrollRoot.removeEventListener('scroll', onScroll); if (scrollRaf) cancelAnimationFrame(scrollRaf); if (resizeHandler) window.removeEventListener('resize', resizeHandler); if (mutationObserver) mutationObserver.disconnect(); clearTimeout(mutationTimer); clearTimeout(geometryTimer); document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); }, }; } const SKELETON_HTML = `楼主帖子加载失败
', created_at: anchorData.created_at || new Date().toISOString(), actions_summary: [], boosts: [], can_boost: false, }; } ctx.op = opPost.username; ctx.totalComments = anchorData.posts_count - 1; floorNavigator.setTotal( anchorData.highest_post_number || anchorData.posts_count || 1, resolvedTarget ); // 更新标题和元数据 overlay.querySelector('.ldp-title').textContent = anchorData.title; overlay.querySelector('.ldp-meta').textContent = `${anchorData.posts_count} 帖 · ${anchorData.views || 0} 浏览 · 楼主 @${ctx.op || '?'}`; updateCommentsHeader(ctx); await mePromise; // 渲染 1 楼 const opNode = renderPost(opPost, false, ctx, 0); ctx.topicEl.appendChild(opNode); ctx.nodeMap.set(1, opNode); ctx.postMap.set(1, opPost); idToPost.set(opPost.id, opPost); ctx.tracker.observe(opNode); // 立即移除骨架屏 maskEl.classList.add('hide'); setTimeout(() => maskEl.remove(), 300); // 绑定底部操作栏 fOpenLink.href = `${BASE}/t/${topicId}`; bindBookmark(fBookmarkBtn, anchorData); bindFooterLike(fLikeBtn, fLikeCountEl, opPost); const canBoostOp = !!(opPost && opPost.can_boost); if (!canBoostOp) { fBoostBtn.disabled = true; fBoostBtn.style.opacity = '0.4'; } fBoostBtn.addEventListener('click', () => { if (fBoostBtn.disabled) return; const opNode = ctx.topicEl.querySelector('.ldp-post'); if (!opNode) return; const wrap = opNode.querySelector(':scope > .ldp-boost-input-wrap'); if (!wrap) return; const opening = !wrap.classList.contains('open'); wrap.classList.toggle('open', opening); if (opening) { wrap.querySelector('.ldp-boost-input').focus(); wrap.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }); fReplyBtn.addEventListener('click', () => { const opNode = ctx.topicEl.querySelector('.ldp-post'); if (!opNode) return; const box = ensureReplyBox(opNode); box.classList.toggle('open'); if (box.classList.contains('open')) { box.querySelector('textarea').focus(); box.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }); footerEl.hidden = false; floorNavigator.refresh(); bindActions(modal, ctx); tracker.start(); // 初始化 pending 数组(用于构建楼中楼) ctx.pending = []; ctx.repliesIO = { observe: () => {}, disconnect: () => {} }; // 兼容占位 // 保存完整的帖子流(用于双向加载) const streamFull = anchorData.post_stream.stream || []; const stream = streamFull.filter(id => { const cached = anchorData.post_stream.posts.find(p => p.id === id); return !(cached && cached.post_number === 1); }); // 初始化双向加载状态 const postCache = new Map(); anchorData.post_stream.posts.forEach(p => postCache.set(p.id, p)); let upCursor = 0; let downCursor = stream.length; let topReached = false; let bottomReached = false; let loadingDown = false; let loadingUp = false; let isAnchoring = true; // 锚定锁:定位完成前禁止触发加载 // 计算已加载的楼层范围 const loadedPostIds = new Set(anchorData.post_stream.posts.map(p => p.id)); const firstLoadedId = anchorData.post_stream.posts[0]?.id; const lastLoadedId = anchorData.post_stream.posts[anchorData.post_stream.posts.length - 1]?.id; if (firstLoadedId && streamFull.includes(firstLoadedId)) { upCursor = streamFull.indexOf(firstLoadedId); topReached = upCursor === 0; } if (lastLoadedId && streamFull.includes(lastLoadedId)) { downCursor = streamFull.indexOf(lastLoadedId) + 1; bottomReached = downCursor >= streamFull.length; } // 渲染目标楼层周围的帖子(使用 attachPost 构建楼中楼关系) loadingTip.classList.add('show'); anchorData.post_stream.posts.forEach(post => { if (post.post_number === 1) return; // 已渲染 attachPost(post, ctx); }); reflowPending(ctx); loadingTip.classList.remove('show'); // 展开完整祖先链,等待滚动稳定后再高亮目标;失败时函数内部降级为扁平定位。 await locatePost(resolvedTarget, ctx); // ====== 添加双向加载逻辑 ====== async function fetchPosts(postIds) { if (!postIds || !postIds.length) return []; const qs = postIds.map(id => `post_ids[]=${id}`).join('&'); const res = await fetchJSON(`${BASE}/t/${topicId}/posts.json?${qs}`); return res.post_stream.posts || []; } async function loadDown() { if (isAnchoring || loadingDown || bottomReached || downCursor >= streamFull.length) return; loadingDown = true; loadDownTip.classList.add('show'); try { const batch = streamFull.slice(downCursor, Math.min(downCursor + PAGE_SIZE, streamFull.length)); if (batch.length === 0) { bottomReached = true; showBottomTip(); return; } const posts = await fetchPosts(batch); posts.forEach(p => { if (p.post_number === 1 || loadedPostIds.has(p.id)) return; loadedPostIds.add(p.id); attachPost(p, ctx); }); reflowPending(ctx); downCursor += batch.length; bottomReached = downCursor >= streamFull.length; if (bottomReached) showBottomTip(); } catch (e) { console.error('向下加载失败:', e); } finally { loadingDown = false; loadDownTip.classList.remove('show'); } } async function loadUp() { if (isAnchoring || loadingUp || topReached || upCursor <= 0) return; loadingUp = true; loadUpTip.classList.add('show'); try { const oldScrollHeight = body.scrollHeight; const oldScrollTop = body.scrollTop; const start = Math.max(0, upCursor - PAGE_SIZE); const batch = streamFull.slice(start, upCursor); if (batch.length === 0) { topReached = true; if (!body.querySelector('.ldp-top-tip')) { const tip = document.createElement('div'); tip.className = 'ldp-top-tip'; tip.textContent = '已是最早的评论'; commentsEl.before(tip); } return; } const posts = await fetchPosts(batch); // 按 stream 顺序处理帖子(不要反转) // streamFull 中的顺序:[id1, id2, id3...] 对应楼层号顺序 // fetchPosts 返回的帖子顺序可能与请求顺序不同,需要按 stream 顺序重新排序 const postIdToPost = new Map(posts.map(p => [p.id, p])); const orderedPosts = batch .map(id => postIdToPost.get(id)) .filter(Boolean); const fragment = document.createDocumentFragment(); const tempCtx = Object.assign({}, ctx, { commentsEl: fragment, nodeMap: ctx.nodeMap, topicEl: ctx.topicEl, }); orderedPosts.forEach(p => { if (p.post_number === 1 || loadedPostIds.has(p.id)) return; loadedPostIds.add(p.id); attachPost(p, tempCtx); }); reflowPending(tempCtx); commentsEl.prepend(fragment); // 高度补偿 requestAnimationFrame(() => { const newScrollHeight = body.scrollHeight; body.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight); }); upCursor = start; topReached = upCursor === 0; if (topReached && !body.querySelector('.ldp-top-tip')) { const tip = document.createElement('div'); tip.className = 'ldp-top-tip'; tip.textContent = '已是最早的评论'; commentsEl.before(tip); } } catch (e) { console.error('向上加载失败:', e); } finally { loadingUp = false; loadUpTip.classList.remove('show'); } } // 监听滚动事件 if (!bottomReached) { const sentinelDownIO = new IntersectionObserver( (entries) => { if (entries.some(en => en.isIntersecting) && !isAnchoring) loadDown(); }, { root: body, rootMargin: '300px' } ); sentinelDownIO.observe(downSentinel); } if (!topReached) { const sentinelUpIO = new IntersectionObserver( (entries) => { if (entries.some(en => en.isIntersecting) && !isAnchoring) loadUp(); }, { root: body, rootMargin: '600px' } ); sentinelUpIO.observe(upSentinel); } body.addEventListener('scroll', () => { if (isAnchoring) return; const { scrollTop, scrollHeight, clientHeight } = body; if (scrollHeight - scrollTop - clientHeight < 400) loadDown(); if (scrollTop < 800) loadUp(); }, { passive: true }); // locatePost 已等待滚动真正停止,可以安全开放双向加载。 isAnchoring = false; showBottomTip(); } catch (e) { alert('加载失败:' + e.message); } return; // 跳转场景直接返回,不加载完整嵌套树 } // 正常浏览:加载嵌套树 API const nestedResponse = await fetchNestedTree(topicId, 0); // 检查服务器返回的是嵌套树还是扁平流(私有话题等) if (nestedResponse.roots && nestedResponse.roots.length >= 0) { // 成功获取嵌套树数据 const opPost = nestedResponse.op_post; let roots = nestedResponse.roots; const topicMeta = nestedResponse.topic; nestedHasMoreRoots = nestedResponse.has_more_roots || false; nestedRootsPage = nestedResponse.page || 0; nestedTreeRoots = roots; if (!opPost) { throw new Error('服务器返回数据缺少 OP 帖子'); } // 建立混合索引 idToPost.clear(); idToPost.set(opPost.id, opPost); indexTree(roots); ctx.postMap.clear(); ctx.postMap.set(1, opPost); indexPostsByNumber(roots, ctx); // 构造 topicData 对象(兼容后续逻辑) topicData = { id: topicMeta?.id || topicId, title: topicMeta?.title || '', posts_count: topicMeta?.posts_count || 1, views: 0, post_stream: { posts: [opPost, ...roots], stream: [] }, details: { created_by: { username: opPost.username } } }; ctx.op = opPost.username; ctx.totalComments = (topicMeta?.posts_count || 1) - 1; // 总评论数 = 总帖子数 - 楼主 floorNavigator.setTotal( topicMeta?.highest_post_number || topicMeta?.posts_count || 1, 1 ); // 更新标题和元数据 overlay.querySelector('.ldp-title').textContent = topicMeta?.title || `话题 #${topicId}`; overlay.querySelector('.ldp-meta').textContent = `${topicMeta?.posts_count || 1} 帖 · 楼主 @${ctx.op || '?'}`; updateCommentsHeader(ctx); await mePromise; // 渲染 1 楼 const opNode = renderPost(opPost, false, ctx, 0); ctx.topicEl.appendChild(opNode); ctx.nodeMap.set(1, opNode); ctx.tracker.observe(opNode); // 立即移除骨架屏 maskEl.classList.add('hide'); setTimeout(() => maskEl.remove(), 300); // 绑定底部操作栏 fOpenLink.href = `${BASE}/t/${topicId}`; bindBookmark(fBookmarkBtn, topicData); bindFooterLike(fLikeBtn, fLikeCountEl, opPost); const canBoostOp = !!(opPost && opPost.can_boost); if (!canBoostOp) { fBoostBtn.disabled = true; fBoostBtn.style.opacity = '0.4'; } fBoostBtn.addEventListener('click', () => { if (fBoostBtn.disabled) return; const opNode = ctx.topicEl.querySelector('.ldp-post'); if (!opNode) return; const wrap = opNode.querySelector(':scope > .ldp-boost-input-wrap'); if (!wrap) return; const opening = !wrap.classList.contains('open'); wrap.classList.toggle('open', opening); if (opening) { wrap.querySelector('.ldp-boost-input').focus(); wrap.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }); fReplyBtn.addEventListener('click', () => { const opNode = ctx.topicEl.querySelector('.ldp-post'); if (!opNode) return; const box = ensureReplyBox(opNode); box.classList.toggle('open'); if (box.classList.contains('open')) { box.querySelector('textarea').focus(); box.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }); footerEl.hidden = false; floorNavigator.refresh(); bindActions(modal, ctx); tracker.start(); // ====== 阶段 2:渲染嵌套树 ====== loadingTip.classList.add('show'); try { renderNestedTree(roots, commentsEl, ctx, 1); loadingTip.classList.remove('show'); // 如果有更多顶层回复,添加滚动监听器 if (nestedHasMoreRoots) { let isLoadingMore = false; // 加载锁 // IntersectionObserver 监听底部哨兵 const sentinelDownIO = new IntersectionObserver( (entries) => { if (entries.some((en) => en.isIntersecting) && !isLoadingMore) { loadMoreRoots(); } }, { root: body, rootMargin: '300px' } ); sentinelDownIO.observe(downSentinel); // scroll 事件兜底 body.addEventListener('scroll', () => { if (isLoadingMore) return; const { scrollTop, scrollHeight, clientHeight } = body; if (scrollHeight - scrollTop - clientHeight < 400) { loadMoreRoots(); } }, { passive: true }); // 加载更多顶层回复 async function loadMoreRoots() { if (!nestedHasMoreRoots || isLoadingMore) return; isLoadingMore = true; loadDownTip.classList.add('show'); try { const nextPage = nestedRootsPage + 1; const response = await fetchNestedTree(topicId, nextPage); if (response.roots && response.roots.length > 0) { // 去重:过滤已存在的帖子 const existingIds = new Set(nestedTreeRoots.map(r => r.id)); const newRoots = response.roots.filter(r => !existingIds.has(r.id)); if (newRoots.length > 0) { // 追加新数据到索引 indexTree(newRoots); // 追加到内存树 nestedTreeRoots = nestedTreeRoots.concat(newRoots); // 渲染新数据 renderNestedTree(newRoots, commentsEl, ctx, 1); } // 更新状态 nestedHasMoreRoots = response.has_more_roots || false; nestedRootsPage = response.page || nextPage; if (!nestedHasMoreRoots) { showBottomTip(); } } else { // 没有新数据,停止加载 nestedHasMoreRoots = false; showBottomTip(); } } catch (e) { console.error('加载更多评论失败:', e); } finally { isLoadingMore = false; loadDownTip.classList.remove('show'); } } } else { showBottomTip(); } } catch (err) { loadingTip.classList.remove('show'); alert('渲染评论失败:' + err.message); } } else if (nestedResponse.flat_topic) { // 服务器返回扁平流(私有话题等) alert('此话题不支持树形视图,请刷新页面使用标准视图'); return; } else { throw new Error('服务器返回数据格式异常'); } } catch (err) { if (err.name === 'AbortError') { // 用户关闭弹框,静默退出 return; } // 加载失败,显示错误 if (maskEl) maskEl.remove(); body.innerHTML = `