[油猴脚本]LinuxDo列表页弹出显示详情页,可评论,滚动加载

一直很嫌弃目前论坛的详情页加载方式,现在的浏览方式非常不方便快速浏览列表,有些帖子进去看一眼就关了。
跳转和新窗口打开都太重了!
加载缓慢!

OK~刚好看到有大佬制作了分屏浏览脚本,但是这个也不是我的习惯,而且一直有个分屏在旁边,我难受!

于是,我参考了 【LinuxDo分屏浏览脚本】必备刷帖神器~,又参考了v2next这个脚本。
开始鞭策AI给我写了这个脚本。代码都是AI写的,与我无瓜,哪里不对您自己改,改得好记得回复评论,给我再参考参考!

功能如下:

  • 弹窗显示帖子详情:点击帖子链接时,以弹窗形式显示帖子内容和评论。弹窗支持点击背景关闭,并监听 ESC 键关闭。

  • 评论加载与分页:在弹窗中加载帖子评论,并支持滚动加载更多评论。

  • 楼中楼评论样式:识别评论之间的回复关系,并以缩进的方式展示“楼中楼”结构,使对话层次更清晰。

  • 图片方便查看:点击帖子或评论中的图片时,直接加载大图。点击背景、按钮、图片本身、ESC可关闭。

  • 评论发布功能:在弹窗中提供评论编辑器,允许用户回复帖子或评论。每个帖子和评论下方都有一个“回复”按钮,点击后会在底部的评论编辑器中自动填充 @ 用户名 #楼层号,方便用户进行回复。

  • UI/UX 改进:包括加载和缓存性能、CSS样式注入、返回顶部按钮、在新标签页打开按钮、Toast消息提示等。

0.2.0版
添加了阅读追踪,再也不用担心浏览过的帖子系统不知道了。对于升级保级有帮助。
添加了已访问标记,点击过的帖子变灰,浏览器会保留记录,刷新页面也存在。

脚本如下

0.2.0版

// ==UserScript==
// @name         Linux.do 弹出浏览详情页,快速看帖助手
// @namespace    https://linux.do
// @version      0.2.0
// @description  Linux.do 弹窗式浏览,支持帖子详情加载、楼中楼评论、阅读追踪、已访问标记
// @match        https://linux.do/*
// @grant        GM_addStyle
// @connect      linux.do
// @run-at       document-end
// ==/UserScript==

(function(){
  'use strict';

  // ==================== 常量配置 ====================
  const CONFIG = {
    PAGE_SIZE: 20,
    THROTTLE_DELAY: 100,
    TOAST_DURATION: 3000,
    LOADING_DELAY: 150,
    SCROLL_TRIGGER_DISTANCE: 300,
    SELECTORS: {
      POST_LINKS: 'a.title,a.topic-link,a.search-link',
      POST_POPUP: '#post-popup',
      IMAGE_LIGHTBOX: '#image-lightbox-overlay',
      CONTENT_WRAPPER: '.post-popup-content-wrapper',
      CONTENT_CONTAINER: '.post-content-container',
      STICKY_EDITOR: '.sticky-comment-editor'
    },
    CSS_CLASSES: {
      MODAL_OPEN: 'modal-open',
      POST_BOX: 'post-box',
      COMMENT_BOX: 'comment-box',
      LOADING_INDICATOR: 'loading-indicator',
      TOAST_MESSAGE: 'toast-message',
      TOAST_ERROR: 'error',
      TOAST_SHOW: 'show'
    },
    API_ENDPOINTS: {
      TOPIC: '/t/{slug}/{id}.json',
      POSTS: '/t/{topicId}/posts.json',
      SUBMIT_POST: '/posts'
    }
  };

  // ==================== 全局状态管理 ====================
  const state = {
    currentTopicId: null,
    currentSlug: null,
    nextPostIds: [],
    postDataCache: [],
    isLoadingMore: false
  };

  // ==================== DOM 元素缓存 ====================
  const elements = {
    postPopup: null,
    openFullBtn: null,
    imageLightbox: null,
    backToTopBtn: null
  };

  // ==================== 已访问帖子跟踪模块 ====================
  const VisitedTracker = {
    storageKey: 'linuxdo_visited_posts',
    visitedPosts: new Map(), // 改用Map来存储时间戳

    init() {
      this.loadVisitedPosts();
      this.markExistingVisitedLinks();
      this.setupLinkObserver();
    },

    loadVisitedPosts() {
      try {
        const stored = localStorage.getItem(this.storageKey);
        if (stored) {
          const visitedData = JSON.parse(stored);
          this.visitedPosts = new Map(visitedData);
        }
      } catch (error) {
        console.warn('加载已访问帖子记录失败:', error);
        this.visitedPosts = new Map();
      }
    },

    saveVisitedPosts() {
      try {
        // 只保留最近200条记录,按时间顺序删除旧记录
        if (this.visitedPosts.size > 200) {
          // 按时间戳排序,删除最旧的记录
          const sortedEntries = Array.from(this.visitedPosts.entries())
            .sort((a, b) => a[1] - b[1]); // 按时间戳升序排序
          
          // 保留最新的200条
          const recentEntries = sortedEntries.slice(-200);
          this.visitedPosts = new Map(recentEntries);
        }
        
        const visitedArray = Array.from(this.visitedPosts.entries());
        localStorage.setItem(this.storageKey, JSON.stringify(visitedArray));
      } catch (error) {
        console.warn('保存已访问帖子记录失败:', error);
      }
    },

    markPostAsVisited(topicId) {
      if (!topicId) return;
      
      const timestamp = Date.now();
      this.visitedPosts.set(topicId.toString(), timestamp);
      this.saveVisitedPosts();
      
      // 立即更新页面上对应的链接
      this.updateLinkVisualState(topicId);
    },

    isPostVisited(topicId) {
      return this.visitedPosts.has(topicId.toString());
    },

    updateLinkVisualState(topicId) {
      // 查找所有指向该帖子的链接
      const links = document.querySelectorAll('a[href*="/t/"]');
      links.forEach(link => {
        const match = link.href.match(/\/t\/[^/]+\/(\d+)/);
        if (match && match[1] === topicId.toString()) {
          this.applyVisitedStyle(link);
        }
      });
    },

    applyVisitedStyle(link) {
      if (!link.classList.contains('post-link-visited')) {
        link.classList.add('post-link-visited');
      }
    },

    markExistingVisitedLinks() {
      const links = document.querySelectorAll('a[href*="/t/"]');
      let markedCount = 0;
      
      links.forEach(link => {
        const match = link.href.match(/\/t\/[^/]+\/(\d+)/);
        if (match) {
          const topicId = match[1];
          if (this.isPostVisited(topicId)) {
            this.applyVisitedStyle(link);
            markedCount++;
          }
        }
      });
      
      
    },

    setupLinkObserver() {
      // 使用 MutationObserver 监听新添加的链接
      const observer = new MutationObserver((mutations) => {
        mutations.forEach(mutation => {
          mutation.addedNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              // 检查新添加的元素是否是链接或包含链接
              const links = node.matches && node.matches('a[href*="/t/"]') ?
                           [node] :
                           node.querySelectorAll ? node.querySelectorAll('a[href*="/t/"]') : [];
              
              links.forEach(link => {
                const match = link.href.match(/\/t\/[^/]+\/(\d+)/);
                if (match) {
                  const topicId = match[1];
                  if (this.isPostVisited(topicId)) {
                    this.applyVisitedStyle(link);
                  }
                }
              });
            }
          });
        });
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true
      });
    }
  };

  // ==================== 阅读状态追踪模块 ====================
  const ReadingTracker = {
    // 状态标识
    isActive: false,
    topicId: null,
    observer: null,
    readingData: new Map(),
    pendingSubmissions: [],
    autoSubmitTimer: null,
    
    // 配置参数
    config: {
      batchSize: 5,
      submitDelay: 3000,
      minReadTime: 300,
      visibilityThreshold: 0.3,
      autoMarkDelay: 2000, // 弹窗停留2秒后自动标记初始内容
      initialMarkDelay: 1000 // 初始内容标记延迟
    },

    init(topicId) {
      // 如果已经在运行,先清理
      if (this.isActive) {
        this.cleanup();
      }
      
      this.isActive = true;
      this.topicId = topicId;
      this.readingData.clear();
      this.pendingSubmissions = [];
      this.startTime = Date.now();
      
      this.setupIntersectionObserver();
      this.setupAutoSubmit();
      this.setupAutoMarkTimer();
      
      
    },

    setupIntersectionObserver() {
      if (this.observer) {
        this.observer.disconnect();
      }
      
      this.observer = new IntersectionObserver((entries) => {
        // 检查追踪器是否仍然活跃
        if (!this.isActive) return;
        
        entries.forEach(entry => {
          const postNumber = parseInt(entry.target.dataset.postNumber);
          
          // 验证数据有效性
          if (!this.isValidPost(postNumber, entry.target)) {
            return;
          }
          
          if (entry.isIntersecting) {
            this.startReading(postNumber);
          } else {
            this.stopReading(postNumber);
          }
        });
      }, {
        threshold: [0.1, 0.3, 0.5, 0.8], // 多个阈值提高检测精度
        root: document.querySelector('.post-popup-content-wrapper'),
        rootMargin: '50px 0px' // 提前50px开始检测
      });
      
      // 设置滚动行为追踪
      this.setupScrollTracking();
    },

    // 新增:滚动行为追踪
    setupScrollTracking() {
      const contentWrapper = document.querySelector('.post-popup-content-wrapper');
      if (!contentWrapper) return;
      
      let scrollTimeout;
      let lastScrollTime = Date.now();
      let totalScrollDistance = 0;
      let lastScrollTop = contentWrapper.scrollTop;
      
      this.scrollHandler = Utils.throttle((e) => {
        if (!this.isActive) return;
        
        const currentTime = Date.now();
        const currentScrollTop = contentWrapper.scrollTop;
        const scrollDistance = Math.abs(currentScrollTop - lastScrollTop);
        
        // 记录滚动行为
        totalScrollDistance += scrollDistance;
        lastScrollTop = currentScrollTop;
        lastScrollTime = currentTime;
        
        // 清除之前的定时器
        if (scrollTimeout) {
          clearTimeout(scrollTimeout);
        }
        
        // 滚动停止后500ms,记录滚动会话
        scrollTimeout = setTimeout(() => {
          if (totalScrollDistance > 100) { // 只记录有意义的滚动
            this.recordScrollSession(totalScrollDistance, currentTime - lastScrollTime + 500);
            
          }
          totalScrollDistance = 0;
        }, 500);
        
      }, 100);
      
      contentWrapper.addEventListener('scroll', this.scrollHandler, { passive: true });
    },

    // 记录滚动会话数据
    recordScrollSession(distance, duration) {
      if (!this.isActive) return;
      
      // 为当前话题添加滚动时间
      this.scrollSessions = this.scrollSessions || [];
      this.scrollSessions.push({
        distance: distance,
        duration: duration,
        timestamp: Date.now()
      });
      
      // 每次滚动都增加话题总时间
      this.topicScrollTime = (this.topicScrollTime || 0) + duration;
    },

    // 验证帖子数据有效性
    isValidPost(postNumber, element) {
      // 检查追踪器状态
      if (!this.isActive) return false;
      
      // 检查 post_number 是否有效
      if (!postNumber || isNaN(postNumber)) return false;
      
      // 检查元素是否仍在 DOM 中
      if (!document.contains(element)) return false;
      
      // 检查是否在已加载的数据缓存中
      const isInCache = state.postDataCache.some(post =>
        post.post_number === postNumber
      );
      
      if (!isInCache) {
        
        return false;
      }
      
      return true;
    },

    observe(commentElement) {
      if (!this.isActive || !this.observer) return;
      
      const postNumber = commentElement.dataset.postNumber;
      if (postNumber && this.isValidPost(parseInt(postNumber), commentElement)) {
        this.observer.observe(commentElement);
        
        // 检查元素是否在初始视口中,如果是则延迟标记为已读
        this.checkInitialVisibility(commentElement, parseInt(postNumber));
      }
    },

    // 检查初始可见性
    checkInitialVisibility(element, postNumber) {
      if (!this.isActive) return;
      
      setTimeout(() => {
        if (!this.isActive || !element || !document.contains(element)) return;
        
        const rect = element.getBoundingClientRect();
        const containerRect = document.querySelector('.post-popup-content-wrapper')?.getBoundingClientRect();
        
        if (containerRect && rect.top < containerRect.bottom && rect.bottom > containerRect.top) {
          // 元素在视口中,标记为开始阅读
          if (!this.readingData.has(postNumber)) {
            
            this.startReading(postNumber);
            
            // 短暂延迟后标记为已读完成
            setTimeout(() => {
              if (this.isActive && this.readingData.has(postNumber)) {
                this.stopReading(postNumber);
              }
            }, this.config.minReadTime);
          }
        }
      }, this.config.initialMarkDelay);
    },

    // 设置自动标记定时器
    setupAutoMarkTimer() {
      if (this.autoMarkTimer) {
        clearTimeout(this.autoMarkTimer);
      }
      
      this.autoMarkTimer = setTimeout(() => {
        if (!this.isActive) return;
        
        
        this.autoMarkInitialContent();
      }, this.config.autoMarkDelay);
    },

    // 自动标记初始内容
    autoMarkInitialContent() {
      if (!this.isActive) return;
      
      const containerRect = document.querySelector('.post-popup-content-wrapper')?.getBoundingClientRect();
      if (!containerRect) return;
      
      // 查找所有已加载的评论元素
      document.querySelectorAll('[data-post-number]').forEach(element => {
        const postNumber = parseInt(element.dataset.postNumber);
        if (!this.isValidPost(postNumber, element)) return;
        
        const rect = element.getBoundingClientRect();
        
        // 检查是否在容器视口范围内(包括上方已滚过的内容)
        if (rect.top < containerRect.bottom) {
          if (!this.readingData.has(postNumber)) {
            
            this.readingData.set(postNumber, {
              startTime: null,
              duration: this.config.minReadTime * 2 // 给一个合理的阅读时间
            });
            this.addToPendingSubmissions(postNumber, this.config.minReadTime * 2);
          }
        }
      });
    },

    startReading(postNumber) {
      if (!this.isActive) return;
      
      if (!this.readingData.has(postNumber)) {
        this.readingData.set(postNumber, {
          startTime: Date.now(),
          duration: 0
        });
        
      }
    },

    stopReading(postNumber) {
      if (!this.isActive) return;
      
      const data = this.readingData.get(postNumber);
      if (data && data.startTime) {
        const duration = Date.now() - data.startTime;
        
        // 只记录有效的阅读时间
        if (duration >= this.config.minReadTime) {
          data.duration = duration;
          this.addToPendingSubmissions(postNumber, duration);
          
        }
        
        // 重置开始时间
        data.startTime = null;
      }
    },

    addToPendingSubmissions(postNumber, duration) {
      if (!this.isActive) return;
      
      // 再次验证数据有效性
      const isValid = state.postDataCache.some(post =>
        post.post_number === postNumber
      );
      
      if (!isValid) {
        console.warn(`丢弃无效的阅读数据 #${postNumber}`);
        return;
      }
      
      this.pendingSubmissions.push({
        postNumber: postNumber,
        duration: duration,
        timestamp: Date.now()
      });

      // 检查是否需要批量提交
      if (this.pendingSubmissions.length >= this.config.batchSize) {
        this.submitPendingData();
      }
    },

    async submitPendingData() {
      if (!this.isActive || this.pendingSubmissions.length === 0) return;

      const dataToSubmit = [...this.pendingSubmissions];
      this.pendingSubmissions = [];

      try {
        // 最终数据验证 - 只提交确实已加载的评论
        const validData = dataToSubmit.filter(item => {
          return state.postDataCache.some(post =>
            post.post_number === item.postNumber
          );
        });
        
        if (validData.length === 0) {
          console.log('没有有效的阅读数据需要提交');
          return;
        }
        
        await this.sendTimingData(validData);
        
      } catch (error) {
        console.error('提交阅读数据失败:', error);
        // 只有在追踪器仍然活跃时才重新加入队列
        if (this.isActive) {
          this.pendingSubmissions.unshift(...dataToSubmit);
        }
      }
    },

    setupAutoSubmit() {
      // 清理现有定时器
      if (this.autoSubmitTimer) {
        clearInterval(this.autoSubmitTimer);
      }
      
      this.autoSubmitTimer = setInterval(() => {
        if (this.isActive && this.pendingSubmissions.length > 0) {
          this.submitPendingData();
        }
      }, this.config.submitDelay);
    },

    // 立即停止所有追踪活动
    stop() {
      
      
      this.isActive = false;
      
      // 停止观察器
      if (this.observer) {
        this.observer.disconnect();
      }
      
      // 清理滚动监听器
      if (this.scrollHandler) {
        const contentWrapper = document.querySelector('.post-popup-content-wrapper');
        if (contentWrapper) {
          contentWrapper.removeEventListener('scroll', this.scrollHandler);
        }
      }
      
      // 清理定时器
      if (this.autoSubmitTimer) {
        clearInterval(this.autoSubmitTimer);
        this.autoSubmitTimer = null;
      }
      
      if (this.autoMarkTimer) {
        clearTimeout(this.autoMarkTimer);
        this.autoMarkTimer = null;
      }
      
      // 在关闭前,最后一次自动标记所有可见内容
      this.finalMarkVisibleContent();
      
      // 立即提交剩余的有效数据
      if (this.pendingSubmissions.length > 0) {
        
        this.submitPendingData();
      }
    },

    // 关闭时最终标记可见内容
    finalMarkVisibleContent() {
      const containerRect = document.querySelector('.post-popup-content-wrapper')?.getBoundingClientRect();
      if (!containerRect) return;
      
      let markedCount = 0;
      document.querySelectorAll('[data-post-number]').forEach(element => {
        const postNumber = parseInt(element.dataset.postNumber);
        if (!this.isValidPost(postNumber, element)) return;
        
        const rect = element.getBoundingClientRect();
        
        // 标记在容器顶部以下的所有内容(用户已经滚动过的)
        if (rect.top < containerRect.top + containerRect.height * 0.8) {
          if (!this.readingData.has(postNumber) || this.readingData.get(postNumber).duration === 0) {
            const readTime = Math.max(this.config.minReadTime,
              Math.min(this.config.minReadTime * 3, (Date.now() - this.startTime) / 10));
            
            this.readingData.set(postNumber, {
              startTime: null,
              duration: readTime
            });
            this.addToPendingSubmissions(postNumber, readTime);
            markedCount++;
          }
        }
      });
      
      
    },

    cleanup() {
      this.stop();
      
      // 清理数据
      this.readingData.clear();
      this.pendingSubmissions = [];
      this.scrollSessions = [];
      this.topicScrollTime = 0;
      this.observer = null;
      this.scrollHandler = null;
      this.autoMarkTimer = null;
      this.topicId = null;
      this.startTime = null;
      
      
    },

    async sendTimingData(readingDataArray) {
      const csrfToken = Utils.getCSRFToken();
      if (!csrfToken) {
        throw new Error('无法获取 CSRF Token');
      }

      const params = new URLSearchParams();
      let totalTopicTime = 0;

      readingDataArray.forEach(data => {
        params.append(`timings[${data.postNumber}]`, data.duration.toString());
        totalTopicTime += data.duration;
      });
      
      // 包含滚动时间,使总时间更真实
      const scrollTime = this.topicScrollTime || 0;
      const finalTopicTime = Math.max(totalTopicTime, scrollTime, totalTopicTime + scrollTime * 0.3);
      
      params.append('topic_time', Math.round(finalTopicTime).toString());
      params.append('topic_id', this.topicId);

      

      const response = await fetch('https://linux.do/topics/timings', {
        method: 'POST',
        headers: {
          'accept': '*/*',
          'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
          'discourse-background': 'true',
          'discourse-logged-in': 'true',
          'discourse-present': 'true',
          'priority': 'u=1, i',
          'sec-fetch-dest': 'empty',
          'sec-fetch-mode': 'cors',
          'sec-fetch-site': 'same-origin',
          'x-csrf-token': csrfToken,
          'x-requested-with': 'XMLHttpRequest',
          'x-silence-logger': 'true'
        },
        referrer: 'https://linux.do/',
        body: params.toString(),
        mode: 'cors',
        credentials: 'include'
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return response;
    },

    
  };

  // ==================== CSS 样式注入 ====================
  GM_addStyle(`
    body { overflow-x: hidden !important; }
    body.${CONFIG.CSS_CLASSES.MODAL_OPEN} { overflow: hidden !important; }

    /* Post Popup Base Styles */
    ${CONFIG.SELECTORS.POST_POPUP} {
      position: fixed; top: 0; left: 0; right: 0; bottom: 0;
      background: rgba(46,47,48,.8); z-index: 1002; display: none;
      justify-content: center; align-items: center; color: #e0e0e0;
    }

    /* Image Lightbox Styles */
    ${CONFIG.SELECTORS.IMAGE_LIGHTBOX} {
      position: fixed; top: 0; left: 0; right: 0; bottom: 0;
      background: rgba(0, 0, 0, 0.9); z-index: 2000; display: none;
      justify-content: center; align-items: center; cursor: pointer;
      overflow: hidden;
    }

    #image-lightbox-content {
      position: relative; max-width: 95vw; max-height: 95vh;
      display: flex; justify-content: center; align-items: flex-start;
      transition: opacity 0.3s ease; overflow: auto;
      border-radius: 8px; background: rgba(0, 0, 0, 0.3);
    }

    .lightbox-image {
      max-width: 100%; height: auto; object-fit: contain;
      border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
      cursor: pointer; transition: opacity 0.3s ease;
      display: block; margin: 0 auto;
    }
    
    .lightbox-image:hover {
      filter: brightness(0.9);
    }

    .lightbox-loading {
      position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
      color: white; font-size: 16px; text-align: center;
      background: rgba(0, 0, 0, 0.7); padding: 20px; border-radius: 8px;
      min-width: 200px; z-index: 2001;
    }

    .lightbox-loading-spinner {
      display: inline-block; width: 20px; height: 20px; margin-right: 10px;
      border: 2px solid #ffffff40; border-top: 2px solid #ffffff;
      border-radius: 50%; animation: lightbox-spin 1s linear infinite;
    }

    @keyframes lightbox-spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }

    /* 滚动条样式 */
    #image-lightbox-content::-webkit-scrollbar { width: 8px; height: 8px; }
    #image-lightbox-content::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); }
    #image-lightbox-content::-webkit-scrollbar-thumb {
      background: rgba(255,255,255,0.3); border-radius: 4px;
    }
    #image-lightbox-content::-webkit-scrollbar-thumb:hover {
      background: rgba(255,255,255,0.5);
    }

    .lightbox-close-btn {
      position: absolute; top: 10px; right: 10px; width: 35px; height: 35px;
      background: rgba(0, 0, 0, 0.6); color: white; border: none;
      border-radius: 50%; font-size: 20px; cursor: pointer;
      display: flex; justify-content: center; align-items: center;
      transition: background-color 0.3s; z-index: 2002;
      backdrop-filter: blur(2px);
    }
    .lightbox-close-btn:hover { background: rgba(255, 255, 255, 0.4); }

    /* Make images clickable */
    .post-content img, .comment-content img {
      cursor: pointer; transition: transform 0.2s ease;
    }
    .post-content img:hover, .comment-content img:hover { transform: scale(1.02); }

    /* Content Wrapper */
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} {
      height: 100%; overflow-y: auto; background: #1e1e1e;
      max-width: 950px; width: 90vw; padding: 3rem 8rem 100px;
      border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.7);
      position: relative; display: flex; flex-direction: column;
      align-items: center; box-sizing: border-box; flex-grow: 1;
    }

    /* Buttons Container */
    .post-popup-buttons {
      position: fixed; top: calc(50vh - 50vh + 3rem);
      right: calc((100vw - 950px) / 2 + 6rem);
      display: flex; flex-direction: column; gap: 10px; z-index: 1004;
    }
    @media (max-width: 1050px) {
      .post-popup-buttons { right: 5vw; top: 15vh; }
    }

    /* Buttons */
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} .close-btn,
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} .open-full-btn {
      position: static; width: 30px; height: 30px; border: none;
      border-radius: 50%; font-size: 18px; cursor: pointer;
      display: flex; justify-content: center; align-items: center;
      z-index: 1004; transition: background-color 0.3s;
    }
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} .close-btn {
      background: #444; color: white;
    }
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} .close-btn:hover { background: #666; }
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} .open-full-btn {
      background: #43a047; color: white;
    }
    ${CONFIG.SELECTORS.CONTENT_WRAPPER} .open-full-btn:hover { background: #5cb85c; }

    /* Content Container */
    ${CONFIG.SELECTORS.CONTENT_CONTAINER} { width: 100%; }

    /* Post/Comment Styles */
    ${CONFIG.SELECTORS.POST_POPUP} .${CONFIG.CSS_CLASSES.POST_BOX},
    ${CONFIG.SELECTORS.POST_POPUP} .${CONFIG.CSS_CLASSES.COMMENT_BOX} {
      margin: 8px 0; background: #2a2a2a; border-radius: 4px;
      padding: 8px; border: 1px solid #444; color: #ddd;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .${CONFIG.CSS_CLASSES.POST_BOX}:hover,
    ${CONFIG.SELECTORS.POST_POPUP} .${CONFIG.CSS_CLASSES.COMMENT_BOX}:hover { background: #333; }

    /* Headers */
    ${CONFIG.SELECTORS.POST_POPUP} .post-header,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-header {
      display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .post-avatar { width: 40px; height: 40px; border-radius: 50%; }
    ${CONFIG.SELECTORS.POST_POPUP} .comment-avatar { width: 30px; height: 30px; border-radius: 50%; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-author,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-author { font-weight: bold; color: #66aaff; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-meta,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-meta { font-size: 12px; color: #999; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-floor,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-floor { font-size: 12px; color: #999; margin-left: auto; }
    ${CONFIG.SELECTORS.POST_POPUP} .comment-author { font-size: 14px; }
    ${CONFIG.SELECTORS.POST_POPUP} .comment-meta,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-floor { font-size: 10px; }

    /* Content */
    ${CONFIG.SELECTORS.POST_POPUP} .post-content,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-content {
      line-height: 1.6; color: #ccc; margin-top: 5px; word-break: break-word;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .comment-content { font-size: 13px; line-height: 1.5; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content img,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-content img {
      max-width: 100%; height: auto; display: block; margin: 8px auto; border-radius: 4px;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .comment-content img { margin: 4px 0; }

    /* Content Elements */
    ${CONFIG.SELECTORS.POST_POPUP} .post-content a,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-content a { color: #66aaff; text-decoration: none; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content a:hover,
    ${CONFIG.SELECTORS.POST_POPUP} .comment-content a:hover { text-decoration: underline; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content pre,
    ${CONFIG.SELECTORS.POST_POPUP} .post-content code {
      background-color: #222; color: #eee; padding: 8px; border-radius: 4px;
      overflow-x: auto; font-family: monospace; font-size: 0.9em; margin: 8px 0;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content pre code { padding: 0; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content blockquote {
      border-left: 4px solid #555; padding-left: 10px; margin: 8px 0; color: #aaa;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content ul,
    ${CONFIG.SELECTORS.POST_POPUP} .post-content ol { margin: 8px 0 8px 20px; padding: 0; }
    ${CONFIG.SELECTORS.POST_POPUP} .post-content li { margin-bottom: 4px; }

    /* Floating Buttons */
    ${CONFIG.SELECTORS.POST_POPUP} .floating-btn {
      position: fixed; width: 40px; height: 40px; border: none; border-radius: 50%;
      color: white; cursor: pointer; z-index: 1003; display: flex;
      align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,0.8);
      font-size: 18px; opacity: 0.85; background-color: #444; transition: background-color 0.3s;
    }
    ${CONFIG.SELECTORS.POST_POPUP} .floating-btn:hover { opacity: 1; background-color: #666; }
    ${CONFIG.SELECTORS.POST_POPUP} #back-to-top-btn {
      background: #3399ff;
      position: fixed;
      bottom: 20px;
      right: calc((100vw - 950px) / 2 + 5rem);
      z-index: 1004;
    }
    @media (max-width: 1050px) {
      ${CONFIG.SELECTORS.POST_POPUP} #back-to-top-btn { right: 5vw; }
    }

    /* Comment Actions */
    .comment-actions {
      display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px;
    }
    .comment-actions button {
      background: #333; color: #eee; border: none; border-radius: 4px;
      padding: 5px 10px; cursor: pointer; font-size: 12px; transition: background-color 0.3s;
    }
    .comment-actions button:hover { background: #555; }

    /* Editor Styles */
    .post-editor-wrapper {
      width: 100%; box-sizing: border-box; overflow: hidden; color: #e0e0e0; padding: 10px;
    }
    ${CONFIG.SELECTORS.STICKY_EDITOR} {
      position: static; background: #2a2a2a; border-top: 1px solid #444; padding: 10px 0;
    }
    ${CONFIG.SELECTORS.STICKY_EDITOR}.active {
      position: sticky; bottom: 0; z-index: 1005; background: #333;
      border-top: 1px solid #555; padding-bottom: 10px;
      box-shadow: 0 -4px 12px rgba(0,0,0,0.5); border-radius: 8px;
    }
    .post-editor-wrapper textarea {
      border-radius: 4px; transition: border 0.3s; width: 100%; max-width: 100%;
      padding: 10px 14px; box-sizing: border-box; outline: none; font-family: sans-serif;
      font-size: 14px; min-height: 80px; resize: vertical; background: #1e1e1e;
      color: #eee; border: 1px solid #444;
    }
    .post-editor-wrapper textarea:focus { border-color: #66aaff; }
    .post-editor-wrapper .toolbar {
      box-sizing: border-box; padding: 5px 0; width: 100%; display: flex;
      justify-content: flex-end; align-items: center; background: none;
      border-top: none; border-radius: 0; gap: 10px;
    }
    .post-editor-wrapper .toolbar .submit-comment-btn {
      background: #3399ff; color: white; border: none; border-radius: 4px;
      padding: 6px 12px; cursor: pointer; font-size: 14px; transition: background-color 0.3s;
    }
    .post-editor-wrapper .toolbar .submit-comment-btn:hover { background: #1a73e8; }
    .post-editor-wrapper .toolbar .submit-comment-btn:disabled {
      background: #555; cursor: not-allowed;
    }
    .post-editor-wrapper .toolbar .close-editor-btn {
      background: none; color: #999; border: 1px solid #555; border-radius: 4px;
      padding: 6px 12px; cursor: pointer; font-size: 14px;
      transition: color 0.3s, border-color 0.3s;
    }
    .post-editor-wrapper .toolbar .close-editor-btn:hover { color: #eee; border-color: #999; }

    /* Toast Styles */
    .${CONFIG.CSS_CLASSES.TOAST_MESSAGE} {
      position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
      background-color: rgba(40, 167, 69, 0.9); color: white; padding: 10px 20px;
      border-radius: 5px; z-index: 9999; opacity: 0;
      transition: opacity 0.5s ease-in-out, background-color 0.3s; pointer-events: none;
      border: 1px solid rgba(255, 255, 255, 0.3); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
    }
    .${CONFIG.CSS_CLASSES.TOAST_MESSAGE}.${CONFIG.CSS_CLASSES.TOAST_ERROR} {
      background-color: rgba(220, 53, 69, 0.9);
    }
    .${CONFIG.CSS_CLASSES.TOAST_MESSAGE}.${CONFIG.CSS_CLASSES.TOAST_SHOW} { opacity: 1; }

    /* Scrollbar Styles */
    ${CONFIG.SELECTORS.POST_POPUP} ::-webkit-scrollbar { width: 8px; }
    ${CONFIG.SELECTORS.POST_POPUP} ::-webkit-scrollbar-track { background: transparent; }
    ${CONFIG.SELECTORS.POST_POPUP} ::-webkit-scrollbar-thumb {
      background-color: rgba(255,255,255,0.2); border-radius: 4px;
    }
    ${CONFIG.SELECTORS.POST_POPUP} ::-webkit-scrollbar-thumb:hover {
      background-color: rgba(255,255,255,0.4);
    }

    /* 已访问帖子链接样式 - 简化版本 */
    .post-link-visited {
      opacity: 0.5 !important;
      transition: opacity 0.2s ease !important;
    }

    .post-link-visited:hover {
      opacity: 0.7 !important;
    }
  `);

  // ==================== 工具函数模块 ====================
  const Utils = {
    throttle(func, limit) {
      let inThrottle = false;
      let lastFunc;
      let lastRan;
      return function() {
        const context = this;
        const args = arguments;
        if (!inThrottle) {
          func.apply(context, args);
          lastRan = Date.now();
          inThrottle = true;
        } else {
          clearTimeout(lastFunc);
          lastFunc = setTimeout(function() {
            if ((Date.now() - lastRan) >= limit) {
              func.apply(context, args);
              lastRan = Date.now();
            }
          }, limit - (Date.now() - lastRan));
        }
      }
    },

    showToast(message, isSuccess = true) {
      let toast = document.querySelector(`.${CONFIG.CSS_CLASSES.TOAST_MESSAGE}`);
      if (!toast) {
        toast = document.createElement("div");
        toast.className = CONFIG.CSS_CLASSES.TOAST_MESSAGE;
        document.body.appendChild(toast);
      }

      toast.textContent = message;
      toast.classList.remove(CONFIG.CSS_CLASSES.TOAST_ERROR);
      if (!isSuccess) {
        toast.classList.add(CONFIG.CSS_CLASSES.TOAST_ERROR);
      }
      toast.classList.add(CONFIG.CSS_CLASSES.TOAST_SHOW);

      setTimeout(() => {
        toast.classList.remove(CONFIG.CSS_CLASSES.TOAST_SHOW);
      }, CONFIG.TOAST_DURATION);
    },

    createPostOrCommentElement(data, isMainPost = false) {
      const element = document.createElement("div");
      element.className = isMainPost ? CONFIG.CSS_CLASSES.POST_BOX : CONFIG.CSS_CLASSES.COMMENT_BOX;

      const header = document.createElement("div");
      header.className = isMainPost ? "post-header" : "comment-header";

      const avatarTemplate = data.avatar_template;
      const avatar = avatarTemplate ? document.createElement("img") : null;
      if (avatar) {
        avatar.src = `https://${location.host}${avatarTemplate.replace("{size}", "45")}`;
        avatar.className = isMainPost ? "post-avatar" : "comment-avatar";
      }

      const author = document.createElement("div");
      author.className = isMainPost ? "post-author" : "comment-author";
      author.textContent = data.username || '系统';

      const meta = document.createElement("div");
      meta.className = isMainPost ? "post-meta" : "comment-meta";
      meta.textContent = new Date(data.created_at).toLocaleString();

      const floor = document.createElement("div");
      floor.className = isMainPost ? "post-floor" : "comment-floor";
      floor.textContent = `#${data.post_number}`;

      if (avatar) header.appendChild(avatar);
      header.appendChild(author);
      header.appendChild(meta);
      header.appendChild(floor);

      const content = document.createElement("div");
      content.className = isMainPost ? "post-content" : "comment-content";
      content.innerHTML = data.cooked;

      this.processLinks(content);
      this.processImages(content);

      element.appendChild(header);
      element.appendChild(content);

      return element;
    },

    processLinks(container) {
      const links = container.querySelectorAll('a');
      links.forEach(link => {
        if (!link.href.startsWith('#') && !link.href.includes('#reply_')) {
          link.setAttribute('target', '_blank');
          link.setAttribute('rel', 'noopener noreferrer');
        }
      });
    },

    processImages(container) {
      const images = container.querySelectorAll('img');
      images.forEach(img => {
        img.addEventListener('click', (e) => {
          e.preventDefault();
          e.stopPropagation();
          
          // 获取原始尺寸的图片URL
          let originalSrc = img.src;
          
          // 如果是Linux.do的图片,尝试获取原始尺寸
          if (img.src.includes(location.host)) {
            // 移除尺寸参数,获取原始图片
            originalSrc = img.src.replace(/(_\d+x\d+)/g, '').replace(/(\?.*)/g, '');
            
            // 如果有data-src属性,优先使用
            if (img.dataset.src) {
              originalSrc = img.dataset.src;
            }
            
            // 如果有data-original属性,优先使用
            if (img.dataset.original) {
              originalSrc = img.dataset.original;
            }
            
            // 检查父元素是否有链接,如果有则使用链接地址
            const parentLink = img.closest('a');
            if (parentLink && parentLink.href &&
                (parentLink.href.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i) ||
                 parentLink.href.includes('/uploads/'))) {
              originalSrc = parentLink.href;
            }
          }
          
          ImageLightbox.show(originalSrc);
        });
      });
    },

    createNestedList(allList = []) {
      const map = new Map();
      allList.forEach(item => {
        map.set(item.id, { ...item, children: [] });
      });

      const nestedList = [];
      allList.forEach(item => {
        const parentIdMatch = item.cooked.match(/<a href="#reply_(\d+)"/);
        const parentId = parentIdMatch ? parseInt(parentIdMatch[1]) : null;

        if (parentId && map.has(parentId)) {
          map.get(parentId).children.push(map.get(item.id));
        } else {
          nestedList.push(map.get(item.id));
        }
      });
      return nestedList;
    },

    getCSRFToken() {
      let token = document.querySelector('meta[name="csrf-token"]')?.content;
      if (token) return token;

      const authenticityTokenInput = document.querySelector('input[name="authenticity_token"]');
      if (authenticityTokenInput) {
        return authenticityTokenInput.value;
      }

      return null;
    }
  };

  // ==================== 图片Lightbox模块 ====================
  const ImageLightbox = {
    element: null,
    
    init() {
      if (this.element) return;
      
      this.element = document.createElement('div');
      this.element.id = 'image-lightbox-overlay';
      
      const lightboxContent = document.createElement('div');
      lightboxContent.id = 'image-lightbox-content';
      
      const lightboxImage = document.createElement('img');
      lightboxImage.className = 'lightbox-image';
      
      const closeBtn = document.createElement('button');
      closeBtn.className = 'lightbox-close-btn';
      closeBtn.innerHTML = '✕';
      closeBtn.title = '关闭 (ESC)';
      closeBtn.onclick = () => this.close();
      
      lightboxContent.appendChild(lightboxImage);
      lightboxContent.appendChild(closeBtn);
      this.element.appendChild(lightboxContent);
      
      this.element.addEventListener('click', (e) => {
        if (e.target === this.element) {
          this.close();
        }
      });
      
      lightboxContent.addEventListener('click', (e) => {
        // 只有点击图片本身时才关闭,其他区域(如关闭按钮)不关闭
        if (e.target.classList.contains('lightbox-image')) {
          this.close();
        } else {
          e.stopPropagation();
        }
      });
      
      document.body.appendChild(this.element);
      elements.imageLightbox = this.element;
    },

    show(imageSrc) {
      if (!this.element) {
        this.init();
      }
      
      const lightboxImage = this.element.querySelector('.lightbox-image');
      const lightboxContent = this.element.querySelector('#image-lightbox-content');
      
      // 显示lightbox
      this.element.style.display = 'flex';
      
      // 清除之前的内容
      lightboxImage.style.opacity = '0';
      lightboxImage.src = '';
      lightboxContent.scrollTop = 0; // 重置滚动位置到顶部
      
      // 显示加载指示器
      this.showLoadingIndicator();
      
      // 创建新图片对象来预加载
      const newImg = new Image();
      const startTime = Date.now();
      
      newImg.onload = () => {
        const loadTime = Date.now() - startTime;
        
        
        // 设置图片
        lightboxImage.src = imageSrc;
        
        // 根据图片尺寸调整显示
        this.adjustImageDisplay(newImg, lightboxImage, lightboxContent);
        
        // 隐藏加载指示器,显示图片
        this.hideLoadingIndicator();
        lightboxImage.style.opacity = '1';
      };
      
      newImg.onerror = () => {
        console.warn('原始图片加载失败,使用备用图片:', imageSrc);
        lightboxImage.src = imageSrc;
        this.hideLoadingIndicator();
        lightboxImage.style.opacity = '1';
      };
      
      newImg.src = imageSrc;
      
      if (!elements.postPopup || elements.postPopup.style.display !== 'flex') {
        document.addEventListener("keydown", PopupManager.handleEscapeKey);
      }
    },

    adjustImageDisplay(newImg, lightboxImage, lightboxContent) {
      const imgWidth = newImg.naturalWidth;
      const imgHeight = newImg.naturalHeight;
      const viewportWidth = window.innerWidth * 0.95;
      const viewportHeight = window.innerHeight * 0.95;
      const aspectRatio = imgWidth / imgHeight;
      
      // 重置样式
      lightboxImage.style.width = 'auto';
      lightboxImage.style.height = 'auto';
      lightboxImage.style.maxWidth = '100%';
      lightboxImage.style.maxHeight = 'none';
      
      // 如果图片很宽,需要限制宽度并居中
      if (imgWidth > viewportWidth) {
        lightboxImage.style.maxWidth = '100%';
        lightboxImage.style.width = '100%';
        lightboxImage.style.height = 'auto';
      }
      
      // 如果图片很高,允许滚动
      if (imgHeight > viewportHeight) {
        lightboxContent.style.alignItems = 'flex-start';
        lightboxContent.style.paddingTop = '20px';
        lightboxContent.style.paddingBottom = '20px';
      } else {
        lightboxContent.style.alignItems = 'center';
        lightboxContent.style.paddingTop = '0';
        lightboxContent.style.paddingBottom = '0';
      }
      
      
    },

    showLoadingIndicator() {
      let loadingDiv = this.element.querySelector('.lightbox-loading');
      if (!loadingDiv) {
        loadingDiv = document.createElement('div');
        loadingDiv.className = 'lightbox-loading';
        loadingDiv.innerHTML = `
          <div class="lightbox-loading-spinner"></div>
          <div>正在加载高清图片...</div>
          <div style="font-size: 12px; margin-top: 8px; opacity: 0.7;">请稍候</div>
        `;
        this.element.appendChild(loadingDiv);
      }
      loadingDiv.style.display = 'block';
    },

    hideLoadingIndicator() {
      const loadingDiv = this.element.querySelector('.lightbox-loading');
      if (loadingDiv) {
        loadingDiv.style.display = 'none';
      }
    },

    close() {
      if (this.element) {
        this.element.style.display = 'none';
        
        // 清理状态
        const lightboxImage = this.element.querySelector('.lightbox-image');
        const lightboxContent = this.element.querySelector('#image-lightbox-content');
        
        if (lightboxImage) {
          lightboxImage.src = '';
          lightboxImage.style.opacity = '0';
        }
        
        if (lightboxContent) {
          lightboxContent.scrollTop = 0;
        }
        
        this.hideLoadingIndicator();
      }
    }
  };

  // ==================== 评论编辑器模块 ====================
  const CommentEditor = {
    container: null,
    textarea: null,
    submitBtn: null,
    closeBtn: null,
    topicId: null,

    init(topicId, parentContainer) {
      this.topicId = topicId;
      
      this.container = document.createElement("div");
      this.container.className = 'sticky-comment-editor';
      
      const editorWrapper = document.createElement("div");
      editorWrapper.className = "post-editor-wrapper";
      editorWrapper.innerHTML = `
        <textarea placeholder="发表你的评论..."></textarea>
        <div class="toolbar">
          <button class="close-editor-btn">关闭</button>
          <button class="submit-comment-btn">回复</button>
        </div>
      `;
      
      this.container.appendChild(editorWrapper);
      parentContainer.appendChild(this.container);
      
      this.textarea = editorWrapper.querySelector("textarea");
      this.submitBtn = editorWrapper.querySelector(".submit-comment-btn");
      this.closeBtn = editorWrapper.querySelector(".close-editor-btn");
      
      this.attachEvents();
    },

    attachEvents() {
      this.submitBtn.onclick = () => {
        const content = this.textarea.value.trim();
        if (content.length < 4) {
          Utils.showToast("评论内容至少需要4个字符!", false);
          return;
        }
        
        if (content) {
          const replyMatch = content.match(/^@\w+\s*#(\d+)\s*/);
          let replyToPostId = null;
          let actualContent = content;

          if (replyMatch) {
            replyToPostId = parseInt(replyMatch[1]);
            actualContent = content.substring(replyMatch[0].length).trim();
          }
          
          this.submitComment(this.topicId, actualContent, replyToPostId);
        } else {
          Utils.showToast("评论内容不能为空!", false);
        }
      };

      this.closeBtn.onclick = () => {
        this.hide();
        this.clear();
      };
    },

    setValue(content) {
      if (this.textarea) {
        this.textarea.value = content;
      }
    },

    clear() {
      if (this.textarea) {
        this.textarea.value = "";
      }
    },

    show() {
      if (this.container) {
        this.container.classList.add("active");
        this.textarea.focus();
        this.container.scrollIntoView({ behavior: "smooth", block: "end" });
      }
    },

    hide() {
      if (this.container) {
        this.container.classList.remove("active");
      }
    },

    async submitComment(topicId, content, replyToPostId = null) {
      const csrfToken = Utils.getCSRFToken();
      if (!csrfToken) {
        Utils.showToast("无法获取安全令牌,请刷新页面重试。", false);
        return;
      }

      this.submitBtn.disabled = true;
      this.submitBtn.textContent = "提交中...";

      const formData = new FormData();
      formData.append("raw", content);
      formData.append("topic_id", topicId);
      if (replyToPostId) {
        formData.append("reply_to_post_number", replyToPostId);
      }
      formData.append("archetype", "reply");
      formData.append("authenticity_token", csrfToken);

      try {
        const response = await fetch(CONFIG.API_ENDPOINTS.SUBMIT_POST, {
          method: "POST",
          headers: {
            "Accept": "application/json",
            "X-CSRF-Token": csrfToken,
            "X-Requested-With": "XMLHttpRequest"
          },
          body: formData
        });

        const result = await response.json();

        if (response.ok) {
          Utils.showToast("评论提交成功!", true);
          this.hide();
          this.clear();

          const contentContainer = elements.postPopup.querySelector(CONFIG.SELECTORS.CONTENT_CONTAINER);
          if (contentContainer && this.container && result) {
            const newCommentElement = PostRenderer.renderComment(result);
            contentContainer.insertBefore(newCommentElement, this.container);
            newCommentElement.scrollIntoView({ behavior: "smooth", block: "end" });
          }
        } else {
          Utils.showToast(`评论提交失败: ${result.errors ? result.errors.join(", ") : response.statusText}`, false);
        }
      } catch (error) {
        console.error('提交评论错误:', error);
        Utils.showToast("提交评论时发生网络错误。", false);
      } finally {
        this.submitBtn.disabled = false;
        this.submitBtn.textContent = "回复";
      }
    }
  };

  // ==================== 帖子渲染模块 ====================
  const PostRenderer = {
    handleReplyButtonClick(data) {
      const editor = PopupManager.getCommentEditor();
      if (editor) {
        editor.setValue(`@${data.username} #${data.post_number} `);
        editor.show();
      }
    },

    renderComment(comment, depth = 0) {
      const commentElement = Utils.createPostOrCommentElement(comment, false);
      commentElement.style.marginLeft = `${depth * 20}px`;

      // 添加阅读追踪支持
      if (comment.post_number && comment.id) {
        commentElement.setAttribute('data-post-number', comment.post_number);
        commentElement.setAttribute('data-post-id', comment.id);
        
        // 验证是否在缓存中,然后才注册追踪
        const isInCache = state.postDataCache.some(post =>
          post.post_number === comment.post_number
        );
        
        if (isInCache && ReadingTracker.isActive) {
          ReadingTracker.observe(commentElement);
        }
      }

      const actions = document.createElement("div");
      actions.className = "comment-actions";
      const replyButton = document.createElement("button");
      replyButton.textContent = "回复";
      replyButton.onclick = () => this.handleReplyButtonClick(comment);
      actions.appendChild(replyButton);
      commentElement.appendChild(actions);

      if (comment.children && comment.children.length > 0) {
        comment.children.forEach(child => {
          commentElement.appendChild(this.renderComment(child, depth + 1));
        });
      }
      return commentElement;
    },

    renderInitialPosts(posts, title, contentContainer) {
      if (posts.length > 0) {
        const titleElement = document.createElement("h2");
        titleElement.textContent = title;
        titleElement.style.cssText = "font-weight: bold; font-size: 24px; margin-bottom: 20px; color: #eee;";
        contentContainer.appendChild(titleElement);

        const mainPost = posts[0];
        const postElement = Utils.createPostOrCommentElement(mainPost, true);

        // 为主帖添加阅读追踪支持
        if (mainPost.post_number && mainPost.id) {
          postElement.setAttribute('data-post-number', mainPost.post_number);
          postElement.setAttribute('data-post-id', mainPost.id);
          
          if (ReadingTracker.isActive) {
            ReadingTracker.observe(postElement);
          }
        }

        const actions = document.createElement("div");
        actions.className = "comment-actions";
        const replyButton = document.createElement("button");
        replyButton.textContent = "回复";
        replyButton.onclick = () => this.handleReplyButtonClick(mainPost);
        actions.appendChild(replyButton);
        postElement.appendChild(actions);

        contentContainer.appendChild(postElement);
      }

      if (posts.length > 1) {
        const comments = posts.slice(1);
        const nestedComments = Utils.createNestedList(comments);

        nestedComments.forEach(comment => {
          contentContainer.appendChild(this.renderComment(comment));
        });
      }
    }
  };

  // ==================== 弹窗管理器模块 ====================
  const PopupManager = {
    commentEditor: null,

    init() {
      this.createPopupContainer();
      this.createButtons();
      this.bindEvents();
    },

    createPopupContainer() {
      elements.postPopup = document.createElement("div");
      elements.postPopup.id = 'post-popup';
      document.body.appendChild(elements.postPopup);
    },

    createButtons() {
      elements.backToTopBtn = document.createElement("button");
      elements.backToTopBtn.id = "back-to-top-btn";
      elements.backToTopBtn.className = "floating-btn";
      elements.backToTopBtn.innerHTML = "↑";
      elements.backToTopBtn.title = "回到顶部";
      elements.backToTopBtn.onclick = () => {
        const contentWrapper = elements.postPopup.querySelector('.post-popup-content-wrapper');
        if (contentWrapper) {
          contentWrapper.scrollTo({ top: 0, behavior: "smooth" });
        }
      };
      elements.postPopup.appendChild(elements.backToTopBtn);

      elements.openFullBtn = document.createElement("button");
      elements.openFullBtn.id = "open-full-btn";
      elements.openFullBtn.className = "open-full-btn";
      elements.openFullBtn.innerHTML = "↗";
      elements.openFullBtn.title = "在新标签页打开";
      elements.openFullBtn.onclick = () => {
        if (state.currentSlug && state.currentTopicId) {
          window.open(`/t/${state.currentSlug}/${state.currentTopicId}`, "_blank");
        }
      };
    },

    bindEvents() {
      document.addEventListener("click", (e) => {
        const link = e.target.closest(CONFIG.SELECTORS.POST_LINKS);
        if (link && link.href.includes('/t/')) {
          if (e.button === 1 || e.metaKey || e.ctrlKey) return;
          e.preventDefault();
          const match = link.href.match(/\/t\/([^/]+)\/(\d+)/);
          if (match) {
            state.currentSlug = match[1];
            state.currentTopicId = match[2];
            this.open(state.currentTopicId, state.currentSlug);
          }
        }
      }, true);

      elements.postPopup.addEventListener("click", (e) => {
        if (e.target === elements.postPopup) {
          this.close();
        }
      });
    },

    handleEscapeKey(e) {
      if (e.key === "Escape") {
        if (elements.imageLightbox && elements.imageLightbox.style.display === "flex") {
          ImageLightbox.close();
        } else if (elements.postPopup && elements.postPopup.style.display === "flex") {
          PopupManager.close();
        }
      }
    },

    async open(topicId, slug) {
      if (!elements.postPopup) return;

      // 重置加载状态
      state.isLoadingMore = false;
      state.nextPostIds = [];
      state.postDataCache = [];

      // 初始化阅读追踪器
      ReadingTracker.init(topicId);

      // 标记帖子为已访问
      VisitedTracker.markPostAsVisited(topicId);

      elements.postPopup.innerHTML = `<div class="post-popup-content-wrapper" style="padding:16px;">⏳ 正在加载...</div>`;
      elements.postPopup.style.display = "flex";
      document.body.classList.add(CONFIG.CSS_CLASSES.MODAL_OPEN);
      document.addEventListener("keydown", this.handleEscapeKey);

      try {
        const response = await fetch(CONFIG.API_ENDPOINTS.TOPIC.replace('{slug}', slug).replace('{id}', topicId));
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        const data = await response.json();
        state.nextPostIds = data.post_stream.stream.slice(data.post_stream.posts.length);
        state.postDataCache = data.post_stream.posts.slice();

        this.renderPopupContent(data);
      } catch (error) {
        console.error('加载帖子失败:', error);
        elements.postPopup.innerHTML = `<div class="post-popup-content-wrapper" style="padding:16px;">⚠️ 加载失败: ${error.message}</div>`;
      }
    },

    renderPopupContent(data) {
      elements.postPopup.innerHTML = '';

      const contentWrapper = document.createElement("div");
      contentWrapper.className = 'post-popup-content-wrapper';

      const closeBtn = document.createElement("button");
      closeBtn.className = "close-btn";
      closeBtn.innerHTML = "✕";
      closeBtn.title = "关闭 (ESC)";
      closeBtn.onclick = () => this.close();

      const buttonsContainer = document.createElement("div");
      buttonsContainer.className = "post-popup-buttons";
      buttonsContainer.appendChild(closeBtn);
      buttonsContainer.appendChild(elements.openFullBtn);

      const contentContainer = document.createElement("div");
      contentContainer.className = 'post-content-container';

      contentWrapper.appendChild(buttonsContainer);
      contentWrapper.appendChild(contentContainer);
      contentWrapper.appendChild(elements.backToTopBtn);
      elements.postPopup.appendChild(contentWrapper);

      PostRenderer.renderInitialPosts(state.postDataCache, data.title, contentContainer);

      this.commentEditor = Object.create(CommentEditor);
      this.commentEditor.init(state.currentTopicId, contentContainer);

      contentWrapper.addEventListener("scroll", Utils.throttle(() => {
        const scrollTop = contentWrapper.scrollTop;
        const clientHeight = contentWrapper.clientHeight;
        const scrollHeight = contentWrapper.scrollHeight;
        const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
        
        if (distanceFromBottom <= CONFIG.SCROLL_TRIGGER_DISTANCE &&
            state.nextPostIds.length > 0 &&
            !state.isLoadingMore) {
          this.loadMorePosts(state.currentTopicId, state.nextPostIds, contentContainer, this.commentEditor.container);
        }
      }, CONFIG.THROTTLE_DELAY));
    },

    async loadMorePosts(topicId, nextIds, contentContainer, editorContainer) {
      if (nextIds.length === 0 || state.isLoadingMore) return;

      state.isLoadingMore = true;

      // 清理已存在的加载指示器
      let existingLoadingIndicator = contentContainer.querySelector(`.${CONFIG.CSS_CLASSES.LOADING_INDICATOR}`);
      if (existingLoadingIndicator) {
        contentContainer.removeChild(existingLoadingIndicator);
      }

      const idsToLoad = nextIds.splice(0, CONFIG.PAGE_SIZE);
      const query = idsToLoad.map(i => `post_ids[]=${i}`).join("&");

      let loadingIndicator = document.createElement("div");
      loadingIndicator.className = CONFIG.CSS_CLASSES.LOADING_INDICATOR;
      loadingIndicator.textContent = "正在加载更多内容...";
      loadingIndicator.style.cssText = "text-align: center; padding: 15px; color: #66aaff; font-size: 14px;";
      contentContainer.appendChild(loadingIndicator);

      try {
        const startTime = Date.now();
        const response = await fetch(CONFIG.API_ENDPOINTS.POSTS.replace('{topicId}', topicId) + `?${query}`);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        const result = await response.json();
        const loadTime = Date.now() - startTime;

        // 确保最小加载时间,避免闪烁
        const minDelay = Math.max(0, CONFIG.LOADING_DELAY - loadTime);
        
        setTimeout(() => {
          if (contentContainer.contains(loadingIndicator)) {
            contentContainer.removeChild(loadingIndicator);
          }

          // 批量添加新内容
          const fragment = document.createDocumentFragment();
          result.post_stream.posts.forEach(post => {
            state.postDataCache.push(post);
            const commentElement = PostRenderer.renderComment(post);
            fragment.appendChild(commentElement);
            
            // 为新加载的评论注册阅读追踪
            if (ReadingTracker.isActive && post.post_number) {
              // 由于评论还在 fragment 中,需要在插入 DOM 后再注册观察
              setTimeout(() => {
                if (document.contains(commentElement)) {
                  ReadingTracker.observe(commentElement);
                  
                }
              }, 10);
            }
          });
          
          contentContainer.insertBefore(fragment, editorContainer);
          
          state.isLoadingMore = false;
          
          // 如果还有更多内容且接近底部,继续加载
          const contentWrapper = contentContainer.closest('.post-popup-content-wrapper');
          if (contentWrapper && nextIds.length > 0) {
            const distanceFromBottom = contentWrapper.scrollHeight - (contentWrapper.scrollTop + contentWrapper.clientHeight);
            if (distanceFromBottom <= CONFIG.SCROLL_TRIGGER_DISTANCE * 2) {
              setTimeout(() => {
                this.loadMorePosts(topicId, nextIds, contentContainer, editorContainer);
              }, 100);
            }
          }
        }, minDelay);

      } catch (error) {
        console.error('加载更多帖子失败:', error);
        state.isLoadingMore = false;
        
        loadingIndicator.textContent = "加载失败,点击重试";
        loadingIndicator.style.cssText += " cursor: pointer; background: #3a3a3a; border-radius: 4px; margin: 10px 0;";
        loadingIndicator.onclick = () => {
          nextIds.unshift(...idsToLoad);
          state.isLoadingMore = false;
          this.loadMorePosts(topicId, nextIds, contentContainer, editorContainer);
        };
      }
    },

    close() {
      if (elements.postPopup) {
        // 立即停止阅读追踪
        ReadingTracker.stop();
        
        elements.postPopup.style.display = "none";
        document.body.classList.remove(CONFIG.CSS_CLASSES.MODAL_OPEN);
        document.removeEventListener("keydown", this.handleEscapeKey);
        
        // 延迟清理,确保数据提交完成
        setTimeout(() => {
          ReadingTracker.cleanup();
        }, 100);
        
        // 重置状态
        state.isLoadingMore = false;
        this.commentEditor = null;
      }
    },

    getCommentEditor() {
      return this.commentEditor;
    }
  };

  // ==================== 主初始化函数 ====================
  function initApp() {
    if (window.self !== window.top || location.pathname.match(/^\/t\//)) {
      return;
    }

    PopupManager.init();
    ImageLightbox.init();
    VisitedTracker.init();
    
    // 页面卸载时清理追踪器
    window.addEventListener('beforeunload', () => {
      ReadingTracker.cleanup();
    });
    
    
  }

  // ==================== 向后兼容函数 ====================
  window.showToast = Utils.showToast;
  window.showImageLightbox = ImageLightbox.show;
  window.closeImageLightbox = ImageLightbox.close;
  
  

  // ==================== 应用启动 ====================
  window.addEventListener("DOMContentLoaded", initApp);

})();

6 Likes

目前两个比较明显的问题:

  • 如果打开话题的话页面某些元素不见了

  • 如果选在弹窗看的话,浏览帖子不会计数


最后,弹窗预览很不错喔 :tieba_024::+1: :+1: :+1:

我是暗黑模式。。所以代码也默认暗黑模式。。你这个我没考虑,再问问ai吧

我刚刚使用了一下,点击某个帖子以后,会打开新窗口,确认了,linuxdo的插件导致的

弹窗预览确实爽,就是计数有点小bug

感谢大佬

针对楼上提出的问题,加上我自己也是这么觉得的。做了新的修改

0.2.0版
添加了阅读追踪,再也不用担心浏览过的帖子系统不知道了。对于升级保级有帮助。
添加了已访问标记,点击过的帖子变灰,浏览器会保留记录,刷新页面也存在。

ps依旧没做黑白模式适配。。我一直夜晚模式,懒