// ==UserScript== // @name S1 Plus - Stage1st 体验增强套件 // @namespace http://tampermonkey.net/ // @version 5.1.0 // @description 为Stage1st论坛提供帖子/用户屏蔽、导航栏自定义、自动签到、阅读进度跟踪、回复收藏、远程同步等多种功能,全方位优化你的论坛体验。 // @author moekyo // @match https://stage1st.com/2b/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_openInTab // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; const SCRIPT_VERSION = '5.1.0'; const SCRIPT_RELEASE_DATE = '2025-09-06'; // --- [新增] SHA-256 哈希计算库 (基于 Web Crypto API) --- /** * 计算字符串的 SHA-256 哈希值。 * @param {string} message - 要计算哈希的字符串。 * @returns {Promise} 64个字符的十六进制哈希字符串。 */ const sha256 = async (message) => { // 将消息编码为 Uint8Array const msgUint8 = new TextEncoder().encode(message); // 使用 subtle.digest 进行哈希计算 const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // 将 ArrayBuffer 转换为字节数组 const hashArray = Array.from(new Uint8Array(hashBuffer)); // 将字节数组转换为十六进制字符串 return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); }; // --- [新增] 确定性JSON序列化与哈希计算辅助函数 --- /** * 对对象进行深度排序,确保键的顺序一致,以便生成稳定的哈希值。 * @param {any} obj 要排序的对象。 * @returns {any} 键已排序的对象。 */ const deterministicSort = (obj) => { if (typeof obj !== 'object' || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(deterministicSort); } const sortedKeys = Object.keys(obj).sort(); const newObj = {}; for (const key of sortedKeys) { newObj[key] = deterministicSort(obj[key]); } return newObj; }; // --- [新增] 全局状态标志,用于防止启动时的同步竞态条件 --- let isInitialSyncInProgress = false; /** * 计算数据对象的 SHA-256 哈希值。 * @param {object} dataObject - 要计算哈希的数据对象 (即 `data` 字段的内容)。 * @returns {Promise} 计算出的哈希值。 */ const calculateDataHash = async (dataObject) => { // 1. 深度排序对象键,确保序列化结果的确定性 const sortedData = deterministicSort(dataObject); // 2. 序列化为JSON字符串 const stringifiedData = JSON.stringify(sortedData); // 3. 计算SHA-256哈希 return await sha256(stringifiedData); }; GM_addStyle(` /* --- 通用颜色 --- */ :root { /* -- 基础调色板 -- */ --s1p-bg: #ECEDEB; --s1p-pri: #D1D9C1; --s1p-sub: #e9ebe8; --s1p-white: #ffffff; --s1p-black-rgb: 0, 0, 0; /* -- 主题色 -- */ --s1p-t: #022C80; --s1p-desc-t: #10388a; --s1p-sec: #2563eb; --s1p-sec-h: #306bebff; --s1p-sub-h: #2563eb; --s1p-sub-h-t: var(--s1p-white); /* -- 状态色 -- */ --s1p-red: #ef4444; --s1p-red-h: #dc2626; --s1p-green: #22c55e; --s1p-success-bg: #d1fae5; --s1p-success-text: #065f46; --s1p-error-bg: #fee2e2; /* -- 组件专属 -- */ --s1p-text-empty: #888; --s1p-icon-color: #a1a1aa; --s1p-icon-close: #9ca3af; --s1p-icon-arrow: #6b7280; --s1p-confirm-hover-bg: #27da80; --s1p-cancel-hover-bg: #ff6464; --s1p-secondary-bg: #e5e7eb; --s1p-secondary-text: #374151; --s1p-code-bg: #eee; /* -- 阅读进度 -- */ --s1p-progress-hot: rgb(192, 51, 34); --s1p-progress-cold: rgb(107, 114, 128); } /* --- [FIX] 导航栏垂直居中对齐修正 --- */ #nv > ul { display: flex !important; align-items: center !important; } /* --- [MODIFIED] 手动同步导航按钮 --- */ /* 1. 确保导航栏的 ul 容器使用 Flexbox 并垂直居中所有 li 元素 */ #nv > ul { display: flex !important; align-items: center !important; } /* 2. 针对包含SVG图标的 a 链接,让它也成为一个Flex容器 */ #s1p-nav-sync-btn a { display: flex; /* 将 a 标签设置为 flex 容器 */ align-items: center; /* 垂直居中内部的 SVG 图标 */ justify-content: center; /* 水平居中内部的 SVG 图标 */ height: 100%; /* 让 a 标签的高度撑满 li 元素 */ vertical-align: middle; /* 尝试通过这个属性来改善对齐 */ } #s1p-nav-sync-btn svg { width: 16px; /* <-- 已修改 */ height: 16px; /* <-- 已修改 */ color: var(--s1p-t); transition: color 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 在这个 svg 标签的样式里增加两行 */ position: relative; top: 1.5px; /* 将图标向下移动 1.5 个像素 */ } #s1p-nav-sync-btn a:hover svg { color: var(--s1p-t); transform: scale(1.1); } /* --- [MODIFIED] 最终简化版同步动画 (只有旋转) --- */ @keyframes s1p-sync-simple-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } #s1p-nav-sync-btn svg.s1p-syncing { animation: s1p-sync-simple-rotate 0.8s linear infinite; pointer-events: none; } #s1p-nav-sync-btn svg.s1p-sync-success { /* 移除了所有成功状态的视觉效果 */ } #s1p-nav-sync-btn svg.s1p-sync-error { /* 移除了所有失败状态的视觉效果 */ } /* --- 手动同步弹窗样式 --- */ .s1p-sync-choice-info { background-color: var(--s1p-sub); border: 1px solid var(--s1p-pri); border-radius: 6px; padding: 12px; margin-top: 12px; font-size: 13px; line-height: 1.7; } .s1p-sync-choice-info-row { display: flex; justify-content: space-between; align-items: center; } .s1p-sync-choice-info-label { font-weight: 500; color: var(--s1p-t); } .s1p-sync-choice-info-time { font-family: monospace, sans-serif; } .s1p-sync-choice-newer { color: var(--s1p-success-text); font-weight: bold; } /* --- 提示框样式 --- */ .s1p-notice { display: flex; align-items: flex-start; gap: 12px; background-color: var(--s1p-sub); border: 1px solid var(--s1p-pri); border-radius: 6px; padding: 12px; margin-top: 12px; } .s1p-notice-icon { flex-shrink: 0; width: 20px; height: 20px; background-color: var(--s1p-t); mask-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3e%3cpath d='M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z'%3e%3c/path%3e%3c/svg%3e"); mask-size: contain; mask-position: center; mask-repeat: no-repeat; margin-top: 1px; } .s1p-notice-content { font-size: 13px; line-height: 1.6; color: var(--s1p-desc-t); } .s1p-notice-content a { color: var(--s1p-t); font-weight: 500; text-decoration: none; } .s1p-notice-content a:hover { text-decoration: underline; } .s1p-notice-content p { margin: 4px 0 0 0; padding: 0; } /* --- 滑块式分段控件样式 --- */ .s1p-segmented-control { position: relative; display: inline-flex; background-color: var(--s1p-sub); border-radius: 6px; padding: 2px; user-select: none; } .s1p-segmented-control-slider { position: absolute; top: 2px; height: calc(100% - 4px); background-color: var(--s1p-sec); border-radius: 5px; box-shadow: 0 1px 3px rgba(var(--s1p-black-rgb), 0.1); transition: all 0.25s ease-in-out; } .s1p-segmented-control:hover .s1p-segmented-control-slider { box-shadow: 0 2px 6px rgba(var(--s1p-black-rgb), 0.15); } .s1p-segmented-control-option { position: relative; z-index: 1; padding: 4px 12px; color: var(--s1p-desc-t); cursor: pointer; transition: color 0.25s ease-in-out, background-color 0.2s ease-in-out; font-size: 13px; line-height: 1.5; white-space: nowrap; border-radius: 4px; } .s1p-segmented-control-option.active { color: var(--s1p-white); font-weight: 500; cursor: default; } .s1p-segmented-control-option:not(.active):hover { background-color: var(--s1p-pri); color: var(--s1p-t); } /* --- 核心修复与通用布局 --- */ #p_pop { display: none !important; } #threadlisttableid td.icn { padding-left: 2px !important; } /* --- 关键字屏蔽样式 --- */ .s1p-hidden-by-keyword, .s1p-hidden-by-quote { display: none !important; } /* --- 按钮通用样式 --- */ .s1p-btn { display: inline-flex; align-items: center; justify-content: center; padding: 5px 10px 5px 12px; border-radius: 4px; background-color: var(--s1p-sub); color: var(--s1p-t); font-size: 14px; font-weight: bold; cursor: pointer; user-select: none; white-space: nowrap; border: 1px solid var(--s1p-pri); transition: all 0.2s ease-in-out;} .s1p-btn:hover { background-color: var(--s1p-sub-h); color: var(--s1p-sub-h-t); border-color: var(--s1p-sub-h); } .s1p-red-btn { background-color: var(--s1p-red); color: var(--s1p-white); border-color: var(--s1p-red); } .s1p-red-btn:hover { background-color: var(--s1p-red-h); border-color: var(--s1p-red-h); } .s1p-btn.s1p-danger:hover { background-color: var(--s1p-red-h); border-color: var(--s1p-red-h); color: var(--s1p-white); } /* --- 帖子操作按钮 (三点图标) --- */ .s1p-options-cell { position: relative; width: 14px; padding: 0 !important; text-align: center; vertical-align: middle; } .s1p-options-cell::after { content: ''; position: absolute; top: 0; left: 100%; width: 6px; height: 100%; } .s1p-options-btn { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 24px; border-radius: 4px; cursor: pointer; color: var(--s1p-icon-color); opacity: 0.4; transition: background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease; } .s1p-options-cell:hover .s1p-options-btn { background-color: var(--s1p-pri); color: var(--s1p-t); opacity: 1; } .s1p-options-menu { position: absolute; top: 50%; left: 100%; margin-left: 6px; transform: translateY(-50%); z-index: 10; background-color: var(--s1p-bg); border: 1px solid var(--s1p-pri); border-radius: 8px; box-shadow: 0 4px 12px rgba(var(--s1p-black-rgb), 0.1); padding: 5px; min-width: 110px; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 0.15s ease-out, visibility 0.15s; } .s1p-options-cell:hover .s1p-options-menu { opacity: 1; visibility: visible; pointer-events: auto; } /* --- 直接确认UI --- */ .s1p-direct-confirm { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 14px; color: var(--s1p-t); padding: 2px 6px; white-space: nowrap; } .s1p-confirm-separator { border-left: 1px solid var(--s1p-pri); height: 20px; margin: 0 2px 0 8px; } .s1p-confirm-action-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; border-radius: 50%; cursor: pointer; transition: background-color 0.2s ease, transform 0.1s ease, background-image 0.2s ease; background-repeat: no-repeat; background-position: center; background-size: 60%; flex-shrink: 0; } .s1p-confirm-action-btn:active { transform: scale(0.95); } .s1p-confirm-action-btn.s1p-confirm { background-color: transparent; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2.5' stroke='%2322c55e'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M4.5 12.75l6 6 9-13.5' /%3e%3c/svg%3e"); } .s1p-confirm-action-btn.s1p-confirm:hover { background-color: var(--s1p-confirm-hover-bg); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M4.5 12.75l6 6 9-13.5' /%3e%3c/svg%3e"); } .s1p-confirm-action-btn.s1p-cancel { background-color: transparent; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2.5' stroke='%23ef4444'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M6 18L18 6M6 6l12 12' /%3e%3c/svg%3e"); } .s1p-confirm-action-btn.s1p-cancel:hover { background-color: var(--s1p-cancel-hover-bg); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M6 18L18 6M6 6l12 12' /%3e%3c/svg%3e"); } /* --- 行内确认菜单样式 (带动画) --- */ .s1p-inline-confirm-menu { transform: translateY(0) !important; margin-left: 0 !important; z-index: 10004; opacity: 0; transform: translateX(-8px) scale(0.95) !important; transition: opacity 0.15s ease-out, transform 0.15s ease-out; pointer-events: none; visibility: visible !important; } .s1p-inline-confirm-menu.visible { opacity: 1; transform: translateX(0) scale(1) !important; pointer-events: auto; } /* --- 阅读进度UI样式 --- */ .s1p-progress-container { display: inline-flex; align-items: center; margin: 0 8px; vertical-align: middle; line-height: 1; } .s1p-progress-jump-btn { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: bold; text-decoration: none; border: 1px solid; border-radius: 4px; padding: 1px 6px 1px 4px; transition: all 0.2s ease-in-out; line-height: 1.4; } .s1p-progress-jump-btn::before { content: ''; display: inline-block; width: 1.1em; height: 1.1em; background-color: currentColor; mask-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3e%3cpath d='M19 15l-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9l6 6z' fill='black'/%3e%3c/svg%3e"); mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .s1p-new-replies-badge { display: inline-block; color: var(--s1p-white); font-size: 12px; font-weight: bold; padding: 1px 5px; border: 1px solid; border-left: none; border-radius: 0 4px 4px 0; line-height: 1.4; user-select: none; } /* --- 通用输入框样式 --- */ .s1p-input { width: 100%; background: var(--s1p-bg); border: 1px solid var(--s1p-pri); border-radius: 6px; padding: 8px 12px; font-size: 14px; box-sizing: border-box; transition: border-color 0.2s ease-in-out, background-color 0.2s ease-in-out; color: var(--s1p-t); } .s1p-input:focus { outline: none; border-color: var(--s1p-sec); background-color: var(--s1p-white); } .s1p-textarea { resize: vertical; min-height: 80px; } /* --- 用户标记悬浮窗 --- */ .s1p-tag-popover { position: absolute; z-index: 10001; width: 300px; background-color: var(--s1p-bg); border-radius: 12px; box-shadow: 0 4px 20px rgba(var(--s1p-black-rgb), 0.08); border: 1px solid var(--s1p-pri); opacity: 0; visibility: hidden; transform: translateY(5px) scale(0.98); transition: opacity 0.2s ease-out, transform 0.2s ease-out, visibility 0.2s; pointer-events: none; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .s1p-tag-popover.visible { opacity: 1; visibility: visible; transform: translateY(0) scale(1); pointer-events: auto; } .s1p-popover-content { padding: 16px; } .s1p-popover-main-content { font-size: 14px; line-height: 1.6; color: var(--s1p-t); padding: 4px 4px 20px 4px; min-height: 30px; word-wrap: break-word; white-space: pre-wrap; } .s1p-popover-main-content.s1p-empty { text-align: center; color: var(--s1p-text-empty); } .s1p-popover-hr { border: none; border-top: 1px solid var(--s1p-pri); margin: 0; } .s1p-popover-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 16px; } .s1p-popover-user-container { display: flex; align-items: center; gap: 10px; min-width: 0; } .s1p-popover-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background-color: var(--s1p-pri); } .s1p-popover-user-info { flex-grow: 1; min-width: 0; } .s1p-popover-username { font-weight: 500; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .s1p-popover-user-id { font-size: 12px; color: var(--s1p-desc-t); white-space: nowrap; } .s1p-popover-actions { display: flex; gap: 8px; flex-shrink: 0; } .s1p-edit-mode-header { font-weight: 600; font-size: 15px; margin-bottom: 12px; } .s1p-edit-mode-textarea { height: 90px; margin-bottom: 12px; } .s1p-edit-mode-actions { display: flex; justify-content: flex-end; gap: 8px; } /* --- [NEW] 通用显示悬浮窗 --- */ .s1p-generic-display-popover { position: absolute; z-index: 10003; max-width: 350px; background-color: var(--s1p-bg); border-radius: 8px; box-shadow: 0 2px 10px rgba(var(--s1p-black-rgb), 0.12); border: 1px solid var(--s1p-pri); padding: 10px 14px; font-size: 13px; line-height: 1.6; color: var(--s1p-t); opacity: 0; visibility: hidden; transform: translateY(5px); transition: opacity 0.15s ease-out, transform 0.15s ease-out; pointer-events: none; white-space: pre-wrap; word-wrap: break-word; } .s1p-generic-display-popover.visible { opacity: 1; visibility: visible; transform: translateY(0); } /* --- [ULTIMATE FIX V2.1] Flexbox Layout Fix --- */ .pi { display: flex !important; align-items: center; gap: 8px; } .pi > .pti { flex-grow: 1; min-width: 0; position: static !important; z-index: auto !important; order: 1; /* [FIX] Set as the first item in the visual order */ overflow: hidden; /* <-- [最终修复] 阻止容器越界重叠 */ } .pi > strong { flex-shrink: 0; /* Prevent "楼主" or floor number from being squished */ order: 2; /* [FIX] Set as the second item in the visual order */ } .pi > #fj { margin-left: auto; order: 3; flex-shrink: 0; position: static !important; z-index: auto !important; } .authi { display: flex !important; align-items: center; flex-wrap: nowrap; overflow: hidden; white-space: nowrap !important; } /* --- [FINAL FIX v2] Corrected CSS Selector Specificity --- */ /* 1. 最外层容器: flex布局,这是基础 */ .s1p-authi-container { display: flex; align-items: center; min-width: 0; } /* 2. 原生按钮容器: 绝对不允许被压缩 */ .s1p-authi-container > .authi { flex-shrink: 0; white-space: nowrap; } /* 3. 脚本按钮总容器: 作为被压缩的主要对象,内部强制不换行 */ .s1p-authi-container > .s1p-authi-actions-wrapper { display: flex; align-items: center; flex-shrink: 1; min-width: 0; flex-wrap: nowrap; } /* 4. [已修正冲突] 脚本容器 *内部* 元素的精确规则: */ /* a) 用户标记容器(.s1p-user-tag-container): 这是唯一允许被压缩的元素 */ .s1p-authi-actions-wrapper > .s1p-user-tag-container { flex-shrink: 1; min-width: 30px; } /* b) 其他所有按钮()和分隔符(): 绝对不允许被压缩 */ .s1p-authi-actions-wrapper > a.s1p-authi-action, .s1p-authi-actions-wrapper > span.pipe { flex-shrink: 0; } .s1p-authi-actions-wrapper { display: inline-flex; align-items: center; min-width: 0; vertical-align: middle; } .s1p-user-tag-container { display: inline-flex; align-items: center; flex-shrink: 1; min-width: 30px; vertical-align: middle; overflow: hidden; border-radius: 6px; } .s1p-user-tag-display { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; background-color: var(--s1p-sub); color: var(--s1p-t); padding: 2px 8px; font-size: 12px; cursor: default; } .s1p-user-tag-options { display: flex; align-items: center; justify-content: center; background-color: var(--s1p-sub); color: var(--s1p-t); padding: 0 8px; flex-shrink: 0; align-self: stretch; cursor: pointer; transition: background-color 0.2s ease-in-out; } .s1p-user-tag-options:hover { background-color: var(--s1p-pri); } /* --- Tag Options Menu --- */ .s1p-tag-options-menu { position: absolute; z-index: 10002; background-color: var(--s1p-bg); border-radius: 6px; box-shadow: 0 2px 8px rgba(var(--s1p-black-rgb), 0.15); border: 1px solid var(--s1p-pri); padding: 4px; display: flex; flex-direction: column; gap: 2px; min-width: max-content; } .s1p-tag-options-menu button { background: none; border: none; padding: 6px 12px; text-align: left; cursor: pointer; border-radius: 4px; font-size: 14px; color: var(--s1p-t); white-space: nowrap; } .s1p-tag-options-menu button:hover { background-color: var(--s1p-sub-h); color: var(--s1p-sub-h-t); } .s1p-tag-options-menu button.s1p-delete:hover { background-color: var(--s1p-red); color: var(--s1p-white); } /* --- 设置面板样式 --- */ .s1p-modal { display: flex; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(var(--s1p-black-rgb), 0.5); justify-content: center; align-items: center; z-index: 9999; } .s1p-modal-content { background-color: var(--s1p-bg); border-radius: 8px; box-shadow: 0 4px 6px rgba(var(--s1p-black-rgb), 0.1); width: 600px; max-width: 90%; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; position: relative; } .s1p-modal-header { background: var(--s1p-pri) ;padding: 16px; border-bottom: 1px solid var(--s1p-pri); display: flex; justify-content: space-between; align-items: center; } .s1p-modal-title { font-size: 18px; font-weight: bold; } .s1p-modal-close { width: 12px; height: 12px; cursor: pointer; color: var(--s1p-icon-close); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M2 2L14 14M14 2L2 14' stroke='currentColor' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: contain; transition: color 0.2s ease-in-out, transform 0.2s ease-in-out; transform: rotate(0deg); } .s1p-modal-close:hover { color: var(--s1p-red); transform: rotate(90deg); } .s1p-modal-body { padding: 0 16px 16px; overflow-y: auto; flex-grow: 1; } .s1p-modal-footer { padding: 12px 16px; border-top: 1px solid var(--s1p-pri); text-align: right; font-size: 12px; } .s1p-tabs { display: flex; border-bottom: 1px solid var(--s1p-pri); } .s1p-tab-btn { padding: 12px 16px; cursor: pointer; border: none; background-color: transparent; font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; white-space: nowrap; } .s1p-tab-btn.active { color: var(--s1p-sec); border-bottom-color: var(--s1p-sec); font-weight: 500; } .s1p-tab-content { display: none; padding-top: 16px; } .s1p-tab-content.active { display: block; } .s1p-empty { text-align: center; padding: 24px; color: var(--s1p-desc-t); } .s1p-list { display: flex; flex-direction: column; gap: 8px; } .s1p-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px; border-radius: 6px; background-color: var(--s1p-bg); border: 1px solid var(--s1p-pri); } .s1p-item-info { flex-grow: 1; min-width: 0; } .s1p-item-title { font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .s1p-item-meta { font-size: 12px; color: var(--s1p-desc-t);} .s1p-item-toggle { font-size: 12px; color: var(--s1p-desc-t); display: flex; align-items: center; gap: 8px; } .s1p-item-toggle input { /* Handled by .s1p-switch */ } .s1p-unblock-btn:hover { background-color: #07855b; border-color: #07855b; } .s1p-sync-title { font-size: 14px; font-weight: 500; margin-bottom: 8px; } .s1p-local-sync-desc { font-size: 14px; color: var(--s1p-desc-t); margin-bottom: 12px; line-height: 1.5; } .s1p-local-sync-buttons { display: flex; gap: 8px; margin-bottom: 16px; } .s1p-sync-textarea { width: 100%; min-height: 80px; margin-bottom: 20px;} /* --- [OPTIMIZED] Sync Settings Panel Disabled State --- */ #s1p-remote-sync-controls-wrapper { transition: opacity 0.3s ease-out; } #s1p-remote-sync-controls-wrapper.is-disabled { opacity: 0.5; pointer-events: none; } /* --- 悬浮提示框 (Toast Notification) --- */ @keyframes s1p-toast-shake { 10%, 90% { transform: translate(-51%, 0); } 20%, 80% { transform: translate(-49%, 0); } 30%, 50%, 70% { transform: translate(-52%, 0); } 40%, 60% { transform: translate(-48%, 0); } } .s1p-toast-notification { position: fixed; left: 50%; bottom: 20px; transform: translate(-50%, 50px); z-index: 10005; padding: 10px 18px; border-radius: 6px; font-size: 14px; font-weight: 500; color: var(--s1p-t); /* <-- 修改了此行 */ background-color: var(--s1p-bg); /* <-- 修改了此行 */ box-shadow: 0 4px 12px rgba(var(--s1p-black-rgb), 0.15); opacity: 0; transition: opacity 0.3s ease-out, transform 0.3s ease-out; pointer-events: none; white-space: nowrap; text-align: center; /* [新增] 为浅色背景增加边框以提高辨识度 */ border: 1px solid var(--s1p-pri); } .s1p-modal-content .s1p-toast-notification { position: absolute; bottom: 15px; } .s1p-toast-notification.visible { opacity: 1; transform: translate(-50%, 0); } .s1p-toast-notification.success { background-color: #27da80; color: var(--s1p-white); /* 确保成功状态字体为白色 */ border-color: #27da80; /* 覆盖边框颜色 */ } .s1p-toast-notification.error { background-color: var(--s1p-red); color: var(--s1p-white); /* 确保失败状态字体为白色 */ border-color: var(--s1p-red); /* 覆盖边框颜色 */ } .s1p-toast-notification.error.visible { animation: s1p-toast-shake 0.5s cubic-bezier(.36,.07,.19,.97) both; } /* --- 确认弹窗样式 --- */ @keyframes s1p-fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes s1p-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes s1p-fade-out { from { opacity: 1; } to { opacity: 0; } } @keyframes s1p-scale-out { from { transform: scale(1); opacity: 1; } to { transform: scale(0.97); opacity: 0; } } .s1p-confirm-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(var(--s1p-black-rgb), 0.65); display: flex; justify-content: center; align-items: center; z-index: 10000; animation: s1p-fade-in 0.2s ease-out; } .s1p-confirm-content { background-color: var(--s1p-bg); border-radius: 12px; box-shadow: 0 10px 25px -5px rgba(var(--s1p-black-rgb), 0.1), 0 10px 10px -5px rgba(var(--s1p-black-rgb), 0.04); width: 480px; max-width: 90%; text-align: left; overflow: hidden; animation: s1p-scale-in 0.25s ease-out; } .s1p-confirm-body { padding: 20px 24px; font-size: 16px; line-height: 1.6; } .s1p-confirm-body .s1p-confirm-title { font-weight: 600; font-size: 18px; margin-bottom: 8px; } .s1p-confirm-body .s1p-confirm-subtitle { font-size: 14px; color: var(--s1p-desc-t); } .s1p-confirm-footer { padding: 12px 24px 20px; display: flex; justify-content: flex-end; gap: 12px; } .s1p-confirm-footer.s1p-centered { justify-content: center; } .s1p-confirm-btn { padding: 9px 14px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all 0.15s ease-in-out; box-shadow: 0 1px 2px 0 rgba(var(--s1p-black-rgb), 0.05); white-space: nowrap; } .s1p-confirm-btn:active { transform: translateY(1px); } .s1p-confirm-btn.s1p-cancel { background-color: var(--s1p-sub); border-color: var(--s1p-pri); } .s1p-confirm-btn.s1p-cancel:hover { border-color: var(--s1p-red); background-color: var(--s1p-error-bg); } .s1p-confirm-btn.s1p-confirm { background-color: var(--s1p-red); color: var(--s1p-white); border-color: var(--s1p-red); } .s1p-confirm-btn.s1p-confirm:hover { background-color: var(--s1p-red-h); border-color: var(--s1p-red-h); } /* --- Collapsible Section --- */ .s1p-collapsible-header { display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none; transition: color 0.2s ease; } .s1p-settings-group-title.s1p-collapsible-header { margin-bottom: 0; } .s1p-collapsible-header:hover { color: var(--s1p-sec); } .s1p-collapsible-header:hover .s1p-expander-arrow { color: var(--s1p-sec); } .s1p-expander-arrow { display: inline-block; width: 12px; height: 12px; color: var(--s1p-icon-arrow); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 16'%3E%3Cpath d='M2 2L8 8L2 14' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: contain; transition: transform 0.3s ease-in-out, color 0.2s ease; } .s1p-expander-arrow.expanded { transform: rotate(90deg); } .s1p-collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; } .s1p-collapsible-content.expanded { max-height: 500px; transition: max-height 0.4s ease-in; padding-top: 12px; } /* --- Feature Content Animation --- */ .s1p-feature-content { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.6s ease-in-out, margin-top 0.6s ease-in-out; margin-top: 0; } .s1p-feature-content.expanded { grid-template-rows: 1fr; margin-top: 16px; } .s1p-feature-content > div { overflow: hidden; transition: opacity 0.5s ease-in-out; } .s1p-feature-content:not(.expanded) > div { opacity: 0; transition-duration: 0.25s; } .s1p-feature-content.expanded > div { opacity: 1; transition-delay: 0.15s; } /* --- 界面定制设置样式 --- */ .s1p-settings-group { margin-bottom: 24px; } .s1p-settings-group-title { font-size: 16px; font-weight: 500; border-bottom: 1px solid var(--s1p-pri); padding-bottom: 16px; margin-bottom: 12px; } .s1p-settings-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; } .s1p-settings-item .s1p-input { width: auto; min-width: 200px; } .s1p-settings-label { font-size: 14px; } .s1p-settings-checkbox { /* Handled by .s1p-switch */ } .s1p-setting-desc { font-size: 12px; color: var(--s1p-desc-t); margin: -4px 0 12px 0; padding: 0; line-height: 1.5; } .s1p-editor-item { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; padding: 6px; border-radius: 4px; background: var(--s1p-bg); } .s1p-editor-item select { background: var(--s1p-bg); width: 100%; border: 1px solid var(--s1p-pri); border-radius: 4px; padding: 6px 8px; font-size: 14px; box-sizing: border-box; } .s1p-editor-item-controls { display: flex; align-items: center; gap: 4px; } .s1p-editor-btn { padding: 4px; font-size: 18px; line-height: 1; cursor: pointer; border-radius: 4px; border:none; background: transparent; color: #9ca3af; } .s1p-editor-btn:hover { background: var(--s1p-secondary-bg); color: var(--s1p-secondary-text); } .s1p-editor-btn.s1p-keyword-rule-delete, .s1p-editor-btn[data-action="delete"] { font-size: 0; width: 26px; height: 26px; padding: 4px; box-sizing: border-box; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23374151'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0' /%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 18px 18px; transition: all 0.2s ease; } .s1p-editor-btn.s1p-keyword-rule-delete:hover, .s1p-editor-btn[data-action="delete"]:hover { background-color: var(--s1p-red); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='white'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0' /%3E%3C/svg%3E"); } .s1p-drag-handle { font-size: 18pt; cursor: grab; } .s1p-editor-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; } .s1p-settings-action-btn { display: inline-block; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background-color 0.2s; border: none; } .s1p-settings-action-btn.s1p-primary { background-color: var(--s1p-sec); color: var(--s1p-white); } .s1p-settings-action-btn.s1p-primary:hover { background-color: var(--s1p-sec-h); } .s1p-settings-action-btn.s1p-secondary { background-color: var(--s1p-secondary-bg); color: var(--s1p-secondary-text); } .s1p-settings-action-btn.s1p-secondary:hover { background-color: var(--s1p-secondary-hover-bg); } /* --- 带图标的搜索框 --- */ .s1p-search-input-wrapper { position: relative; display: flex; align-items: center; width: 100%; } .s1p-search-input-wrapper .s1p-input { padding-left: 34px; padding-right: 34px; } .s1p-search-input-wrapper svg.s1p-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--s1p-icon-color); pointer-events: none; transition: color 0.2s ease-in-out; } .s1p-search-input-wrapper .s1p-input:focus + svg.s1p-search-icon { color: var(--s1p-sec); } /* --- 搜索框清空按钮 --- */ .s1p-search-clear-btn { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background-color: transparent; border: none; border-radius: 50%; cursor: pointer; opacity: 1; transition: background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease; padding: 0; } .s1p-search-clear-btn.hidden { opacity: 0; pointer-events: none; transform: translateY(-50%) scale(0.8); } .s1p-search-clear-btn:hover { background-color: var(--s1p-pri); } .s1p-search-clear-btn svg { width: 12px; height: 12px; color: var(--s1p-icon-arrow); } /* --- 搜索关键词高亮 --- */ mark.s1p-highlight { background-color: var(--s1p-pri); color: var(--s1p-t); font-weight: bold; padding: 1px 3px; border-radius: 3px; text-decoration: none; } /* --- Modern Toggle Switch --- */ .s1p-switch { position: relative; display: inline-block; width: 40px; height: 22px; vertical-align: middle; flex-shrink: 0; } .s1p-switch input { opacity: 0; width: 0; height: 0; } .s1p-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--s1p-pri); transition: .3s; border-radius: 22px; } .s1p-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: var(--s1p-white); transition: .3s; border-radius: 50%; box-shadow: 0 1px 3px rgba(var(--s1p-black-rgb), 0.1); } input:checked + .s1p-slider { background-color: var(--s1p-sec); } input:checked + .s1p-slider:before { transform: translateX(18px); } /* --- Nav Editor Dragging --- */ .s1p-editor-item.s1p-dragging { opacity: 0.5; } /* --- 用户标记设置面板专属样式 --- */ .s1p-item-meta-id { font-family: monospace; background-color: var(--s1p-bg); padding: 1px 5px; border-radius: 4px; font-size: 11px; color: var(--s1p-t); } .s1p-item-content { margin-top: 10px; /* 增加上边距 */ padding-top: 10px; /* 增加上内边距,文字与分割线间的空间 */ border-top: 1px solid var(--s1p-pri); /* 添加分割线 */ color: var(--s1p-t); /* 使用更醒目的主文本颜色 */ font-size: 12px; /* 增大字体 */ font-weight: bold; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } #s1p-tab-bookmarks .s1p-item-content { background-color: transparent; border: none; border-bottom: 1px solid var(--s1p-pri); border-radius: 0; padding: 0 0 10px 0; margin-top: 0; margin-bottom: 10px; } .s1p-item-editor textarea { width: 100%; min-height: 60px; margin-top: 8px; } .s1p-item-actions { display: flex; align-self: flex-start; flex-shrink: 0; gap: 8px; margin-left: 16px; } .s1p-item-actions .s1p-btn.s1p-primary { background-color: #3b82f6; color: var(--s1p-white); } .s1p-item-actions .s1p-btn.s1p-primary:hover { background-color: #2563eb; } .s1p-item-actions .s1p-btn.s1p-danger { background-color: var(--s1p-red); color: var(--s1p-white); } .s1p-item-actions .s1p-btn.s1p-danger:hover { background-color: var(--s1p-red-h); border-color: var(--s1p-red-h);} /* --- 引用屏蔽占位符 --- */ .s1p-quote-placeholder { background-color: var(--s1p-bg); border: 1px solid var(--s1p-pri); padding: 8px 12px; border-radius: 6px; margin: 10px 0; font-size: 13px; color: var(--s1p-desc-t); display: flex; justify-content: space-between; align-items: center; } div.s1p-quote-placeholder span.s1p-quote-toggle { color: var(--s1p-t); font-weight: 500; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: background-color 0.2s ease, color 0.2s ease; } div.s1p-quote-placeholder span.s1p-quote-toggle:hover { background-color: var(--s1p-sub); color: var(--s1p-t); } .s1p-quote-wrapper { overflow: hidden; transition: max-height 0.35s ease-in-out; } /* --- Image Hiding --- */ .s1p-image-container { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; margin: 8px 0; } .s1p-image-placeholder { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; background-color: var(--s1p-sub); color: var(--s1p-t); border: 1px solid var(--s1p-pri); cursor: pointer; font-size: 13px; transition: all 0.2s ease; } .s1p-image-placeholder:hover { background-color: var(--s1p-sub-h); color: var(--s1p-sub-h-t); border-color: var(--s1p-sub-h); } .s1p-image-container.hidden > .zoom { display: none; } .s1p-image-toggle-all-container { margin-bottom: 10px; } .s1p-image-toggle-all-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; background-color: var(--s1p-sub); color: var(--s1p-t); border: 1px solid var(--s1p-pri); cursor: pointer; font-size: 13px; transition: all 0.2s ease; } .s1p-image-toggle-all-btn:hover { background-color: var(--s1p-sub-h); color: var(--s1p-sub-h-t); border-color: var(--s1p-sub-h); } /* --- [更新] 回复收藏内容切换 V3 --- */ .s1p-bookmark-preview, .s1p-bookmark-full { /* 保留换行和空格,确保纯文本格式正确显示 */ white-space: pre-wrap; word-break: break-word; } .s1p-bookmark-full { display: none; } .s1p-bookmark-toggle { /* 保持为行内元素,自然跟在文字后方 */ display: inline; margin-left: 4px; /* 与文字稍微隔开 */ font-weight: 500; color: var(--s1p-t); cursor: pointer; text-decoration: none; font-size: 13px; } .s1p-bookmark-toggle:hover { color: var(--s1p-sec); text-decoration: underline; } `); // --- S1 NUX 兼容性检测 --- let isS1NuxEnabled = false; const detectS1Nux = () => { const archiverLink = document.querySelector('a[href*="archiver"]'); if (archiverLink) { const style = window.getComputedStyle(archiverLink, '::before'); if (style && style.content.includes('NUXISENABLED')) { console.log('S1 Plus: S1 NUX is enabled'); isS1NuxEnabled = true; } else { console.log('S1 Plus: S1 NUX is not enabled'); } } }; let dynamicallyHiddenThreads = {}; // [MODIFIED] 为远程推送增加防抖机制,并防止与初始同步冲突 let remotePushTimeout; const debouncedTriggerRemoteSyncPush = () => { // [OPTIMIZATION] 如果初始同步正在进行,则跳过由数据变更触发的推送 if (isInitialSyncInProgress) { console.log('S1 Plus: 初始同步进行中,已跳过本次自动推送请求。'); return; } const settings = getSettings(); // [MODIFIED] 增加对自动同步子开关的判断 if (!settings.syncRemoteEnabled || !settings.syncAutoEnabled || !settings.syncRemoteGistId || !settings.syncRemotePat) { return; } clearTimeout(remotePushTimeout); // 延迟5秒推送,如果在5秒内有新的数据变动,则重新计时 remotePushTimeout = setTimeout(() => { triggerRemoteSyncPush(); }, 5000); }; // [FIXED] 只有在数据实际变动时才更新时间戳并触发同步 const updateLastModifiedTimestamp = () => { GM_setValue('s1p_last_modified', Date.now()); debouncedTriggerRemoteSyncPush(); }; // [NEW] 更新上次同步时间的显示 const updateLastSyncTimeDisplay = () => { const container = document.querySelector('#s1p-last-sync-time-container'); if (!container) return; const lastSyncTs = GM_getValue('s1p_last_sync_timestamp', 0); if (lastSyncTs > 0) { container.textContent = `上次成功同步于: ${new Date(lastSyncTs).toLocaleString('zh-CN', { hour12: false })}`; } else { container.textContent = '尚未进行过远程同步。'; } }; // --- 数据处理 & 核心功能 --- const getBlockedThreads = () => GM_getValue('s1p_blocked_threads', {}); const saveBlockedThreads = (threads) => { GM_setValue('s1p_blocked_threads', threads); updateLastModifiedTimestamp(); }; const getBlockedUsers = () => GM_getValue('s1p_blocked_users', {}); const saveBlockedUsers = (users) => { GM_setValue('s1p_blocked_users', users); updateLastModifiedTimestamp(); }; const saveUserTags = (tags) => { GM_setValue('s1p_user_tags', tags); updateLastModifiedTimestamp(); }; // [NEW] Bookmarked Replies data functions const getBookmarkedReplies = () => GM_getValue('s1p_bookmarked_replies', {}); const saveBookmarkedReplies = (replies) => { GM_setValue('s1p_bookmarked_replies', replies); updateLastModifiedTimestamp(); }; // [MODIFIED] 升级并获取用户标记,自动迁移旧数据 const getUserTags = () => { const tags = GM_getValue('s1p_user_tags', {}); let needsMigration = false; const migratedTags = { ...tags }; Object.keys(migratedTags).forEach(id => { if (typeof migratedTags[id] === 'string' || !migratedTags[id].timestamp) { needsMigration = true; const oldTag = typeof migratedTags[id] === 'string' ? migratedTags[id] : migratedTags[id].tag; const oldName = (migratedTags[id] && migratedTags[id].name) ? migratedTags[id].name : `用户 #${id}`; migratedTags[id] = { name: oldName, tag: oldTag, timestamp: (migratedTags[id] && migratedTags[id].timestamp) || Date.now() }; } }); if (needsMigration) { console.log('S1 Plus: 正在将用户标记迁移到新版数据结构...'); saveUserTags(migratedTags); return migratedTags; } return tags; }; const getTitleFilterRules = () => { const rules = GM_getValue('s1p_title_filter_rules', null); if (rules !== null) return rules; // --- 向下兼容:迁移旧的关键字数据 --- const oldKeywords = GM_getValue('s1p_title_keywords', null); if (Array.isArray(oldKeywords)) { const newRules = oldKeywords.map(k => ({ pattern: k, enabled: true, id: `rule_${Date.now()}_${Math.random()}` })); saveTitleFilterRules(newRules); GM_setValue('s1p_title_keywords', null); // 清理旧数据 return newRules; } return []; }; const saveTitleFilterRules = (rules) => { GM_setValue('s1p_title_filter_rules', rules); updateLastModifiedTimestamp(); }; const blockThread = (id, title, reason = 'manual') => { const b = getBlockedThreads(); if (b[id]) return; b[id] = { title, timestamp: Date.now(), reason }; saveBlockedThreads(b); hideThread(id); }; const unblockThread = (id) => { const b = getBlockedThreads(); delete b[id]; saveBlockedThreads(b); showThread(id); }; const hideThread = (id) => { (document.getElementById(`normalthread_${id}`) || document.getElementById(`stickthread_${id}`))?.setAttribute('style', 'display: none !important'); }; const showThread = (id) => { (document.getElementById(`normalthread_${id}`) || document.getElementById(`stickthread_${id}`))?.removeAttribute('style'); } const hideBlockedThreads = () => Object.keys(getBlockedThreads()).forEach(hideThread); const blockUser = (id, name) => { const settings = getSettings(); const b = getBlockedUsers(); b[id] = { name, timestamp: Date.now(), blockThreads: settings.blockThreadsOnUserBlock }; saveBlockedUsers(b); hideUserPosts(id); hideBlockedUserQuotes(); hideBlockedUserRatings(); if (b[id].blockThreads) applyUserThreadBlocklist(); }; // [MODIFIED] 增加调用评分刷新函数 const unblockUser = (id) => { const b = getBlockedUsers(); delete b[id]; saveBlockedUsers(b); showUserPosts(id); hideBlockedUserQuotes(); hideBlockedUserRatings(); unblockThreadsByUser(id); }; // [FIX] 更精确地定位帖子作者,避免错误隐藏被评分的帖子 const hideUserPosts = (id) => { document.querySelectorAll(`.authi a[href*="space-uid-${id}.html"]`).forEach(l => l.closest('table.plhin')?.setAttribute('style', 'display: none !important')); }; const showUserPosts = (id) => { document.querySelectorAll(`.authi a[href*="space-uid-${id}.html"]`).forEach(l => l.closest('table.plhin')?.removeAttribute('style')); }; const hideBlockedUsersPosts = () => Object.keys(getBlockedUsers()).forEach(hideUserPosts); const hideBlockedUserQuotes = () => { const settings = getSettings(); const blockedUsers = getBlockedUsers(); const blockedUserNames = Object.values(blockedUsers).map(u => u.name); document.querySelectorAll('div.quote').forEach(quoteElement => { const quoteAuthorElement = quoteElement.querySelector('blockquote font[color="#999999"]'); if (!quoteAuthorElement) return; const text = quoteAuthorElement.textContent.trim(); const match = text.match(/^(.*)\s发表于\s.*$/); if (!match || !match[1]) return; const authorName = match[1]; const isBlocked = settings.enableUserBlocking && blockedUserNames.includes(authorName); const wrapper = quoteElement.parentElement.classList.contains('s1p-quote-wrapper') ? quoteElement.parentElement : null; if (isBlocked) { if (!wrapper) { const newWrapper = document.createElement('div'); newWrapper.className = 's1p-quote-wrapper'; quoteElement.parentNode.insertBefore(newWrapper, quoteElement); newWrapper.appendChild(quoteElement); newWrapper.style.maxHeight = '0'; const newPlaceholder = document.createElement('div'); newPlaceholder.className = 's1p-quote-placeholder'; newPlaceholder.innerHTML = `一条来自已屏蔽用户的引用已被隐藏。点击展开`; newWrapper.parentNode.insertBefore(newPlaceholder, newWrapper); newPlaceholder.querySelector('.s1p-quote-toggle').addEventListener('click', function () { const isCollapsed = newWrapper.style.maxHeight === '0px'; if (isCollapsed) { const style = window.getComputedStyle(quoteElement); const marginTop = parseFloat(style.marginTop); const marginBottom = parseFloat(style.marginBottom); newWrapper.style.maxHeight = (quoteElement.offsetHeight + marginTop + marginBottom) + 'px'; this.textContent = '点击折叠'; } else { newWrapper.style.maxHeight = '0px'; this.textContent = '点击展开'; } }); } } else { if (wrapper) { const placeholder = wrapper.previousElementSibling; if (placeholder && placeholder.classList.contains('s1p-quote-placeholder')) { placeholder.remove(); } wrapper.parentNode.insertBefore(quoteElement, wrapper); wrapper.remove(); } } }); }; // [MODIFIED] 函数现在可以同时处理隐藏和显示,是一个完整的“刷新”功能 const hideBlockedUserRatings = () => { const settings = getSettings(); const blockedUserIds = Object.keys(getBlockedUsers()); document.querySelectorAll('tbody.ratl_l tr').forEach(row => { const userLink = row.querySelector('a[href*="space-uid-"]'); if (userLink) { const uidMatch = userLink.href.match(/space-uid-(\d+)/); if (uidMatch && uidMatch[1]) { const isBlocked = settings.enableUserBlocking && blockedUserIds.includes(uidMatch[1]); row.style.display = isBlocked ? 'none' : ''; } } }); }; const hideThreadsByTitleKeyword = () => { const rules = getTitleFilterRules().filter(r => r.enabled && r.pattern); const newHiddenThreads = {}; const regexes = rules.map(r => { try { return { regex: new RegExp(r.pattern), pattern: r.pattern }; } catch (e) { console.error(`S1 Plus: 屏蔽规则 "${r.pattern}" 不是一个有效的正则表达式,将被忽略。`, e); return null; } }).filter(Boolean); document.querySelectorAll('tbody[id^="normalthread_"], tbody[id^="stickthread_"]').forEach(row => { const titleElement = row.querySelector('th a.s.xst'); if (!titleElement) return; const title = titleElement.textContent.trim(); const threadId = row.id.replace(/^(normalthread_|stickthread_)/, ''); let isHidden = false; if (regexes.length > 0) { const matchingRule = regexes.find(r => r.regex.test(title)); if (matchingRule) { newHiddenThreads[threadId] = { title, pattern: matchingRule.pattern }; row.classList.add('s1p-hidden-by-keyword'); isHidden = true; } } if (!isHidden) { row.classList.remove('s1p-hidden-by-keyword'); } }); dynamicallyHiddenThreads = newHiddenThreads; }; const getReadProgress = () => GM_getValue('s1p_read_progress', {}); const saveReadProgress = (progress) => { GM_setValue('s1p_read_progress', progress); updateLastModifiedTimestamp(); }; const updateThreadProgress = (threadId, postId, page, lastReadFloor) => { if (!postId || !page || !lastReadFloor) return; const progress = getReadProgress(); progress[threadId] = { postId, page, timestamp: Date.now(), lastReadFloor: lastReadFloor }; saveReadProgress(progress); }; const applyUserThreadBlocklist = () => { const blockedUsers = getBlockedUsers(); const usersToBlockThreads = Object.keys(blockedUsers).filter(uid => blockedUsers[uid].blockThreads); if (usersToBlockThreads.length === 0) return; document.querySelectorAll('tbody[id^="normalthread_"], tbody[id^="stickthread_"]').forEach(row => { const authorLink = row.querySelector('td.by cite a[href*="space-uid-"]'); if (authorLink) { const uidMatch = authorLink.href.match(/space-uid-(\d+)\.html/); const authorId = uidMatch ? uidMatch[1] : null; if (authorId && usersToBlockThreads.includes(authorId)) { const threadId = row.id.replace(/^(normalthread_|stickthread_)/, ''); const titleElement = row.querySelector('th a.s.xst'); if (threadId && titleElement) { blockThread(threadId, titleElement.textContent.trim(), `user_${authorId}`); } } } }); }; const unblockThreadsByUser = (userId) => { const allBlockedThreads = getBlockedThreads(); const reason = `user_${userId}`; Object.keys(allBlockedThreads).forEach(threadId => { if (allBlockedThreads[threadId].reason === reason) { unblockThread(threadId); } }); }; const updatePostImageButtonState = (postContainer) => { const toggleButton = postContainer.querySelector('.s1p-image-toggle-all-btn'); if (!toggleButton) return; const totalImages = postContainer.querySelectorAll('.s1p-image-container').length; if (totalImages <= 1) { const container = toggleButton.closest('.s1p-image-toggle-all-container'); if (container) container.remove(); return; } const hiddenImages = postContainer.querySelectorAll('.s1p-image-container.hidden').length; if (hiddenImages > 0) { toggleButton.textContent = `显示本楼所有图片 (${hiddenImages}/${totalImages})`; } else { toggleButton.textContent = `隐藏本楼所有图片 (${totalImages}/${totalImages})`; } }; const manageImageToggleAllButtons = () => { const settings = getSettings(); // 如果没有开启“默认隐藏图片”,则移除所有切换按钮并直接返回 if (!settings.hideImagesByDefault) { document.querySelectorAll('.s1p-image-toggle-all-container').forEach(el => el.remove()); return; } document.querySelectorAll('table.plhin').forEach(postContainer => { const imageContainers = postContainer.querySelectorAll('.s1p-image-container'); const postContentArea = postContainer.querySelector('td.t_f'); if (!postContentArea) return; let toggleButtonContainer = postContainer.querySelector('.s1p-image-toggle-all-container'); if (imageContainers.length <= 1) { if (toggleButtonContainer) toggleButtonContainer.remove(); return; } if (!toggleButtonContainer) { toggleButtonContainer = document.createElement('div'); toggleButtonContainer.className = 's1p-image-toggle-all-container'; const toggleButton = document.createElement('button'); toggleButton.className = 's1p-image-toggle-all-btn'; toggleButtonContainer.appendChild(toggleButton); toggleButton.addEventListener('click', (e) => { e.preventDefault(); const imagesInPost = postContainer.querySelectorAll('.s1p-image-container'); const shouldShowAll = postContainer.querySelector('.s1p-image-container.hidden'); if (shouldShowAll) { imagesInPost.forEach(container => { container.classList.remove('hidden'); container.dataset.manualShow = 'true'; }); } else { imagesInPost.forEach(container => { container.classList.add('hidden'); delete container.dataset.manualShow; }); } updatePostImageButtonState(postContainer); }); postContentArea.prepend(toggleButtonContainer); } updatePostImageButtonState(postContainer); }); }; // --- [新增] 修改“只看该作者”为“只看该用户”的函数 --- const renameAuthorLinks = () => { document.querySelectorAll('div.authi a[href*="authorid="]').forEach(link => { if (link.textContent.trim() === '只看该作者') { link.textContent = '只看该用户'; } }); }; // [MODIFIED] 图片隐藏功能的核心逻辑 (支持实时切换) const applyImageHiding = () => { const settings = getSettings(); // 如果功能未开启,则移除所有包装和占位符 if (!settings.hideImagesByDefault) { document.querySelectorAll('.s1p-image-container').forEach(container => { const originalElement = container.querySelector('img.zoom')?.closest('a') || container.querySelector('img.zoom'); if (originalElement) { container.parentNode.insertBefore(originalElement, container); } container.remove(); }); return; } // 步骤 1: 遍历所有帖子图片,确保它们都被容器包裹并绑定切换事件 document.querySelectorAll('div.t_fsz img.zoom').forEach(img => { if (img.closest('.s1p-image-container')) return; // 如果已被包裹,则跳过 const targetElement = img.closest('a') || img; const container = document.createElement('div'); container.className = 's1p-image-container'; const placeholder = document.createElement('span'); placeholder.className = 's1p-image-placeholder'; // 初始文本不重要,会在步骤2中被正确设置 placeholder.textContent = '图片处理中...'; targetElement.parentNode.insertBefore(container, targetElement); container.appendChild(placeholder); container.appendChild(targetElement); placeholder.addEventListener('click', (e) => { e.preventDefault(); const isHidden = container.classList.toggle('hidden'); if (isHidden) { placeholder.textContent = '显示图片'; delete container.dataset.manualShow; } else { placeholder.textContent = '隐藏图片'; container.dataset.manualShow = 'true'; } const postContainer = container.closest('table.plhin'); if (postContainer) { updatePostImageButtonState(postContainer); } }); }); // 步骤 2: 根据当前设置和图片状态,同步所有容器的 class 和占位符文本 document.querySelectorAll('.s1p-image-container').forEach(container => { const placeholder = container.querySelector('.s1p-image-placeholder'); if (!placeholder) return; const shouldBeHidden = settings.hideImagesByDefault && container.dataset.manualShow !== 'true'; container.classList.toggle('hidden', shouldBeHidden); if (shouldBeHidden) { placeholder.textContent = '显示图片'; } else { placeholder.textContent = '隐藏图片'; } }); }; /** * 点击事件处理函数,用于在新标签页打开帖子。 * @param {MouseEvent} e - 点击事件对象。 */ const threadLinkClickHandler = (e) => { const settings = getSettings(); // 再次检查设置,确保功能开启 if (settings.openThreadsInNewTab) { e.preventDefault(); GM_openInTab(e.currentTarget.href, { active: !settings.openThreadsInBackground }); } }; /** * 遍历帖子列表的所有标题链接,并根据用户设置应用或移除“新标签页打开”的行为。 */ const applyThreadLinkBehavior = () => { const settings = getSettings(); document.querySelectorAll('tbody[id^="normalthread_"] th a.s.xst, tbody[id^="stickthread_"] th a.s.xst').forEach(link => { // 先移除旧的监听器,以防重复添加或在禁用功能时清理 link.removeEventListener('click', threadLinkClickHandler); // 如果功能启用,则添加新的监听器 if (settings.openThreadsInNewTab) { link.addEventListener('click', threadLinkClickHandler); } }); }; /** * 遍历帖子列表的所有页码链接,并根据用户设置应用或移除“新标签页打开”的行为。 */ const applyPageLinkBehavior = () => { const settings = getSettings(); document.querySelectorAll('tbody[id^="normalthread_"] span.tps a, tbody[id^="stickthread_"] span.tps a').forEach(link => { // 移除旧的监听器和 onclick 属性 link.removeEventListener('click', threadLinkClickHandler); link.removeAttribute('onclick'); // 如果功能启用,则添加新的监听器 if (settings.openThreadsInNewTab) { link.addEventListener('click', threadLinkClickHandler); } }); }; // [MODIFIED] 导出数据对象,采用新的嵌套结构并包含内容哈希 const exportLocalDataObject = async () => { const lastUpdated = GM_getValue('s1p_last_modified', 0); const lastUpdatedFormatted = new Date(lastUpdated).toLocaleString('zh-CN', { hour12: false }); // --- [FIX] 从要同步的设置中排除 Gist ID 和 PAT --- const allSettings = getSettings(); const { syncRemoteGistId, syncRemotePat, ...syncedSettings } = allSettings; // ----------------------------------------------------- const data = { settings: syncedSettings, // 使用过滤后的设置对象 threads: getBlockedThreads(), users: getBlockedUsers(), user_tags: getUserTags(), title_filter_rules: getTitleFilterRules(), read_progress: getReadProgress(), bookmarked_replies: getBookmarkedReplies() }; const contentHash = await calculateDataHash(data); return { version: 4.0, // 版本号升级 lastUpdated, lastUpdatedFormatted, contentHash, // 新增内容哈希字段 data // 所有用户数据被封装在 data 对象中 }; }; const exportLocalData = async () => JSON.stringify(await exportLocalDataObject(), null, 2); // [MODIFIED] 导入数据,兼容新旧两种数据结构 const importLocalData = (jsonStr) => { try { const imported = JSON.parse(jsonStr); if (typeof imported !== 'object' || imported === null) throw new Error("无效数据格式"); // --- 兼容性处理:判断是新结构还是旧结构 --- const dataToImport = imported.data && imported.version >= 4.0 ? imported.data : imported; let threadsImported = 0, usersImported = 0, progressImported = 0, rulesImported = 0, tagsImported = 0, bookmarksImported = 0; const upgradeAndMerge = (type, importedData, getter, saver) => { if (!importedData || typeof importedData !== 'object') return 0; Object.keys(importedData).forEach(id => { const item = importedData[id]; if (type === 'users' && typeof item.blockThreads === 'undefined') item.blockThreads = false; if (type === 'threads' && typeof item.reason === 'undefined') item.reason = 'manual'; }); const merged = { ...getter(), ...importedData }; saver(merged); return Object.keys(importedData).length; }; if (dataToImport.settings) { // --- [FIX] 导入设置时忽略 Gist ID 和 PAT,保留本地配置 --- const importedSettings = { ...dataToImport.settings }; delete importedSettings.syncRemoteGistId; delete importedSettings.syncRemotePat; saveSettings({ ...getSettings(), ...importedSettings }); // ---------------------------------------------------------- } threadsImported = upgradeAndMerge('threads', dataToImport.threads, getBlockedThreads, saveBlockedThreads); usersImported = upgradeAndMerge('users', dataToImport.users, getBlockedUsers, saveBlockedUsers); if (dataToImport.user_tags && typeof dataToImport.user_tags === 'object') { const mergedTags = { ...getUserTags(), ...dataToImport.user_tags }; saveUserTags(mergedTags); tagsImported = Object.keys(dataToImport.user_tags).length; } if (dataToImport.title_filter_rules && Array.isArray(dataToImport.title_filter_rules)) { saveTitleFilterRules(dataToImport.title_filter_rules); rulesImported = dataToImport.title_filter_rules.length; } else if (dataToImport.title_keywords && Array.isArray(dataToImport.title_keywords)) { // 向后兼容导入旧格式 const newRules = dataToImport.title_keywords.map(k => ({ pattern: k, enabled: true, id: `rule_${Date.now()}_${Math.random()}` })); saveTitleFilterRules(newRules); rulesImported = newRules.length; } if (dataToImport.read_progress) { const mergedProgress = { ...getReadProgress(), ...dataToImport.read_progress }; saveReadProgress(mergedProgress); progressImported = Object.keys(dataToImport.read_progress).length; } if (dataToImport.bookmarked_replies) { const mergedBookmarks = { ...getBookmarkedReplies(), ...dataToImport.bookmarked_replies }; saveBookmarkedReplies(mergedBookmarks); bookmarksImported = Object.keys(dataToImport.bookmarked_replies).length; } // [FIXED] 导入成功后,将本地时间戳与导入的时间戳同步 GM_setValue('s1p_last_modified', imported.lastUpdated || 0); hideBlockedThreads(); hideBlockedUsersPosts(); applyUserThreadBlocklist(); hideThreadsByTitleKeyword(); initializeNavbar(); applyInterfaceCustomizations(); // 导入数据是一次大数据变更,直接触发一次推送 triggerRemoteSyncPush(); return { success: true, message: `成功导入 ${threadsImported} 条帖子、${usersImported} 条用户、${tagsImported} 条标记、${bookmarksImported} 条收藏、${rulesImported} 条标题规则、${progressImported} 条阅读进度及相关设置。` }; } catch (e) { return { success: false, message: `导入失败: ${e.message}` }; } }; const fetchRemoteData = () => new Promise((resolve, reject) => { const { syncRemoteGistId, syncRemotePat } = getSettings(); if (!syncRemoteGistId || !syncRemotePat) { return reject(new Error('配置不完整')); } const syncRemoteApiUrl = `https://api.github.com/gists/${syncRemoteGistId}`; GM_xmlhttpRequest({ method: 'GET', url: syncRemoteApiUrl, headers: { 'Authorization': `Bearer ${syncRemotePat}`, 'Accept': 'application/vnd.github.v3+json' }, onload: (response) => { if (response.status === 200) { try { const gistData = JSON.parse(response.responseText); const fileContent = gistData.files['s1plus_sync.json']?.content; if (fileContent) { resolve(JSON.parse(fileContent)); } else { // If file doesn't exist, it's not an error, just means it's the first sync. // Return an empty object so it can be populated. resolve({}); } } catch (e) { reject(new Error(`解析Gist数据失败: ${e.message}`)); } } else { reject(new Error(`GitHub API请求失败,状态码: ${response.status}`)); } }, onerror: () => { reject(new Error('网络请求失败。')); } }); }); const pushRemoteData = (dataObject) => new Promise((resolve, reject) => { const { syncRemoteGistId, syncRemotePat } = getSettings(); if (!syncRemoteGistId || !syncRemotePat) { return reject(new Error('配置不完整')); } const syncRemoteApiUrl = `https://api.github.com/gists/${syncRemoteGistId}`; const payload = { files: { 's1plus_sync.json': { content: JSON.stringify(dataObject, null, 2) } } }; GM_xmlhttpRequest({ method: 'PATCH', url: syncRemoteApiUrl, headers: { 'Authorization': `Bearer ${syncRemotePat}`, 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json' }, data: JSON.stringify(payload), onload: (response) => { if (response.status === 200) { resolve({ success: true, message: '数据已成功推送到Gist。' }); } else { reject(new Error(`Gist更新失败,状态码: ${response.status}, 响应: ${response.responseText}`)); } }, onerror: () => { reject(new Error('网络请求失败。')); } }); }); // [MODIFIED] 触发式推送函数,现在是异步的 const triggerRemoteSyncPush = () => { // 异步执行,不阻塞主流程 (async () => { const settings = getSettings(); if (!settings.syncRemoteEnabled || !settings.syncRemoteGistId || !settings.syncRemotePat) { return; } console.log('S1 Plus: 检测到数据变更,触发远程同步推送...'); try { const dataToPush = await exportLocalDataObject(); await pushRemoteData(dataToPush); GM_setValue('s1p_last_sync_timestamp', Date.now()); updateLastSyncTimeDisplay(); console.log('S1 Plus: 数据已成功推送到远程。'); } catch (error) { console.error('S1 Plus: 自动推送数据失败:', error); } })(); }; /** * [新增] 迁移并校验远程数据 * @param {object} remoteGistObject - 从 Gist 拉取的原始对象 * @returns {Promise} 返回一个包含 data, version, contentHash, lastUpdated 的规范化对象 */ const migrateAndValidateRemoteData = async (remoteGistObject) => { if (!remoteGistObject || typeof remoteGistObject !== 'object') { throw new Error("远程数据为空或格式无效"); } let data, version, contentHash, lastUpdated; // 场景1: 新版数据结构 (v4.0+) if (remoteGistObject.data && remoteGistObject.version >= 4.0) { data = remoteGistObject.data; version = remoteGistObject.version; contentHash = remoteGistObject.contentHash; lastUpdated = remoteGistObject.lastUpdated; // --- 核心校验逻辑 --- const calculatedHash = await calculateDataHash(data); if (calculatedHash !== contentHash) { throw new Error("云端备份已损坏 (哈希校验失败),同步已暂停以保护您的本地数据。"); } // 场景2: 旧版扁平数据结构 (需要迁移) } else { console.log("S1 Plus: 检测到旧版云端数据格式,将进行自动迁移。"); version = remoteGistObject.version || 3.2; // 假设旧版版本 lastUpdated = remoteGistObject.lastUpdated || 0; // 从顶层属性中提取数据 data = { settings: remoteGistObject.settings || defaultSettings, threads: remoteGistObject.threads || {}, users: remoteGistObject.users || {}, user_tags: remoteGistObject.user_tags || {}, title_filter_rules: remoteGistObject.title_filter_rules || [], read_progress: remoteGistObject.read_progress || {}, bookmarked_replies: remoteGistObject.bookmarked_replies || {} }; // 旧版数据没有哈希,我们计算一个用于后续比较 contentHash = await calculateDataHash(data); } return { data, version, contentHash, lastUpdated, full: remoteGistObject }; }; // [S1 PLUS 整合版] // --- 操作: 用下面的完整代码块替换现有的 'performAutoSync' 函数。 // --- 优点: 返回详细状态对象 (来自双方),且函数自包含配置检查 (来自 cosmos),更健壮。 // 自动同步控制器,集成哈希校验逻辑并返回操作结果 const performAutoSync = async () => { const settings = getSettings(); if (!settings.syncRemoteEnabled || !settings.syncRemoteGistId || !settings.syncRemotePat) { return { status: 'skipped', reason: 'disabled' }; } // [OPTIMIZATION] 开始同步前,设置标志位 isInitialSyncInProgress = true; console.log('S1 Plus (Sync): 启动同步检查...'); try { const rawRemoteData = await fetchRemoteData(); if (Object.keys(rawRemoteData).length === 0) { // Gist为空,首次同步 console.log(`S1 Plus (Sync): 远程为空,推送本地数据...`); const localData = await exportLocalDataObject(); await pushRemoteData(localData); GM_setValue('s1p_last_sync_timestamp', Date.now()); return { status: 'success', action: 'pushed_initial' }; } // 1. 校验和迁移远程数据 const remote = await migrateAndValidateRemoteData(rawRemoteData); const remoteTimestamp = remote.lastUpdated; const remoteHash = remote.contentHash; // 2. 计算本地哈希和时间戳 const localDataObject = await exportLocalDataObject(); const localTimestamp = localDataObject.lastUpdated; const localHash = localDataObject.contentHash; // 场景A: 完全一致 if (remoteHash === localHash) { console.log(`S1 Plus (Sync): 本地与远程数据哈希一致,无需同步。`); return { status: 'success', action: 'no_change' }; } // 哈希不一致,根据时间戳决策 // 场景B: 远程有更新 if (remoteTimestamp > localTimestamp) { console.log(`S1 Plus (Sync): 远程数据比本地新,正在后台应用...`); importLocalData(JSON.stringify(remote.full)); GM_setValue('s1p_last_sync_timestamp', Date.now()); return { status: 'success', action: 'pulled' }; // 场景C: 本地有更新 } else if (localTimestamp > remoteTimestamp) { console.log(`S1 Plus (Sync): 本地数据比远程新,正在后台推送...`); await pushRemoteData(localDataObject); GM_setValue('s1p_last_sync_timestamp', Date.now()); return { status: 'success', action: 'pushed' }; // 场景D: 冲突 (时间戳相同但哈希不同) } else { console.warn(`S1 Plus (Sync): 检测到同步冲突 (时间戳相同但内容不同),自动同步已暂停。请手动同步以解决冲突。`); return { status: 'conflict', reason: 'timestamps match but hashes differ' }; } } catch (error) { console.error('S1 Plus: 自动同步失败:', error); return { status: 'failure', error: error.message }; } finally { // [OPTIMIZATION] 无论成功或失败,最后都清除标志位,以允许后续正常的防抖同步 isInitialSyncInProgress = false; console.log('S1 Plus (Sync): 同步检查完成。'); } }; // [S1 PLUS 整合版] // --- 操作: 用下面的完整代码块替换现有的 'defaultSettings' 对象。 // --- 优点: 结构清晰,为新功能添加了默认设置。 // --- 设置管理 --- const defaultSettings = { enablePostBlocking: true, enableUserBlocking: true, enableUserTagging: true, enableReadProgress: true, enableBookmarkReplies: true, readingProgressCleanupDays: 0, openProgressInNewTab: true, openProgressInBackground: false, openThreadsInNewTab: false, openThreadsInBackground: false, enableNavCustomization: true, changeLogoLink: true, hideBlacklistTip: true, blockThreadsOnUserBlock: true, showBlockedByKeywordList: false, showManuallyBlockedList: false, hideImagesByDefault: false, threadBlockHoverDelay: 1, customTitleSuffix: ' - STAGE1ₛₜ', customNavLinks: [ { name: '论坛', href: 'forum.php' }, { name: '归墟', href: 'forum-157-1.html' }, { name: '漫区', href: 'forum-6-1.html' }, { name: '游戏', href: 'forum-4-1.html' }, { name: '影视', href: 'forum-48-1.html' }, { name: 'PC数码', href: 'forum-51-1.html' }, { name: '黑名单', href: 'home.php?mod=space&do=friend&view=blacklist' } ], syncRemoteEnabled: false, syncDailyFirstLoad: true, // [新增] 新增每日首次加载同步开关 syncAutoEnabled: true, // 现有自动后台同步开关 syncRemoteGistId: '', syncRemotePat: '', }; const getSettings = () => { const saved = GM_getValue('s1p_settings', {}); // 如果用户已保存自定义导航,则保留,否则使用默认值 if (saved.customNavLinks && Array.isArray(saved.customNavLinks)) { return { ...defaultSettings, ...saved, customNavLinks: saved.customNavLinks }; } return { ...defaultSettings, ...saved }; }; const saveSettings = (settings) => { GM_setValue('s1p_settings', settings); updateLastModifiedTimestamp(); }; // --- 界面定制功能 --- const applyInterfaceCustomizations = () => { const settings = getSettings(); if (settings.changeLogoLink) document.querySelector('#hd h2 a')?.setAttribute('href', './forum.php'); if (settings.hideBlacklistTip) document.getElementById('hiddenpoststip')?.remove(); // 添加标题后缀修改 if (settings.customTitleSuffix) { const titlePattern = /^(.+?)(?:论坛)?(?:\s*-\s*Stage1st)?\s*-\s*stage1\/s1\s+游戏动漫论坛$/; if (titlePattern.test(document.title)) { document.title = document.title.replace(titlePattern, '$1') + settings.customTitleSuffix; } } }; /** * [MODIFIED & OPTIMIZED] 添加或移除导航栏上的手动同步按钮,并增加状态反馈 */ const updateNavbarSyncButton = () => { const settings = getSettings(); const existingBtn = document.getElementById('s1p-nav-sync-btn'); const managerLink = document.getElementById('s1p-nav-link'); if (!settings.syncRemoteEnabled) { if (existingBtn) existingBtn.remove(); return; } if (existingBtn || !managerLink) return; const li = document.createElement('li'); li.id = 's1p-nav-sync-btn'; const a = document.createElement('a'); a.href = 'javascript:void(0);'; a.innerHTML = ``; a.addEventListener('click', async (e) => { e.preventDefault(); const icon = a.querySelector('svg'); // 如果图标不存在或正在同步中,则直接返回,防止重复触发 if (!icon || icon.classList.contains('s1p-syncing')) return; // 清除可能残留的状态类,并添加同步中动画 icon.classList.remove('s1p-sync-success', 's1p-sync-error'); icon.classList.add('s1p-syncing'); let success = false; try { success = await handleManualSync(); // 先移除同步中动画,再添加结果动画 icon.classList.remove('s1p-syncing'); // 根据同步结果添加状态类 (null表示用户取消,不给状态反馈) if (success !== null) { icon.classList.add(success ? 's1p-sync-success' : 's1p-sync-error'); } } catch (error) { // 即使出现意外错误,也要确保移除同步中状态 icon.classList.remove('s1p-syncing'); console.error("S1 Plus: Manual sync handler threw an error:", error); } finally { // 动画结束后再移除状态,确保反馈可见 setTimeout(() => { icon.classList.remove('s1p-sync-success', 's1p-sync-error'); // 动画结束后,将 transform 属性重置,以备下一次 hover 动画 icon.style.transform = ''; }, 1200); } }); a.addEventListener('mouseover', (e) => { const popover = document.getElementById('s1p-generic-display-popover'); if (popover && popover.s1p_api) { popover.s1p_api.show(e.currentTarget, '手动同步数据'); } }); a.addEventListener('mouseout', () => { const popover = document.getElementById('s1p-generic-display-popover'); if (popover && popover.s1p_api) { popover.s1p_api.hide(); } }); li.appendChild(a); // [MODIFIED] 将同步按钮插入到“S1 Plus 设置”按钮之后 managerLink.insertAdjacentElement('afterend', li); }; const initializeNavbar = () => { const settings = getSettings(); const navUl = document.querySelector('#nv > ul'); if (!navUl) return; const createManagerLink = () => { const li = document.createElement('li'); li.id = 's1p-nav-link'; const a = document.createElement('a'); a.href = 'javascript:void(0);'; a.textContent = 'S1 Plus 设置'; a.addEventListener('click', createManagementModal); li.appendChild(a); return li; }; document.getElementById('s1p-nav-link')?.remove(); document.getElementById('s1p-nav-sync-btn')?.remove(); if (settings.enableNavCustomization) { navUl.innerHTML = ''; (settings.customNavLinks || []).forEach(link => { if (!link.name || !link.href) return; const li = document.createElement('li'); if (window.location.href.includes(link.href)) li.className = 'a'; const a = document.createElement('a'); a.href = link.href; a.textContent = link.name; a.setAttribute('hidefocus', 'true'); li.appendChild(a); navUl.appendChild(li); }); } navUl.appendChild(createManagerLink()); updateNavbarSyncButton(); }; // --- [NEW] Helper function for search component /** * Escapes special characters in a string for use in a regular expression. * @param {string} str The string to escape. * @returns {string} The escaped string. */ function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Recursively finds and highlights text in a DOM node without breaking HTML. * @param {Node} node The starting DOM node. * @param {RegExp} regex The regex to match text with. */ function highlightTextInNode(node, regex) { if (node.nodeType === 3) { // Text node const text = node.textContent; const matches = [...text.matchAll(regex)]; if (matches.length > 0) { const fragment = document.createDocumentFragment(); let lastIndex = 0; matches.forEach(match => { if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index))); } const mark = document.createElement('mark'); mark.className = 's1p-highlight'; mark.textContent = match[0]; fragment.appendChild(mark); lastIndex = match.index + match[0].length; }); if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.substring(lastIndex))); } node.parentNode.replaceChild(fragment, node); } } else if (node.nodeType === 1 && node.childNodes && !/^(script|style)$/i.test(node.tagName)) { // Element node const children = Array.from(node.childNodes); for (const child of children) { highlightTextInNode(child, regex); } } } // [REPLACED] Bookmark search component with safe highlighting /** * Sets up the interactive search functionality for the bookmarks tab. * @param {HTMLElement} bookmarksTabElement The container element for the bookmarks tab. */ function setupBookmarkSearchComponent(bookmarksTabElement) { const searchInput = bookmarksTabElement.querySelector('#s1p-bookmark-search-input'); const clearButton = bookmarksTabElement.querySelector('#s1p-bookmark-search-clear-btn'); const list = bookmarksTabElement.querySelector('#s1p-bookmarks-list'); const noResultsMessage = bookmarksTabElement.querySelector('#s1p-bookmarks-no-results'); const emptyMessage = bookmarksTabElement.querySelector('#s1p-bookmarks-empty-message'); if (!searchInput || !list || !clearButton || !noResultsMessage) return; const allItems = Array.from(list.querySelectorAll('.s1p-item')); const itemCache = allItems.map(item => { const contentEl = item.querySelector('.s1p-item-content'); const metaEl = item.querySelector('.s1p-item-meta'); // Store original HTML if not already stored if (!contentEl.dataset.originalHtml) { contentEl.dataset.originalHtml = contentEl.innerHTML; } if (!metaEl.dataset.originalHtml) { metaEl.dataset.originalHtml = metaEl.innerHTML; } return { element: item, searchableText: (contentEl.textContent + ' ' + metaEl.textContent).toLowerCase(), contentEl: contentEl, metaEl: metaEl, }; }); const performSearch = () => { const query = searchInput.value.toLowerCase().trim(); clearButton.classList.toggle('hidden', query.length === 0); const keywords = query.split(/\s+/).filter(k => k); let visibleCount = 0; const highlightRegex = keywords.length > 0 ? new RegExp(keywords.map(escapeRegExp).join('|'), 'gi') : null; for (const item of itemCache) { const isVisible = keywords.length === 0 || keywords.every(keyword => item.searchableText.includes(keyword)); // Reset highlights first by restoring original HTML item.contentEl.innerHTML = item.contentEl.dataset.originalHtml; item.metaEl.innerHTML = item.metaEl.dataset.originalHtml; item.element.style.display = isVisible ? 'flex' : 'none'; if (isVisible) { visibleCount++; if (highlightRegex) { // Apply new, safe highlighting that only targets text nodes highlightTextInNode(item.contentEl, highlightRegex); highlightTextInNode(item.metaEl, highlightRegex); } } } const hasAnyItems = allItems.length > 0; list.style.display = hasAnyItems ? 'flex' : 'none'; emptyMessage.style.display = !hasAnyItems ? 'block' : 'none'; noResultsMessage.style.display = (hasAnyItems && visibleCount === 0 && query.length > 0) ? 'block' : 'none'; }; searchInput.addEventListener('input', performSearch); clearButton.addEventListener('click', () => { searchInput.value = ''; performSearch(); searchInput.focus(); }); clearButton.classList.toggle('hidden', searchInput.value.length === 0); } // --- UI 创建 --- const formatDate = (timestamp) => new Date(timestamp).toLocaleString('zh-CN'); let currentToast = null; // 用一个全局变量来管理当前的提示框实例 /** * [MODIFIED] 显示消息,支持 true(成功)/false(失败)/null(中立) 三种状态 * @param {string} message - 要显示的消息内容。 * @param {boolean|null} isSuccess - 消息状态。 */ const showMessage = (message, isSuccess) => { // 如果上一个提示框还存在,立即移除,防止重叠 if (currentToast) { currentToast.remove(); } const toast = document.createElement('div'); toast.textContent = message; // --- [核心修正] --- // 使用更完善的逻辑来处理三种状态 let toastClass = 's1p-toast-notification'; if (isSuccess === true) { toastClass += ' success'; } else if (isSuccess === false) { toastClass += ' error'; } // 如果 isSuccess 是 null 或 undefined,则不添加额外 class,显示默认的黑灰色样式 toast.className = toastClass; // --- [修正结束] --- const modalContent = document.querySelector('.s1p-modal-content'); if (modalContent) { modalContent.appendChild(toast); } else { document.body.appendChild(toast); } currentToast = toast; // 让动画生效 setTimeout(() => { toast.classList.add('visible'); }, 50); // 3秒后自动消失 setTimeout(() => { toast.classList.remove('visible'); toast.addEventListener('transitionend', () => { if (toast.parentNode) { toast.remove(); } if (currentToast === toast) { currentToast = null; } }, { once: true }); }, 3000); }; const createConfirmationModal = (title, subtitle, onConfirm, confirmText = '确定') => { document.querySelector('.s1p-confirm-modal')?.remove(); const modal = document.createElement('div'); modal.className = 's1p-confirm-modal'; modal.innerHTML = `
${title}
${subtitle}
`; const closeModal = () => { modal.querySelector('.s1p-confirm-content').style.animation = 's1p-scale-out 0.25s ease-out forwards'; modal.style.animation = 's1p-fade-out 0.25s ease-out forwards'; setTimeout(() => modal.remove(), 250); }; modal.querySelector('.s1p-confirm').addEventListener('click', () => { onConfirm(); closeModal(); }); modal.querySelector('.s1p-cancel').addEventListener('click', closeModal); modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); document.body.appendChild(modal); }; /** * [MODIFIED] 创建一个行内确认菜单 (V2: 带智能定位和动画) * @param {HTMLElement} anchorElement - 菜单定位的锚点元素 * @param {string} confirmText - 确认提示文本 * @param {Function} onConfirm - 点击确认后执行的回调函数 */ const createInlineConfirmMenu = (anchorElement, confirmText, onConfirm) => { document.querySelector('.s1p-inline-confirm-menu')?.remove(); const menu = document.createElement('div'); menu.className = 's1p-options-menu s1p-inline-confirm-menu'; menu.style.width = 'max-content'; menu.innerHTML = `
${confirmText}
`; document.body.appendChild(menu); const anchorRect = anchorElement.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); const top = anchorRect.top + window.scrollY + (anchorRect.height / 2) - (menuRect.height / 2); let left; const spaceOnRight = window.innerWidth - anchorRect.right; const requiredSpace = menuRect.width + 16; if (spaceOnRight >= requiredSpace) { left = anchorRect.right + window.scrollX + 8; } else { left = anchorRect.left + window.scrollX - menuRect.width - 8; } if (left < window.scrollX) { left = window.scrollX + 8; } menu.style.top = `${top}px`; menu.style.left = `${left}px`; let isClosing = false; const closeMenu = () => { if (isClosing) return; isClosing = true; document.removeEventListener('click', closeMenu); menu.classList.remove('visible'); setTimeout(() => { if (menu.parentNode) { menu.remove(); } }, 200); }; menu.querySelector('.s1p-confirm').addEventListener('click', (e) => { e.stopPropagation(); onConfirm(); closeMenu(); }); menu.querySelector('.s1p-cancel').addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); }); requestAnimationFrame(() => { menu.classList.add('visible'); }); setTimeout(() => { document.addEventListener('click', closeMenu, { once: true }); }, 0); }; const removeProgressJumpButtons = () => document.querySelectorAll('.s1p-progress-container').forEach(el => el.remove()); const removeBlockButtonsFromThreads = () => document.querySelectorAll('.s1p-options-cell').forEach(el => el.remove()); const refreshUserPostsOnPage = (userId) => { if (!userId) return; document.querySelectorAll(`.authi a[href*="space-uid-${userId}"]`).forEach(userLink => { const postTable = userLink.closest('table[id^="pid"]'); if (postTable) { const postId = postTable.id.replace('pid', ''); refreshSinglePostActions(postId); } }); }; // [S1 PLUS 整合版] // --- 操作: 用下面的完整代码块替换现有的 'createManagementModal' 函数。 // --- 优点: 添加了新UI开关并提供了清晰的独立性描述 (来自 cosmos)。 const createManagementModal = () => { // ... (此处省略 calculateModalWidth 内部代码,与原文件一致) ... const calculateModalWidth = () => { const measureContainer = document.createElement('div'); measureContainer.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden; pointer-events: none;'; const tabsDiv = document.createElement('div'); tabsDiv.className = 's1p-tabs'; tabsDiv.style.display = 'inline-flex'; tabsDiv.innerHTML = ` `; measureContainer.appendChild(tabsDiv); document.body.appendChild(measureContainer); let totalTabsWidth = 0; tabsDiv.querySelectorAll('.s1p-tab-btn').forEach(btn => { const style = window.getComputedStyle(btn); totalTabsWidth += btn.offsetWidth + parseFloat(style.marginLeft) + parseFloat(style.marginRight); }); document.body.removeChild(measureContainer); return totalTabsWidth + 32; // 32px for padding }; const requiredWidth = calculateModalWidth(); document.querySelector('.s1p-modal')?.remove(); const modal = document.createElement('div'); modal.className = 's1p-modal'; modal.style.opacity = '0'; modal.innerHTML = `
S1 Plus 设置
本地备份与恢复
通过手动复制/粘贴数据,在不同浏览器或设备间迁移或备份你的所有S1 Plus配置,包括屏蔽列表、导航栏、阅读进度和各项开关设置。
远程同步 (通过GitHub Gist)

启用后,你可以在导航栏手动同步,或开启下面的自动同步。

启用后,每天第一次打开论坛时会自动检查并同步数据。此功能独立于下方的“自动后台同步”。

启用后,数据将在停止操作5秒后自动同步。关闭后将切换为纯手动同步模式。

点击此处查看设置教程

Token只会保存在你的浏览器本地,不会上传到任何地方。

危险操作
以下操作会立即清空脚本在当前浏览器中的所选数据,且无法撤销。请在操作前务必通过“导出数据”功能进行备份。
`; // ... (此处省略 modal 宽度计算、tabs 定义、渲染逻辑等,与原文件一致) ... const modalContent = modal.querySelector('.s1p-modal-content'); if (requiredWidth > 600) { modalContent.style.width = `${requiredWidth}px`; } document.body.appendChild(modal); updateLastSyncTimeDisplay(); const tabs = { 'general-settings': modal.querySelector('#s1p-tab-general-settings'), 'threads': modal.querySelector('#s1p-tab-threads'), 'users': modal.querySelector('#s1p-tab-users'), 'tags': modal.querySelector('#s1p-tab-tags'), 'bookmarks': modal.querySelector('#s1p-tab-bookmarks'), 'nav-settings': modal.querySelector('#s1p-tab-nav-settings'), 'sync': modal.querySelector('#s1p-tab-sync'), }; const dataClearanceConfig = { blockedThreads: { label: '手动屏蔽的帖子和用户主题帖', clear: () => saveBlockedThreads({}) }, blockedUsers: { label: '屏蔽的用户列表', clear: () => saveBlockedUsers({}) }, userTags: { label: '全部用户标记', clear: () => saveUserTags({}) }, titleFilterRules: { label: '标题关键字屏蔽规则', clear: () => { saveTitleFilterRules([]); GM_setValue('s1p_title_keywords', null); } }, readProgress: { label: '所有帖子阅读进度', clear: () => saveReadProgress({}) }, bookmarkedReplies: { label: '收藏的回复', clear: () => saveBookmarkedReplies({}) }, settings: { label: '界面、导航栏及其他设置', clear: () => saveSettings(defaultSettings) } }; const clearDataOptionsContainer = modal.querySelector('#s1p-clear-data-options'); if (clearDataOptionsContainer) { clearDataOptionsContainer.innerHTML = Object.keys(dataClearanceConfig).map(key => `
`).join(''); } const remoteToggle = modal.querySelector('#s1p-remote-enabled-toggle'); const controlsWrapper = modal.querySelector('#s1p-remote-sync-controls-wrapper'); const updateRemoteSyncInputsState = () => { const isMasterEnabled = remoteToggle.checked; controlsWrapper.classList.toggle('is-disabled', !isMasterEnabled); controlsWrapper.querySelectorAll('[data-s1p-sync-control]').forEach(el => { el.disabled = !isMasterEnabled; }); }; const settings = getSettings(); remoteToggle.checked = settings.syncRemoteEnabled; modal.querySelector('#s1p-daily-first-load-sync-enabled-toggle').checked = settings.syncDailyFirstLoad; modal.querySelector('#s1p-auto-sync-enabled-toggle').checked = settings.syncAutoEnabled; modal.querySelector('#s1p-remote-gist-id-input').value = settings.syncRemoteGistId || ''; modal.querySelector('#s1p-remote-pat-input').value = settings.syncRemotePat || ''; remoteToggle.addEventListener('change', updateRemoteSyncInputsState); updateRemoteSyncInputsState(); // ... (此处省略所有 render...Tab 函数的内部代码和事件绑定逻辑,与原文件一致) ... // [REFACTORED] 全新用户标记标签页渲染逻辑 const renderTagsTab = (options = {}) => { const editingUserId = options.editingUserId; const settings = getSettings(); const isEnabled = settings.enableUserTagging; const toggleHTML = `
`; const userTags = getUserTags(); const tagItems = Object.entries(userTags).sort(([, a], [, b]) => (b.timestamp || 0) - (a.timestamp || 0)); const contentHTML = `
用户标记管理

在此集中管理、编辑、导出或导入您为所有用户添加的标记。

已标记用户列表
${tagItems.length === 0 ? `
暂无用户标记
` : `
${tagItems.map(([id, data]) => { if (id === editingUserId) { // --- 编辑模式 --- return `
${data.name}
ID: ${id}
`; } else { // --- 正常显示模式 --- return `
${data.name}
ID: ${id}   标记于: ${formatDate(data.timestamp)}
${data.tag}
`; } }).join('')}
` }
`; tabs['tags'].innerHTML = ` ${toggleHTML}
${contentHTML}
`; if (editingUserId) { const textarea = tabs['tags'].querySelector('.s1p-tag-edit-area'); if (textarea) { textarea.focus(); textarea.selectionStart = textarea.selectionEnd = textarea.value.length; } } }; const renderBookmarksTab = () => { const settings = getSettings(); const isEnabled = settings.enableBookmarkReplies; const toggleHTML = `
`; const bookmarkedReplies = getBookmarkedReplies(); const bookmarkItems = Object.values(bookmarkedReplies).sort((a, b) => b.timestamp - a.timestamp); const hasBookmarks = bookmarkItems.length > 0; const contentHTML = ` ${hasBookmarks ? `
` : ''}
${!hasBookmarks ? `
暂无收藏的回复
` : `
${bookmarkItems.map(item => { const fullText = item.postContent || ''; const isLong = fullText.length > 150; let contentBlock; if (isLong) { const previewText = fullText.substring(0, 150); contentBlock = `
${previewText}... 查看完整回复
`; } else { contentBlock = `
${fullText}
`; } return `
${contentBlock}
${item.authorName} · 收藏于: ${formatDate(item.timestamp)}
来自帖子: ${item.threadTitle}
`; }).join('')}
` }
`; tabs['bookmarks'].innerHTML = ` ${toggleHTML}
${contentHTML}
`; tabs['bookmarks'].addEventListener('click', e => { const toggleLink = e.target.closest('[data-action="toggle-bookmark-content"]'); if (toggleLink) { e.preventDefault(); e.stopPropagation(); const contentItem = toggleLink.closest('.s1p-item-content'); if (!contentItem) return; const preview = contentItem.querySelector('.s1p-bookmark-preview'); const full = contentItem.querySelector('.s1p-bookmark-full'); if (!preview || !full) return; const isCurrentlyCollapsed = window.getComputedStyle(full).display === 'none'; if (isCurrentlyCollapsed) { full.style.display = 'block'; preview.style.display = 'none'; } else { full.style.display = 'none'; preview.style.display = 'block'; } } }); if (hasBookmarks) { setupBookmarkSearchComponent(tabs['bookmarks']); } }; const renderUserTab = () => { const settings = getSettings(); const isEnabled = settings.enableUserBlocking; const toggleHTML = `
`; const blockedUsers = getBlockedUsers(); const userItemIds = Object.keys(blockedUsers).sort((a, b) => blockedUsers[b].timestamp - blockedUsers[a].timestamp); const contentHTML = `

提示:顶部总开关仅影响未来新屏蔽用户的默认设置。每个用户下方的独立开关,才是控制该用户主题帖的最终开关,拥有最高优先级。

${userItemIds.length === 0 ? `
暂无屏蔽的用户
` : `
${userItemIds.map(id => { const item = blockedUsers[id]; return `
${item.name || `用户 #${id}`}
屏蔽时间: ${formatDate(item.timestamp)}
屏蔽该用户的主题帖
`; }).join('')}
` }
`; tabs['users'].innerHTML = ` ${toggleHTML}
${contentHTML}
`; }; const renderThreadTab = () => { const settings = getSettings(); const isEnabled = settings.enablePostBlocking; const toggleHTML = `
`; const blockedThreads = getBlockedThreads(); const manualItemIds = Object.keys(blockedThreads).sort((a, b) => blockedThreads[b].timestamp - blockedThreads[a].timestamp); const contentHTML = `
标题关键字屏蔽规则

将自动屏蔽标题匹配已启用规则的帖子,支持正则表达式。修改后请点击“保存规则”以生效。

当前页面被关键字屏蔽的帖子
手动屏蔽的帖子列表
${manualItemIds.length === 0 ? `
暂无手动屏蔽的帖子
` : `
${manualItemIds.map(id => { const item = blockedThreads[id]; return `
${item.title || `帖子 #${id}`}
屏蔽时间: ${formatDate(item.timestamp)} ${item.reason && item.reason !== 'manual' ? `(因屏蔽用户${item.reason.replace('user_', '')})` : ''}
`; }).join('')}
` }
`; tabs['threads'].innerHTML = ` ${toggleHTML}
${contentHTML}
`; const renderDynamicallyHiddenList = () => { const listContainer = tabs['threads'].querySelector('#s1p-dynamically-hidden-list'); const hiddenItems = Object.entries(dynamicallyHiddenThreads); if (hiddenItems.length === 0) { listContainer.innerHTML = `
当前页面没有被关键字屏蔽的帖子
`; } else { listContainer.innerHTML = `
${hiddenItems.map(([id, item]) => `
${item.title}
匹配规则: ${item.pattern}
`).join('')}
`; } }; const renderRules = () => { const rules = getTitleFilterRules(); const container = tabs['threads'].querySelector('#s1p-keyword-rules-list'); if (!container) return; container.innerHTML = rules.map(rule => `
`).join(''); if (rules.length === 0) { container.innerHTML = `
暂无规则
`; } }; renderRules(); renderDynamicallyHiddenList(); const saveKeywordRules = () => { const newRules = []; tabs['threads'].querySelectorAll('#s1p-keyword-rules-list .s1p-editor-item').forEach(item => { const pattern = item.querySelector('.s1p-keyword-rule-pattern').value.trim(); if (pattern) { let id = item.dataset.ruleId; if (id.startsWith('new_')) { id = `rule_${Date.now()}_${Math.random()}`; } newRules.push({ id: id, enabled: item.querySelector('.s1p-keyword-rule-enable').checked, pattern: pattern }); } }); saveTitleFilterRules(newRules); hideThreadsByTitleKeyword(); renderDynamicallyHiddenList(); renderRules(); }; tabs['threads'].addEventListener('click', e => { const target = e.target; const header = target.closest('.s1p-collapsible-header'); if (header) { if (header.id === 's1p-blocked-by-keyword-header') { const currentSettings = getSettings(); const isNowExpanded = !currentSettings.showBlockedByKeywordList; currentSettings.showBlockedByKeywordList = isNowExpanded; saveSettings(currentSettings); header.querySelector('.s1p-expander-arrow').classList.toggle('expanded', isNowExpanded); tabs['threads'].querySelector('#s1p-dynamically-hidden-list-container').classList.toggle('expanded', isNowExpanded); } else if (header.id === 's1p-manually-blocked-header') { const currentSettings = getSettings(); const isNowExpanded = !currentSettings.showManuallyBlockedList; currentSettings.showManuallyBlockedList = isNowExpanded; saveSettings(currentSettings); header.querySelector('.s1p-expander-arrow').classList.toggle('expanded', isNowExpanded); tabs['threads'].querySelector('#s1p-manually-blocked-list-container').classList.toggle('expanded', isNowExpanded); } } else if (target.id === 's1p-keyword-rule-add-btn') { const container = tabs['threads'].querySelector('#s1p-keyword-rules-list'); const emptyMsg = container.querySelector('.s1p-empty'); if (emptyMsg) emptyMsg.remove(); const newItem = document.createElement('div'); newItem.className = 's1p-editor-item'; newItem.dataset.ruleId = `new_${Date.now()}`; newItem.innerHTML = `
`; container.appendChild(newItem); newItem.querySelector('input[type="text"]').focus(); } else if (target.classList.contains('s1p-keyword-rule-delete')) { const item = target.closest('.s1p-editor-item'); item.remove(); const container = tabs['threads'].querySelector('#s1p-keyword-rules-list'); if (container.children.length === 0) { container.innerHTML = `
暂无规则
`; } } else if (target.id === 's1p-keyword-rules-save-btn') { saveKeywordRules(); showMessage('规则已保存!', true); } }); }; const renderGeneralSettingsTab = () => { const settings = getSettings(); tabs['general-settings'].innerHTML = `
功能开关
1个月
3个月
6个月
永不
通用设置
`; const moveSlider = (control, retries = 3) => { if (!control || retries <= 0) return; const slider = control.querySelector('.s1p-segmented-control-slider'); const activeOption = control.querySelector('.s1p-segmented-control-option.active'); if (slider && activeOption) { const width = activeOption.offsetWidth; if (width === 0) { setTimeout(() => moveSlider(control, retries - 1), 50); return; } slider.style.width = `${width}px`; slider.style.left = `${activeOption.offsetLeft}px`; } }; const openInNewTabCheckbox = tabs['general-settings'].querySelector('#s1p-openProgressInNewTab'); const openInBackgroundItem = tabs['general-settings'].querySelector('#s1p-openProgressInBackground-item'); const openThreadsInNewTabCheckbox = tabs['general-settings'].querySelector('#s1p-openThreadsInNewTab'); const openThreadsInBackgroundItem = tabs['general-settings'].querySelector('#s1p-openThreadsInBackground-item'); openInNewTabCheckbox.addEventListener('change', (e) => { openInBackgroundItem.style.display = e.target.checked ? 'flex' : 'none'; }); openThreadsInNewTabCheckbox.addEventListener('change', (e) => { openThreadsInBackgroundItem.style.display = e.target.checked ? 'flex' : 'none'; }); const cleanupControl = tabs['general-settings'].querySelector('#s1p-readingProgressCleanupDays-control'); if (cleanupControl) { setTimeout(() => moveSlider(cleanupControl), 0); cleanupControl.addEventListener('click', (e) => { const target = e.target.closest('.s1p-segmented-control-option'); if (!target || target.classList.contains('active')) return; const newValue = parseInt(target.dataset.value, 10); const currentSettings = getSettings(); currentSettings.readingProgressCleanupDays = newValue; saveSettings(currentSettings); cleanupControl.querySelectorAll('.s1p-segmented-control-option').forEach(opt => opt.classList.remove('active')); target.classList.add('active'); moveSlider(cleanupControl); }); } tabs['general-settings'].addEventListener('change', e => { const target = e.target; const settingKey = target.dataset.setting; if (settingKey) { const settings = getSettings(); if (target.type === 'checkbox') { settings[settingKey] = target.checked; } else if (target.type === 'number' || target.tagName === 'SELECT') { settings[settingKey] = parseInt(target.value, 10); } else { settings[settingKey] = target.value; } saveSettings(settings); applyInterfaceCustomizations(); if (settingKey === 'hideImagesByDefault') { applyImageHiding(); manageImageToggleAllButtons(); } if (settingKey === 'openThreadsInNewTab' || settingKey === 'openThreadsInBackground') { applyThreadLinkBehavior(); applyPageLinkBehavior(); } if (settingKey === 'openProgressInNewTab' || settingKey === 'openProgressInBackground') { removeProgressJumpButtons(); addProgressJumpButtons(); } } }); }; const renderNavSettingsTab = () => { const settings = getSettings(); tabs['nav-settings'].innerHTML = `
`; const navListContainer = tabs['nav-settings'].querySelector('.s1p-nav-editor-list'); const renderNavList = (links) => { navListContainer.innerHTML = (links || []).map((link, index) => `
::
`).join(''); }; renderNavList(settings.customNavLinks); let draggedItem = null; navListContainer.addEventListener('dragstart', e => { if (e.target.classList.contains('s1p-editor-item')) { draggedItem = e.target; setTimeout(() => { e.target.classList.add('s1p-dragging'); }, 0); } }); navListContainer.addEventListener('dragend', e => { if (draggedItem) { draggedItem.classList.remove('s1p-dragging'); draggedItem = null; } }); navListContainer.addEventListener('dragover', e => { e.preventDefault(); if (!draggedItem) return; const container = e.currentTarget; const otherItems = [...container.querySelectorAll('.s1p-editor-item:not(.s1p-dragging)')]; const nextSibling = otherItems.find(item => { const rect = item.getBoundingClientRect(); return e.clientY < rect.top + rect.height / 2; }); if (nextSibling) { container.insertBefore(draggedItem, nextSibling); } else { container.appendChild(draggedItem); } }); tabs['nav-settings'].addEventListener('click', e => { const target = e.target; if (target.id === 's1p-nav-add-btn') { const newItem = document.createElement('div'); newItem.className = 's1p-editor-item'; newItem.draggable = true; newItem.style.gridTemplateColumns = 'auto 1fr 1fr auto'; newItem.innerHTML = `
::
`; navListContainer.appendChild(newItem); } else if (target.dataset.action === 'delete') { target.closest('.s1p-editor-item').remove(); } else if (target.id === 's1p-nav-restore-btn') { const currentSettings = getSettings(); currentSettings.enableNavCustomization = defaultSettings.enableNavCustomization; currentSettings.customNavLinks = defaultSettings.customNavLinks; saveSettings(currentSettings); renderNavSettingsTab(); applyInterfaceCustomizations(); initializeNavbar(); showMessage('导航栏已恢复为默认设置!', true); } else if (target.id === 's1p-settings-save-btn') { const newSettings = { ...getSettings(), enableNavCustomization: tabs['nav-settings'].querySelector('#s1p-enableNavCustomization').checked, customNavLinks: Array.from(navListContainer.querySelectorAll('.s1p-editor-item')).map(item => ({ name: item.querySelector('.s1p-nav-name').value.trim(), href: item.querySelector('.s1p-nav-href').value.trim() })).filter(l => l.name && l.href) }; saveSettings(newSettings); applyInterfaceCustomizations(); initializeNavbar(); showMessage('设置已保存!', true); } }); }; // --- 初始化渲染和事件绑定 --- renderGeneralSettingsTab(); renderThreadTab(); renderUserTab(); renderTagsTab(); renderBookmarksTab(); renderNavSettingsTab(); modal.style.transition = 'opacity 0.2s ease-out'; requestAnimationFrame(() => { modal.style.opacity = '1'; }); modal.addEventListener('change', e => { const target = e.target; const settings = getSettings(); const featureKey = target.dataset.feature; if (featureKey && target.classList.contains('s1p-feature-toggle')) { const isChecked = target.checked; settings[featureKey] = isChecked; const contentWrapper = target.closest('.s1p-settings-item')?.nextElementSibling; if (contentWrapper && contentWrapper.classList.contains('s1p-feature-content')) { contentWrapper.classList.toggle('expanded', isChecked); } saveSettings(settings); switch (featureKey) { case 'enablePostBlocking': isChecked ? addBlockButtonsToThreads() : removeBlockButtonsFromThreads(); break; case 'enableUserBlocking': refreshAllAuthiActions(); isChecked ? hideBlockedUsersPosts() : Object.keys(getBlockedUsers()).forEach(showUserPosts); hideBlockedUserQuotes(); hideBlockedUserRatings(); break; case 'enableUserTagging': refreshAllAuthiActions(); break; case 'enableReadProgress': document.getElementById('s1p-readingProgressCleanupContainer').style.display = isChecked ? 'flex' : 'none'; isChecked ? addProgressJumpButtons() : removeProgressJumpButtons(); break; case 'enableBookmarkReplies': refreshAllAuthiActions(); break; } return; } else if (target.matches('.s1p-user-thread-block-toggle')) { const userId = target.dataset.userId; const blockThreads = target.checked; const users = getBlockedUsers(); if (users[userId]) { users[userId].blockThreads = blockThreads; saveBlockedUsers(users); if (blockThreads) applyUserThreadBlocklist(); else unblockThreadsByUser(userId); renderThreadTab(); } } else if (target.matches('#s1p-blockThreadsOnUserBlock')) { const currentSettings = getSettings(); currentSettings.blockThreadsOnUserBlock = target.checked; saveSettings(currentSettings); } }); /** * [OPTIMIZED] 从UI列表中移除一个项目,并在列表为空时显示提示信息。 * @param {HTMLElement} triggerElement - 触发删除操作的元素(如按钮)。 * @param {string} emptyHTML - 当列表为空时,要设置给容器的innerHTML。 * @param {function} [onEmptyCallback] - 列表变为空后执行的可选回调函数。 */ function removeListItem(triggerElement, emptyHTML, onEmptyCallback) { const item = triggerElement.closest('.s1p-item'); if (!item) return; const list = item.parentElement; item.remove(); if (list && list.children.length === 0) { const container = list.parentElement; if (container) { container.innerHTML = emptyHTML; } if (onEmptyCallback) { onEmptyCallback(container); } } } modal.addEventListener('click', async (e) => { const target = e.target; if (e.target.matches('.s1p-modal, .s1p-modal-close')) modal.remove(); if (e.target.matches('.s1p-tab-btn')) { modal.querySelectorAll('.s1p-tab-btn, .s1p-tab-content').forEach(el => el.classList.remove('active')); e.target.classList.add('active'); const activeTab = tabs[e.target.dataset.tab]; if (activeTab) activeTab.classList.add('active'); } const unblockThreadId = e.target.dataset.unblockThreadId; if (unblockThreadId) { unblockThread(unblockThreadId); removeListItem(target, '
暂无手动屏蔽的帖子
'); } const unblockUserId = e.target.dataset.unblockUserId; if (unblockUserId) { // [OPTIMIZED] 深度联动优化,避免重绘帖子列表 const allBlockedThreads = getBlockedThreads(); const threadsToUnblock = Object.keys(allBlockedThreads).filter(threadId => allBlockedThreads[threadId].reason === `user_${unblockUserId}`); unblockUser(unblockUserId); removeListItem(target, '
暂无屏蔽的用户
'); const threadList = document.querySelector('#s1p-manually-blocked-list-container .s1p-list'); if (threadList) { threadsToUnblock.forEach(threadId => { const threadItemToRemove = threadList.querySelector(`.s1p-item[data-thread-id="${threadId}"]`); threadItemToRemove?.remove(); }); if (threadList.children.length === 0) { const container = threadList.closest('#s1p-manually-blocked-list-container'); if (container) { container.innerHTML = '
暂无手动屏蔽的帖子
'; } } } } const removeBookmarkId = target.closest('[data-action="remove-bookmark"]')?.dataset.postId; if (removeBookmarkId) { const bookmarks = getBookmarkedReplies(); delete bookmarks[removeBookmarkId]; saveBookmarkedReplies(bookmarks); refreshSinglePostActions(removeBookmarkId); removeListItem(target, '
暂无收藏的回复
', () => { // 列表清空后,移除搜索框 document.querySelector('#s1p-bookmark-search-input')?.closest('.s1p-settings-group')?.remove(); }); } // --- 本地备份与恢复事件 (已优化) --- const syncTextarea = modal.querySelector('#s1p-local-sync-textarea'); if (e.target.id === 's1p-local-export-btn') { const dataToExport = await exportLocalData(); syncTextarea.value = dataToExport; syncTextarea.select(); navigator.clipboard.writeText(dataToExport).then(() => { showMessage('数据已导出并复制到剪贴板', true); }).catch(() => { showMessage('自动复制失败,请手动复制', false); }); } if (e.target.id === 's1p-local-import-btn') { const jsonStr = syncTextarea.value.trim(); if (!jsonStr) return showMessage('请先粘贴要导入的数据', false); const result = importLocalData(jsonStr); showMessage(result.message, result.success); if (result.success) { renderThreadTab(); renderUserTab(); renderGeneralSettingsTab(); renderTagsTab(); renderBookmarksTab(); } } if (e.target.id === 's1p-clear-select-all') { const isChecked = e.target.checked; modal.querySelectorAll('.s1p-clear-data-checkbox').forEach(chk => chk.checked = isChecked); } if (e.target.id === 's1p-clear-selected-btn') { const selectedKeys = Array.from(modal.querySelectorAll('.s1p-clear-data-checkbox:checked')).map(chk => chk.dataset.clearKey); if (selectedKeys.length === 0) { return showMessage('请至少选择一个要清除的数据项。', false); } const itemsToClear = selectedKeys.map(key => `“${dataClearanceConfig[key].label}”`).join('、'); createConfirmationModal( '确认要清除所选数据吗?', `即将删除 ${itemsToClear} 的所有数据,此操作不可逆!`, () => { selectedKeys.forEach(key => { if (dataClearanceConfig[key]) { dataClearanceConfig[key].clear(); } }); if (selectedKeys.includes('settings')) { modal.querySelector('#s1p-remote-enabled-toggle').checked = false; modal.querySelector('#s1p-daily-first-load-sync-enabled-toggle').checked = true; // [整合] 重置新开关 modal.querySelector('#s1p-auto-sync-enabled-toggle').checked = true; modal.querySelector('#s1p-remote-gist-id-input').value = ''; modal.querySelector('#s1p-remote-pat-input').value = ''; updateRemoteSyncInputsState(); } // 全局刷新 hideBlockedThreads(); hideBlockedUsersPosts(); applyUserThreadBlocklist(); hideThreadsByTitleKeyword(); initializeNavbar(); applyInterfaceCustomizations(); document.querySelectorAll('.s1p-progress-container').forEach(el => el.remove()); // 重新渲染所有标签页 renderThreadTab(); renderUserTab(); renderGeneralSettingsTab(); renderTagsTab(); renderBookmarksTab(); showMessage('选中的本地数据已成功清除。', true); }, '确认清除' ); } if (e.target.id === 's1p-remote-save-btn') { const currentSettings = getSettings(); currentSettings.syncRemoteEnabled = modal.querySelector('#s1p-remote-enabled-toggle').checked; currentSettings.syncDailyFirstLoad = modal.querySelector('#s1p-daily-first-load-sync-enabled-toggle').checked; // [整合] 保存新开关 currentSettings.syncAutoEnabled = modal.querySelector('#s1p-auto-sync-enabled-toggle').checked; currentSettings.syncRemoteGistId = modal.querySelector('#s1p-remote-gist-id-input').value.trim(); currentSettings.syncRemotePat = modal.querySelector('#s1p-remote-pat-input').value.trim(); saveSettings(currentSettings); updateNavbarSyncButton(); // 保存后更新导航栏按钮 showMessage('远程同步设置已保存。', true); } if (e.target.id === 's1p-remote-manual-sync-btn') { handleManualSync(); } if (e.target.id === 's1p-open-gist-page-btn') { const gistId = modal.querySelector('#s1p-remote-gist-id-input').value.trim(); if (gistId) { GM_openInTab(`https://gist.github.com/${gistId}`, true); } else { showMessage('请先填写 Gist ID。', false); } } // --- 用户标记标签页专属事件 --- const targetTab = target.closest('#s1p-tab-tags'); if (targetTab) { const action = target.dataset.action; const userId = target.dataset.userId; if (action === 'edit-tag-item') renderTagsTab({ editingUserId: userId }); if (action === 'cancel-tag-edit') renderTagsTab(); if (action === 'delete-tag-item') { const userName = target.dataset.userName; createConfirmationModal(`确认删除对 "${userName}" 的标记吗?`, '此操作不可撤销。', () => { const tags = getUserTags(); delete tags[userId]; saveUserTags(tags); refreshUserPostsOnPage(userId); removeListItem(target, `
暂无用户标记
`); showMessage(`已删除对 ${userName} 的标记。`, true); }, '确认删除'); } else if (action === 'save-tag-edit') { const userName = target.dataset.userName; const newTag = targetTab.querySelector(`.s1p-item[data-user-id="${userId}"] .s1p-tag-edit-area`).value.trim(); const tags = getUserTags(); if (newTag) { tags[userId] = { ...tags[userId], tag: newTag, timestamp: Date.now(), name: userName }; saveUserTags(tags); refreshUserPostsOnPage(userId); renderTagsTab(); // 保存后需要重绘以退出编辑模式 showMessage(`已更新对 ${userName} 的标记。`, true); } else { createConfirmationModal(`标记内容为空`, '您希望删除对该用户的标记吗?', () => { delete tags[userId]; saveUserTags(tags); refreshUserPostsOnPage(userId); renderTagsTab(); // 删除后需要重绘 showMessage(`已删除对 ${userName} 的标记。`, true); }, '确认删除'); } } else if (target.id === 's1p-export-tags-btn') { const textarea = targetTab.querySelector('#s1p-tags-sync-textarea'); const dataToExport = JSON.stringify(getUserTags(), null, 2); textarea.value = dataToExport; textarea.select(); navigator.clipboard.writeText(dataToExport).then(() => { showMessage('用户标记已导出并复制到剪贴板。', true); }).catch(() => { showMessage('复制失败,请手动复制。', false); }); } else if (target.id === 's1p-import-tags-btn') { const textarea = targetTab.querySelector('#s1p-tags-sync-textarea'); const jsonStr = textarea.value.trim(); if (!jsonStr) return showMessage('请先粘贴要导入的数据。', false); try { const imported = JSON.parse(jsonStr); if (typeof imported !== 'object' || imported === null || Array.isArray(imported)) throw new Error("无效数据格式,应为一个对象。"); for (const key in imported) { const item = imported[key]; if (typeof item !== 'object' || item === null || typeof item.tag === 'undefined' || typeof item.name === 'undefined') throw new Error(`用户 #${key} 的数据格式不正确。`); } createConfirmationModal('确认导入用户标记吗?', '导入的数据将覆盖现有相同用户的标记。', () => { const currentTags = getUserTags(); const mergedTags = { ...currentTags, ...imported }; saveUserTags(mergedTags); renderTagsTab(); showMessage(`成功导入/更新 ${Object.keys(imported).length} 条用户标记。`, true); textarea.value = ''; refreshAllAuthiActions(); }, '确认导入'); } catch (e) { showMessage(`导入失败: ${e.message}`, false); } } } }); }; /** * [OPTIMIZED] 手动同步处理器,解耦UI逻辑并返回布尔值结果。 * @returns {Promise} 返回 true 表示成功, false 表示失败, null 表示用户取消操作。 */ const handleManualSync = () => { return new Promise(async (resolve) => { const settings = getSettings(); if (!settings.syncRemoteEnabled || !settings.syncRemoteGistId || !settings.syncRemotePat) { showMessage('远程同步未启用或配置不完整。', false); return resolve(false); } showMessage('正在检查云端数据...', null); try { const rawRemoteData = await fetchRemoteData(); if (Object.keys(rawRemoteData).length === 0) { const pushAction = { text: '推送本地数据到云端', className: 's1p-confirm', action: async () => { showMessage('正在向云端推送数据...', null); try { const localData = await exportLocalDataObject(); await pushRemoteData(localData); GM_setValue('s1p_last_sync_timestamp', Date.now()); updateLastSyncTimeDisplay(); showMessage('推送成功!已初始化云端备份。', true); resolve(true); } catch (e) { showMessage(`推送失败: ${e.message}`, false); resolve(false); } } }; const cancelAction = { text: '取消', className: 's1p-cancel', action: () => { showMessage('操作已取消。', null); resolve(null); } }; createAdvancedConfirmationModal('初始化云端同步', '

检测到云端备份为空,是否将当前本地数据作为初始版本推送到云端?

', [pushAction, cancelAction]); return; } const remote = await migrateAndValidateRemoteData(rawRemoteData); const localDataObject = await exportLocalDataObject(); if (remote.contentHash === localDataObject.contentHash) { showMessage('数据已是最新,无需同步。', true); GM_setValue('s1p_last_sync_timestamp', Date.now()); updateLastSyncTimeDisplay(); return resolve(true); } const formatForDisplay = (ts) => new Date(ts || 0).toLocaleString('zh-CN', { hour12: false }); const localNewer = localDataObject.lastUpdated > remote.lastUpdated; const isConflict = localDataObject.lastUpdated === remote.lastUpdated; let bodyHtml = isConflict ? `

警告:检测到同步冲突!

两份数据的时间戳相同但内容不同。请仔细选择您希望保留的版本。

` : `

检测到本地数据与云端备份不一致,请选择同步方式:

`; bodyHtml += `
本地数据:${formatForDisplay(localDataObject.lastUpdated)}
云端备份:${formatForDisplay(remote.lastUpdated)}
`; const pullAction = { text: '从云端拉取', className: 's1p-confirm', action: () => { const result = importLocalData(JSON.stringify(remote.full)); if (result.success) { GM_setValue('s1p_last_sync_timestamp', Date.now()); updateLastSyncTimeDisplay(); showMessage(`拉取成功!页面即将刷新。`, true); setTimeout(() => location.reload(), 1200); resolve(true); } else { showMessage(`导入失败: ${result.message}`, false); resolve(false); } } }; const pushAction = { text: '向云端推送', className: 's1p-confirm', action: async () => { try { await pushRemoteData(localDataObject); GM_setValue('s1p_last_sync_timestamp', Date.now()); updateLastSyncTimeDisplay(); showMessage('推送成功!已更新云端备份。', true); resolve(true); } catch (e) { showMessage(`推送失败: ${e.message}`, false); resolve(false); } } }; const cancelAction = { text: '取消', className: 's1p-cancel', action: () => { showMessage('操作已取消。', null); resolve(null); } }; createAdvancedConfirmationModal('手动同步选择', bodyHtml, [pullAction, pushAction, cancelAction]); } catch (error) { const corruptionErrorMessage = "云端备份已损坏"; if (error?.message.includes(corruptionErrorMessage)) { const forcePushAction = { text: '强制推送,覆盖云端', className: 's1p-confirm', action: async () => { try { const localDataObjectForPush = await exportLocalDataObject(); await pushRemoteData(localDataObjectForPush); GM_setValue('s1p_last_sync_timestamp', Date.now()); updateLastSyncTimeDisplay(); showMessage('推送成功!已使用本地数据修复云端备份。', true); resolve(true); } catch (e) { showMessage(`强制推送失败: ${e.message}`, false); resolve(false); } } }; const cancelAction = { text: '暂不处理', className: 's1p-cancel', action: () => { showMessage('操作已取消。云端备份仍处于损坏状态。', null); resolve(null); } }; createAdvancedConfirmationModal("检测到云端备份损坏", `

云端备份文件校验失败,为保护数据已暂停同步。

是否用当前健康的本地数据强制覆盖云端损坏的备份?

`, [forcePushAction, cancelAction]); } else { showMessage(`操作失败: ${error.message}`, false); resolve(false); } } }); }; const createAdvancedConfirmationModal = (title, bodyHtml, buttons) => { document.querySelector('.s1p-confirm-modal')?.remove(); const modal = document.createElement('div'); modal.className = 's1p-confirm-modal'; const footerButtons = buttons.map((btn, index) => `` ).join(''); modal.innerHTML = `
${title}
${bodyHtml}
`; const closeModal = () => { modal.querySelector('.s1p-confirm-content').style.animation = 's1p-scale-out 0.25s ease-out forwards'; modal.style.animation = 's1p-fade-out 0.25s ease-out forwards'; setTimeout(() => modal.remove(), 250); }; modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); buttons.forEach((btn, index) => { const buttonEl = modal.querySelector(`[data-btn-index="${index}"]`); if (buttonEl) { buttonEl.addEventListener('click', () => { if (btn.action) btn.action(); closeModal(); }); } }); document.body.appendChild(modal); }; const addBlockButtonsToThreads = () => { document.querySelectorAll('tbody[id^="normalthread_"], tbody[id^="stickthread_"]').forEach(row => { const tr = row.querySelector('tr'); if (!tr || row.querySelector('.s1p-options-cell') || tr.classList.contains('ts') || tr.classList.contains('th')) return; const titleElement = row.querySelector('th a.s.xst'); if (!titleElement) return; const threadId = row.id.replace(/^(normalthread_|stickthread_)/, ''); const threadTitle = titleElement.textContent.trim(); const optionsCell = document.createElement('td'); optionsCell.className = 's1p-options-cell'; const optionsBtn = document.createElement('div'); optionsBtn.className = 's1p-options-btn'; optionsBtn.title = '屏蔽此贴'; optionsBtn.innerHTML = ``; const optionsMenu = document.createElement('div'); optionsMenu.className = 's1p-options-menu'; optionsMenu.innerHTML = `
屏蔽该帖子吗?
`; const cancelBtn = optionsMenu.querySelector('.s1p-cancel'); const confirmBtn = optionsMenu.querySelector('.s1p-confirm'); cancelBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const parentCell = e.currentTarget.closest('.s1p-options-cell'); if (parentCell) { optionsMenu.style.visibility = 'hidden'; optionsMenu.style.opacity = '0'; parentCell.style.pointerEvents = 'none'; setTimeout(() => { optionsMenu.style.removeProperty('visibility'); optionsMenu.style.removeProperty('opacity'); parentCell.style.removeProperty('pointer-events'); }, 200); } }); confirmBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); blockThread(threadId, threadTitle); }); optionsCell.appendChild(optionsBtn); optionsCell.appendChild(optionsMenu); tr.prepend(optionsCell); const headerTh = document.querySelector('#threadlist > .th th:first-child'); if (headerTh) { headerTh.colSpan = 3; } const separatorRow = document.querySelector('#separatorline > tr.ts'); if (separatorRow && separatorRow.childElementCount < 6) { const emptyTd = document.createElement('td'); separatorRow.prepend(emptyTd); } }); }; const initializeTaggingPopover = () => { let popover = document.getElementById('s1p-tag-popover-main'); if (!popover) { popover = document.createElement('div'); popover.id = 's1p-tag-popover-main'; popover.className = 's1p-tag-popover'; document.body.appendChild(popover); } let hideTimeout, showTimeout; let isComposing = false; const startHideTimer = () => { if (isComposing) return; clearTimeout(showTimeout); clearTimeout(hideTimeout); hideTimeout = setTimeout(() => popover.classList.remove('visible'), 300); }; const cancelHideTimer = () => clearTimeout(hideTimeout); const repositionPopover = (anchorElement) => { if (!anchorElement) return; const rect = anchorElement.getBoundingClientRect(); const popoverRect = popover.getBoundingClientRect(); let top = rect.bottom + window.scrollY + 5; let left = rect.left + window.scrollX; if ((left + popoverRect.width) > (window.innerWidth - 10)) { left = window.innerWidth - popoverRect.width - 10; } if (left < 10) { left = 10; } popover.style.top = `${top}px`; popover.style.left = `${left}px`; }; const renderEditMode = (userName, userId, currentTag = '') => { popover.innerHTML = `
为 ${userName} ${currentTag ? '编辑' : '添加'}标记
`; popover.querySelector('textarea').focus(); }; const show = (anchorElement, userId, userName) => { cancelHideTimer(); clearTimeout(showTimeout); showTimeout = setTimeout(() => { popover.dataset.userId = userId; popover.dataset.userName = userName; const userTags = getUserTags(); renderEditMode(userName, userId, userTags[userId]?.tag || ''); popover.classList.add('visible'); repositionPopover(anchorElement); }, 0); }; popover.show = show; popover.addEventListener('click', (e) => { const target = e.target.closest('button[data-action]'); if (!target) return; const { userId, userName } = popover.dataset; const userTags = getUserTags(); switch (target.dataset.action) { case 'save': const newTag = popover.querySelector('textarea').value.trim(); if (newTag) { userTags[userId] = { name: userName, tag: newTag, timestamp: Date.now() }; } else { delete userTags[userId]; } saveUserTags(userTags); refreshUserPostsOnPage(userId); popover.classList.remove('visible'); break; case 'cancel-edit': popover.classList.remove('visible'); break; } }); popover.addEventListener('mouseenter', cancelHideTimer); popover.addEventListener('mouseleave', startHideTimer); popover.addEventListener('compositionstart', () => isComposing = true); popover.addEventListener('compositionend', () => isComposing = false); }; const initializeGenericDisplayPopover = () => { let popover = document.getElementById('s1p-generic-display-popover'); if (!popover) { popover = document.createElement('div'); popover.id = 's1p-generic-display-popover'; popover.className = 's1p-generic-display-popover'; document.body.appendChild(popover); } let showTimeout, hideTimeout; const show = (anchor, text) => { clearTimeout(hideTimeout); showTimeout = setTimeout(() => { popover.textContent = text; const rect = anchor.getBoundingClientRect(); popover.style.display = 'block'; let top = rect.top + window.scrollY - popover.offsetHeight - 6; let left = rect.left + window.scrollX + (rect.width / 2) - (popover.offsetWidth / 2); if (top < window.scrollY) { top = rect.bottom + window.scrollY + 6; } if (left < 10) left = 10; if (left + popover.offsetWidth > window.innerWidth) { left = window.innerWidth - popover.offsetWidth - 10; } popover.style.top = `${top}px`; popover.style.left = `${left}px`; popover.classList.add('visible'); }, 50); }; const hide = () => { clearTimeout(showTimeout); hideTimeout = setTimeout(() => { popover.classList.remove('visible'); }, 100); }; // [MODIFIED] Attach API to the element for external use if (!popover.s1p_api) { popover.s1p_api = { show, hide }; } // Keep existing listeners for user tags document.body.addEventListener('mouseover', e => { const tagDisplay = e.target.closest('.s1p-user-tag-display'); if (tagDisplay && tagDisplay.dataset.fullTag && tagDisplay.scrollWidth > tagDisplay.clientWidth) { show(tagDisplay, tagDisplay.dataset.fullTag); } }); document.body.addEventListener('mouseout', e => { const tagDisplay = e.target.closest('.s1p-user-tag-display'); if (tagDisplay) { hide(); } }); }; const getTimeBasedColor = (hours) => { if (hours <= 1) return 'var(--s1p-progress-hot)'; if (hours <= 24) return `rgb(${Math.round(192 - hours * 4)}, ${Math.round(51 + hours * 2)}, ${Math.round(34 + hours * 2)})`; if (hours <= 168) return `rgb(${Math.round(100 - (hours - 24) / 3)}, ${Math.round(100 + (hours - 24) / 4)}, ${Math.round(80 + (hours - 24) / 4)})`; return 'var(--s1p-progress-cold)'; }; const addProgressJumpButtons = () => { const settings = getSettings(); const progressData = getReadProgress(); if (Object.keys(progressData).length === 0) return; const now = Date.now(); document.querySelectorAll('tbody[id^="normalthread_"], tbody[id^="stickthread_"]').forEach(row => { const container = row.querySelector('th'); if (!container || container.querySelector('.s1p-progress-container')) return; const threadIdMatch = row.id.match(/(?:normalthread_|stickthread_)(\d+)/); if (!threadIdMatch) return; const threadId = threadIdMatch[1]; const progress = progressData[threadId]; if (progress && progress.page) { const { postId, page, timestamp, lastReadFloor: savedFloor } = progress; const hoursDiff = (now - (timestamp || 0)) / 3600000; const fcolor = getTimeBasedColor(hoursDiff); const replyEl = row.querySelector('td.num a.xi2'); const currentReplies = replyEl ? parseInt(replyEl.textContent.replace(/,/g, '')) || 0 : 0; const latestFloor = currentReplies + 1; const newReplies = (savedFloor !== undefined && latestFloor > savedFloor) ? latestFloor - savedFloor : 0; const progressContainer = document.createElement('span'); progressContainer.className = 's1p-progress-container'; const jumpBtn = document.createElement('a'); jumpBtn.className = 's1p-progress-jump-btn'; if (savedFloor) { jumpBtn.textContent = `P${page}-#${savedFloor}`; jumpBtn.title = `跳转至上次离开的第 ${page} 页,第 ${savedFloor} 楼`; } else { jumpBtn.textContent = `P${page}`; jumpBtn.title = `跳转至上次离开的第 ${page} 页`; } jumpBtn.href = `forum.php?mod=redirect&goto=findpost&ptid=${threadId}&pid=${postId}`; jumpBtn.style.color = fcolor; jumpBtn.style.borderColor = fcolor; jumpBtn.addEventListener('click', (e) => { e.preventDefault(); if (settings.openProgressInNewTab) { GM_openInTab(jumpBtn.href, { active: !settings.openProgressInBackground }); } else { window.location.href = jumpBtn.href; } }); jumpBtn.addEventListener('mouseover', () => { jumpBtn.style.backgroundColor = fcolor; jumpBtn.style.color = 'var(--s1p-white)'; }); jumpBtn.addEventListener('mouseout', () => { jumpBtn.style.backgroundColor = 'transparent'; jumpBtn.style.color = fcolor; }); progressContainer.appendChild(jumpBtn); if (newReplies > 0) { const newRepliesBadge = document.createElement('span'); newRepliesBadge.className = 's1p-new-replies-badge'; newRepliesBadge.textContent = `+${newReplies}`; newRepliesBadge.title = `有 ${newReplies} 条新回复`; newRepliesBadge.style.backgroundColor = fcolor; newRepliesBadge.style.borderColor = fcolor; progressContainer.appendChild(newRepliesBadge); jumpBtn.style.borderTopRightRadius = '0'; jumpBtn.style.borderBottomRightRadius = '0'; } container.appendChild(progressContainer); } }); }; const trackReadProgressInThread = () => { const settings = getSettings(); if (!settings.enableReadProgress || !document.getElementById('postlist')) return; let threadId = null; const threadIdMatch = window.location.href.match(/thread-(\d+)-/); if (threadIdMatch) { threadId = threadIdMatch[1]; } else { const params = new URLSearchParams(window.location.search); threadId = params.get('tid') || params.get('ptid'); } if (!threadId) { const tidInput = document.querySelector('input[name="tid"]#tid'); if (tidInput) { threadId = tidInput.value; } } if (!threadId) return; let currentPage = '1'; const threadPageMatch = window.location.href.match(/thread-\d+-(\d+)-/); const params = new URLSearchParams(window.location.search); if (threadPageMatch) { currentPage = threadPageMatch[1]; } else if (params.has('page')) { currentPage = params.get('page'); } else { const currentPageElement = document.querySelector('div.pg strong'); if (currentPageElement && !isNaN(currentPageElement.textContent.trim())) { currentPage = currentPageElement.textContent.trim(); } } let visiblePosts = new Map(); let saveTimeout; const getFloorFromElement = (el) => { const floorElement = el.querySelector('.pi em'); return floorElement ? parseInt(floorElement.textContent) || 0 : 0; }; const saveCurrentProgress = () => { if (visiblePosts.size === 0) return; let maxFloor = 0; let finalPostId = null; visiblePosts.forEach((floor, postId) => { if (floor > maxFloor) { maxFloor = floor; finalPostId = postId; } }); if (finalPostId && maxFloor > 0) { updateThreadProgress(threadId, finalPostId, currentPage, maxFloor); } }; const debouncedSave = () => { clearTimeout(saveTimeout); saveTimeout = setTimeout(saveCurrentProgress, 1500); }; const observer = new IntersectionObserver(entries => { entries.forEach(entry => { const postId = entry.target.id.replace('pid', ''); if (entry.isIntersecting) { const floor = getFloorFromElement(entry.target); if (floor > 0) { visiblePosts.set(postId, floor); } } else { visiblePosts.delete(postId); } }); debouncedSave(); }, { threshold: 0.1 }); document.querySelectorAll('table[id^="pid"]').forEach(el => observer.observe(el)); const finalSave = () => { clearTimeout(saveTimeout); saveCurrentProgress(); }; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { finalSave(); } }); window.addEventListener('beforeunload', finalSave); }; const refreshAllAuthiActions = () => { document.querySelectorAll('.s1p-authi-actions-wrapper').forEach(el => el.remove()); addActionsToPostFooter(); }; const refreshSinglePostActions = (postId) => { const postTable = document.querySelector(`table#pid${postId}`); if (!postTable) return; const container = postTable.querySelector('.s1p-authi-container'); if (container) { const authiDiv = container.querySelector('.authi'); if (authiDiv) { container.parentElement.insertBefore(authiDiv, container); } container.remove(); } addActionsToSinglePost(postTable); }; const createOptionsMenu = (anchorElement) => { document.querySelector('.s1p-tag-options-menu')?.remove(); const { userId, userName } = anchorElement.dataset; const menu = document.createElement('div'); menu.className = 's1p-tag-options-menu'; menu.innerHTML = ` `; document.body.appendChild(menu); const rect = anchorElement.getBoundingClientRect(); menu.style.top = `${rect.bottom + window.scrollY + 2}px`; menu.style.left = `${rect.right + window.scrollX - menu.offsetWidth}px`; const closeMenu = () => menu.remove(); menu.addEventListener('click', (e) => { e.stopPropagation(); const action = e.target.dataset.action; if (action === 'edit') { const popover = document.getElementById('s1p-tag-popover-main'); if (popover && popover.show) { popover.show(anchorElement, userId, userName); } closeMenu(); } else if (action === 'delete') { createInlineConfirmMenu(anchorElement, '确认删除?', () => { const tags = getUserTags(); delete tags[userId]; saveUserTags(tags); refreshUserPostsOnPage(userId); }); closeMenu(); } }); setTimeout(() => { document.addEventListener('click', closeMenu, { once: true }); }, 0); };; /** * [OPTIMIZED] Adds action buttons to a single post, with a guard to prevent duplication. * @param {HTMLTableElement} postTable - The main table element for a single post. */ const addActionsToSinglePost = (postTable) => { const settings = getSettings(); const authiDiv = postTable.querySelector('.plc .authi'); if (!authiDiv) return; if (authiDiv.parentElement.classList.contains('s1p-authi-container')) { return; } const plsCell = postTable.querySelector('td.pls'); if (!plsCell) return; const userProfileLink = plsCell.querySelector('a[href*="space-uid-"]'); if (!userProfileLink) return; const uidMatch = userProfileLink.href.match(/space-uid-(\d+)\.html/); const userId = uidMatch ? uidMatch[1] : null; if (!userId) return; const postId = postTable.id.replace('pid', ''); const floorElement = postTable.querySelector(`#postnum${postId} em`); const floor = floorElement ? parseInt(floorElement.textContent, 10) : 0; const userName = userProfileLink.textContent.trim(); const userAvatar = plsCell.querySelector('.avatar img')?.src; const newContainer = document.createElement('div'); newContainer.className = 's1p-authi-container'; const scriptActionsWrapper = document.createElement('span'); scriptActionsWrapper.className = 's1p-authi-actions-wrapper'; if (settings.enableBookmarkReplies) { const bookmarkedReplies = getBookmarkedReplies(); const isBookmarked = !!bookmarkedReplies[postId]; const pipe = document.createElement('span'); pipe.className = 'pipe'; pipe.textContent = '|'; scriptActionsWrapper.appendChild(pipe); const bookmarkLink = document.createElement('a'); bookmarkLink.href = 'javascript:void(0);'; bookmarkLink.className = 's1p-authi-action s1p-bookmark-reply'; bookmarkLink.textContent = isBookmarked ? '该回复已收藏' : '收藏该回复'; bookmarkLink.addEventListener('click', (e) => { e.preventDefault(); const currentBookmarks = getBookmarkedReplies(); const wasBookmarked = !!currentBookmarks[postId]; if (wasBookmarked) { delete currentBookmarks[postId]; saveBookmarkedReplies(currentBookmarks); bookmarkLink.textContent = '收藏该回复'; showMessage('已取消收藏该回复。', true); } else { const threadTitleEl = document.querySelector('#thread_subject'); const threadTitle = threadTitleEl ? threadTitleEl.textContent.trim() : '未知标题'; const threadIdMatch = window.location.href.match(/thread-(\d+)-/); const params = new URLSearchParams(window.location.search); const threadId = threadIdMatch ? threadIdMatch[1] : (params.get('tid') || params.get('ptid')); const contentEl = postTable.querySelector('td.t_f'); let postContent = '无法获取内容'; if (contentEl) { const contentClone = contentEl.cloneNode(true); contentClone.querySelectorAll('.pstatus, .quote, .s1p-image-toggle-all-container').forEach(el => el.remove()); postContent = contentClone.innerText.trim().replace(/\n{3,}/g, '\n\n'); } if (!threadId) { showMessage('无法获取帖子ID,收藏失败。', false); return; } currentBookmarks[postId] = { postId, threadId, threadTitle, floor, authorId: userId, authorName: userName, postContent: postContent, timestamp: Date.now() }; saveBookmarkedReplies(currentBookmarks); bookmarkLink.textContent = '该回复已收藏'; showMessage('已收藏该回复。', true); } }); scriptActionsWrapper.appendChild(bookmarkLink); } if (settings.enableUserBlocking) { const pipe = document.createElement('span'); pipe.className = 'pipe'; pipe.textContent = '|'; scriptActionsWrapper.appendChild(pipe); const blockLink = document.createElement('a'); blockLink.href = 'javascript:void(0);'; blockLink.textContent = '屏蔽该用户'; blockLink.className = 's1p-authi-action s1p-block-user-in-authi'; blockLink.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const confirmText = getSettings().blockThreadsOnUserBlock ? `屏蔽用户并隐藏其主题帖?` : `确认屏蔽该用户?`; createInlineConfirmMenu(e.currentTarget, confirmText, () => blockUser(userId, userName)); }); scriptActionsWrapper.appendChild(blockLink); } if (settings.enableUserTagging) { const userTags = getUserTags(); const userTag = userTags[userId]; const pipe = document.createElement('span'); pipe.className = 'pipe'; pipe.textContent = '|'; scriptActionsWrapper.appendChild(pipe); if (userTag && userTag.tag) { const tagContainer = document.createElement('span'); tagContainer.className = 's1p-authi-action s1p-user-tag-container'; const fullTagText = userTag.tag; const tagDisplay = document.createElement('span'); tagDisplay.className = 's1p-user-tag-display'; tagDisplay.textContent = `用户标记:${fullTagText}`; tagDisplay.dataset.fullTag = fullTagText; tagDisplay.removeAttribute('title'); const optionsIcon = document.createElement('span'); optionsIcon.className = 's1p-user-tag-options'; optionsIcon.innerHTML = '⋮'; optionsIcon.dataset.userId = userId; optionsIcon.dataset.userName = userName; optionsIcon.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); createOptionsMenu(e.currentTarget); }); tagContainer.appendChild(tagDisplay); tagContainer.appendChild(optionsIcon); scriptActionsWrapper.appendChild(tagContainer); } else { const tagLink = document.createElement('a'); tagLink.href = 'javascript:void(0);'; tagLink.textContent = '标记该用户'; tagLink.className = 's1p-authi-action s1p-tag-user-in-authi'; tagLink.addEventListener('click', (e) => { e.preventDefault(); const popover = document.getElementById('s1p-tag-popover-main'); if (popover && popover.show) { popover.show(e.currentTarget, userId, userName, userAvatar); } }); scriptActionsWrapper.appendChild(tagLink); } } if (scriptActionsWrapper.hasChildNodes()) { authiDiv.parentElement.insertBefore(newContainer, authiDiv); newContainer.appendChild(authiDiv); newContainer.appendChild(scriptActionsWrapper); } }; const addActionsToPostFooter = () => { const settings = getSettings(); if (!settings.enableUserBlocking && !settings.enableUserTagging && !settings.enableBookmarkReplies) return; document.querySelectorAll('table[id^="pid"]').forEach(addActionsToSinglePost); }; function autoSign() { const checkinLink = document.querySelector('a[href*="study_daily_attendance-daily_attendance.html"]'); if (!checkinLink) return; var now = new Date(); var date = now.getFullYear() + "-" + (now.getMonth() + 1) + "-" + now.getDate(); var signedDate = GM_getValue("signedDate"); if (signedDate == date) { checkinLink.style.display = 'none'; return; } if (now.getHours() < 6) return; GM_xmlhttpRequest({ method: "GET", url: checkinLink.href, onload: function (response) { GM_setValue("signedDate", date); checkinLink.style.display = 'none'; console.log('S1 Plus: Auto check-in request sent. Status:', response.status); }, onerror: function (response) { console.error('S1 Plus: Auto check-in request failed.', response); } }); } const cleanupOldReadProgress = () => { const settings = getSettings(); if (!settings.readingProgressCleanupDays || settings.readingProgressCleanupDays <= 0) return; const progress = getReadProgress(); const originalCount = Object.keys(progress).length; if (originalCount === 0) return; const now = Date.now(); const maxAge = settings.readingProgressCleanupDays * 24 * 60 * 60 * 1000; const cleanedProgress = {}; let cleanedCount = 0; for (const threadId in progress) { if (Object.prototype.hasOwnProperty.call(progress, threadId)) { const record = progress[threadId]; if (record.timestamp && (now - record.timestamp < maxAge)) { cleanedProgress[threadId] = record; } else { cleanedCount++; } } } if (cleanedCount > 0) { console.log(`S1 Plus: Cleaned up ${cleanedCount} old reading progress records (older than ${settings.readingProgressCleanupDays} days).`); saveReadProgress(cleanedProgress); } }; // [S1 PLUS 整合版] // --- 操作: 将这个全新的函数添加到 'main' 函数的正上方。 // --- 优点: 整合了 kyo 方案的兼容性回退逻辑和优雅中断执行的模式,同时保持了 cosmos 方案的逻辑清晰度。 /** * [整合版] 脚本启动时的同步总控制器。 * 优先执行每日首次同步;如果条件不符,则回退到常规的启动时同步检查(如果开启)。 * @returns {Promise} - 返回 true 表示页面即将刷新,主流程应中断。 */ const handleStartupSync = async () => { const settings = getSettings(); if (!settings.syncRemoteEnabled) { return false; // 总开关未开,直接跳过所有启动同步。 } // --- 逻辑1: 检查是否需要执行“每日首次加载同步” --- const today = new Date().toLocaleDateString('sv'); // 使用 YYYY-MM-DD 格式 const lastSyncDate = GM_getValue('s1p_last_daily_sync_date', null); if (settings.syncDailyFirstLoad && today !== lastSyncDate) { console.log('S1 Plus: 正在执行每日首次加载同步...'); showMessage('S1 Plus: 正在执行每日首次自动同步...', null); GM_setValue('s1p_last_daily_sync_date', today); // 立即标记,防止重复触发 const result = await performAutoSync(); switch (result.status) { case 'success': if (result.action === 'pulled') { showMessage('每日同步完成,正在刷新页面以应用最新数据...', true); setTimeout(() => location.reload(), 1500); return true; // 返回true,中断主流程 } else { showMessage('每日首次同步完成。', true); } break; case 'failure': showMessage(`每日首次同步失败: ${result.error}`, false); break; case 'conflict': showMessage('每日首次同步检测到冲突,请手动解决。', false); break; } // 只要每日同步被触发过(无论成功失败),就结束启动同步流程。 return false; } // --- 逻辑2: 如果不执行每日同步,则回退到原有的“自动后台同步”的启动检查 --- if (settings.syncAutoEnabled) { console.log('S1 Plus: 执行常规启动时同步检查...'); const result = await performAutoSync(); if (result.status === 'success' && result.action === 'pulled') { // 只有在拉取数据时才需要用户反馈和刷新 showMessage('检测到云端有更新,正在刷新页面...', true); setTimeout(() => location.reload(), 1500); return true; // 返回true,中断主流程 } } return false; // 默认不刷新 }; // [S1 PLUS 整合版] // --- 操作: 用下面的完整 async 函数替换现有的 'main' 函数。 // --- 优点: 采纳了 kyo 方案的中断逻辑,当页面需要刷新时,停止后续无效操作,提升效率。 // --- 主流程 --- async function main() { // [整合] 首先执行启动同步逻辑,并根据结果决定是否中断 const isReloading = await handleStartupSync(); if (isReloading) { return; // 如果页面即将刷新,则中断后续所有脚本初始化操作 } cleanupOldReadProgress(); detectS1Nux(); initializeNavbar(); initializeGenericDisplayPopover(); const observerCallback = (mutations, observer) => { observer.disconnect(); applyChanges(); const ctElement = document.getElementById('ct'); if (ctElement) { observer.observe(ctElement, { childList: true, subtree: true }); } }; const observer = new MutationObserver(observerCallback); applyChanges(); const ctElement = document.getElementById('ct'); if (ctElement) { observer.observe(ctElement, { childList: true, subtree: true }); } } function applyChanges() { const settings = getSettings(); if (settings.enablePostBlocking) { hideBlockedThreads(); hideThreadsByTitleKeyword(); addBlockButtonsToThreads(); applyUserThreadBlocklist(); } if (settings.enableUserBlocking) { hideBlockedUsersPosts(); hideBlockedUserQuotes(); hideBlockedUserRatings(); } if (settings.enableUserBlocking || settings.enableUserTagging || settings.enableBookmarkReplies) { addActionsToPostFooter(); } if (settings.enableUserTagging) { initializeTaggingPopover(); } if (settings.enableReadProgress) { addProgressJumpButtons(); } applyInterfaceCustomizations(); applyImageHiding(); manageImageToggleAllButtons(); renameAuthorLinks(); applyThreadLinkBehavior(); applyPageLinkBehavior(); trackReadProgressInThread(); try { autoSign(); } catch (e) { console.error('S1 Plus: Error caught while running autoSign():', e); } } main(); })();