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