您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为 Linux.do 论坛添加稍后再看功能
// ==UserScript== // @name Linux.do 稍后再看 // @namespace http://tampermonkey.net/ // @version 2.6 // @description 为 Linux.do 论坛添加稍后再看功能 // @author HeYeYe // @match https://linux.do/* // @exclude https://linux.do/a/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-end // @license MIT License // ==/UserScript== (function() { 'use strict'; // 添加样式 GM_addStyle(` /* 浮动管理面板 */ #read-later-container { position: fixed; top: 50%; right: 20px; transform: translateY(-50%); z-index: 10000; user-select: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } /* 主管理按钮 */ #read-later-btn { width: 50px; height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; border-radius: 25px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: all 0.3s ease; color: white; font-size: 20px; position: relative; } #read-later-btn:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(0,0,0,0.2); } /* 数量徽章 */ .read-later-badge { position: absolute; top: -5px; right: -5px; background: #ff4757; color: white; border-radius: 10px; padding: 2px 6px; font-size: 11px; font-weight: bold; min-width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } /* 管理面板 */ #read-later-panel { position: absolute; right: 60px; top: 0; width: 350px; background: white; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.1); border: 1px solid #e1e8ed; display: none; overflow: hidden; max-height: 500px; } #read-later-panel.show { display: block; animation: slideIn 0.3s ease; } @keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } /* 面板头部 */ .panel-header { padding: 15px 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600; display: flex; justify-content: space-between; align-items: center; } .panel-close { background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; } .panel-close:hover { background: rgba(255,255,255,0.2); } /* 列表区域 */ .read-later-list { max-height: 350px; overflow-y: auto; } .list-item { padding: 12px 20px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.2s; display: flex; justify-content: space-between; align-items: flex-start; } .list-item:hover { background: #f8f9fa; } .list-item:last-child { border-bottom: none; } .item-content { flex: 1; min-width: 0; } .item-title { font-size: 13px; font-weight: 500; color: #333; margin: 0 0 4px 0; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .item-meta { font-size: 11px; color: #999; margin: 0; display: flex; gap: 10px; } .item-actions { margin-left: 10px; display: flex; gap: 5px; } .action-btn { width: 20px; height: 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .delete-btn { background: #ff4757; color: white; } .delete-btn:hover { background: #ff3742; transform: scale(1.1); } /* 空状态 */ .empty-state { padding: 40px 20px; text-align: center; color: #999; font-size: 14px; } /* 清空按钮 */ .clear-all-btn { padding: 10px 20px; background: #ff4757; color: white; border: none; font-size: 12px; cursor: pointer; width: 100%; transition: background 0.2s; } .clear-all-btn:hover { background: #ff3742; } /* 隐藏按钮 */ .hide-btn { position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; background: #ff4757; color: white; border: none; border-radius: 50%; font-size: 12px; cursor: pointer; display: none; align-items: center; justify-content: center; } #read-later-container:hover .hide-btn { display: flex; } /* 恢复按钮(隐藏状态下) */ .restore-btn { position: fixed; bottom: 20px; right: 20px; width: 40px; height: 40px; background: #667eea; color: white; border: none; border-radius: 50%; font-size: 16px; cursor: pointer; z-index: 10000; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.15); } .restore-btn:hover { transform: scale(1.1); } /* 拖拽时的样式 */ .dragging { transition: none !important; opacity: 0.8; } /* 滚动条美化 */ .read-later-list::-webkit-scrollbar { width: 6px; } .read-later-list::-webkit-scrollbar-track { background: #f1f1f1; } .read-later-list::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 3px; } .read-later-list::-webkit-scrollbar-thumb:hover { background: #a1a1a1; } /* 列表页面的添加按钮样式 */ .read-later-add-btn { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin-left: 8px; background: #667eea; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; opacity: 0; transform: scale(0.8); vertical-align: middle; } .read-later-add-btn:hover { background: #5a6fd8; transform: scale(1); } .read-later-add-btn.added { background: #4CAF50; opacity: 1; transform: scale(1); } .read-later-add-btn.added:hover { background: #45a049; } /* 帖子项悬停时显示按钮 */ .topic-list-item:hover .read-later-add-btn, .topic-list-body tr:hover .read-later-add-btn, .latest-topic-list-item:hover .read-later-add-btn, .topic-body:hover .read-later-add-btn { opacity: 1; transform: scale(1); } /* 已添加的按钮始终显示 */ .read-later-add-btn.added { opacity: 1 !important; transform: scale(1) !important; } /* 当前帖子页面提示 */ .current-topic-indicator { display: inline-flex; align-items: center; margin-left: 10px; padding: 4px 8px; background: #e8f5e8; color: #4CAF50; border-radius: 4px; font-size: 12px; font-weight: 500; } /* 同步相关样式 */ .sync-status { padding: 8px 20px; background: #f8f9fa; border-bottom: 1px solid #e9ecef; font-size: 11px; color: #666; display: flex; justify-content: space-between; align-items: center; } .sync-status.syncing { background: #fff3cd; color: #856404; } .sync-status.error { background: #f8d7da; color: #721c24; } .sync-status.success { background: #d4edda; color: #155724; } .sync-btn { background: none; border: 1px solid #ccc; padding: 2px 8px; border-radius: 3px; font-size: 10px; cursor: pointer; transition: all 0.2s; } .sync-btn:hover { background: #f0f0f0; } .sync-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* 设置面板样式 */ .settings-panel { position: absolute; right: 60px; top: 0; width: 400px; background: white; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.1); border: 1px solid #e1e8ed; display: none; overflow: visible; /* 改为 visible 允许下拉菜单溢出 */ max-height: 600px; } .settings-panel.show { display: block; animation: slideIn 0.3s ease; overflow: visible; /* 确保显示时也允许溢出 */ } .settings-form { padding: 20px; overflow: visible; /* 表单容器也允许溢出 */ } .form-group { margin-bottom: 15px; } .form-label { display: block; margin-bottom: 5px; font-size: 13px; font-weight: 500; color: #333; } .form-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; box-sizing: border-box; } .form-input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); } .form-textarea { min-height: 60px; resize: vertical; font-family: monospace; } .form-checkbox { margin-right: 8px; } .form-help { font-size: 11px; color: #666; margin-top: 4px; line-height: 1.4; } .form-actions { display: flex; gap: 10px; padding-top: 15px; border-top: 1px solid #eee; } .btn-primary { background: #667eea; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: background 0.2s; } .btn-primary:hover { background: #5a6fd8; } .btn-secondary { background: #6c757d; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: background 0.2s; } .btn-secondary:hover { background: #545b62; } .btn-danger { background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: background 0.2s; } .btn-danger:hover { background: #c82333; } /* 设置按钮 */ .settings-btn { position: absolute; top: -8px; left: -8px; width: 20px; height: 20px; background: #667eea; color: white; border: none; border-radius: 50%; font-size: 12px; cursor: pointer; display: none; align-items: center; justify-content: center; } #read-later-container:hover .settings-btn { display: flex; } /* 帖子计数显示 */ .topic-count-info { padding: 10px 20px; background: #f8f9fa; border-bottom: 1px solid #e9ecef; font-size: 12px; color: #666; text-align: center; } /* Gist ID 输入组合样式 */ .gist-input-group { position: relative; display: flex; gap: 5px; align-items: stretch; } .gist-input-group .form-input { flex: 1; margin: 0; } .gist-select-btn { background: #667eea; color: white; border: none; padding: 0 12px; border-radius: 6px; font-size: 12px; cursor: pointer; white-space: nowrap; transition: background 0.2s; height: 34px; min-width: 60px; display: flex; align-items: center; justify-content: center; } .gist-select-btn:hover { background: #5a6fd8; } .gist-select-btn:disabled { background: #ccc; cursor: not-allowed; } /* Gist 下拉菜单样式 */ .gist-dropdown { position: absolute; top: calc(100% + 4px); /* 调整距离,更贴近输入框 */ left: 0; right: 0; background: white; border: 2px solid #667eea; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); z-index: 99999; max-height: 250px; overflow-y: auto; display: none; } .gist-dropdown.show { display: block !important; visibility: visible !important; opacity: 1 !important; } /* 导出功能样式 */ .export-section { padding: 15px 20px; border-top: 1px solid #e9ecef; background: #f8f9fa; } .export-title { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 10px; } .export-options { display: flex; gap: 8px; margin-bottom: 10px; } .export-format-btn { padding: 6px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; font-size: 12px; cursor: pointer; transition: all 0.2s; color: #666; } .export-format-btn:hover { border-color: #667eea; color: #667eea; } .export-format-btn.active { background: #667eea; border-color: #667eea; color: white; } .export-actions { display: flex; gap: 8px; } .export-btn { flex: 1; padding: 8px 12px; background: #28a745; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; transition: background 0.2s; } .export-btn:hover { background: #218838; } .export-btn:disabled { background: #ccc; cursor: not-allowed; } .export-copy-btn { padding: 8px 12px; background: #17a2b8; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; transition: background 0.2s; } .export-copy-btn:hover { background: #138496; } .export-info { font-size: 11px; color: #666; margin-top: 8px; line-height: 1.4; } /* 排序控制样式 */ .sort-controls { padding: 10px 20px; background: #f8f9fa; border-bottom: 1px solid #e9ecef; display: flex; align-items: center; gap: 10px; } .sort-label { font-size: 12px; color: #666; font-weight: 500; } .sort-btn { padding: 4px 8px; border: 1px solid #ddd; background: white; border-radius: 4px; font-size: 11px; cursor: pointer; transition: all 0.2s; color: #666; } .sort-btn:hover { border-color: #667eea; color: #667eea; } .sort-btn.active { background: #667eea; border-color: #667eea; color: white; } .gist-dropdown-item { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.2s; } .gist-dropdown-item:hover { background: #f8f9fa; } .gist-dropdown-item:last-child { border-bottom: none; } .gist-item-id { font-family: monospace; font-size: 11px; color: #666; margin-bottom: 2px; word-break: break-all; } .gist-item-desc { font-size: 12px; color: #333; margin-bottom: 2px; line-height: 1.3; } .gist-item-date { font-size: 10px; color: #999; } .gist-dropdown-empty, .gist-dropdown-loading, .gist-dropdown-error { padding: 20px; text-align: center; font-size: 12px; line-height: 1.4; } .gist-dropdown-loading { color: #666; } .gist-dropdown-error { color: #ff4757; } .gist-dropdown-empty { color: #999; } `); // 全局变量 let readLaterList = []; let isDragging = false; let dragOffset = { x: 0, y: 0 }; let observer = null; let selectedExportFormat = 'markdown'; // 默认导出格式 let lastDataChecksum = ''; // 用于检测数据变化 let crossTabSyncInterval = null; // 跨标签页同步定时器 let currentSortMode = 'oldest-first'; // 默认排序模式:从旧到新 let syncConfig = { enabled: false, token: '', gistId: '', lastSync: 0, autoSync: true, syncInterval: 5 * 60 * 1000 // 5分钟自动同步 }; // 初始化 function init() { loadReadLaterList(); loadSyncConfig(); createFloatingButton(); startObserving(); // 启动跨标签页数据同步 startCrossTabSync(); // 初始扫描页面 setTimeout(scanAndAddButtons, 1000); // 启动自动同步 startAutoSync(); // 监听页面变化(SPA路由) let currentUrl = window.location.href; setInterval(() => { if (window.location.href !== currentUrl) { currentUrl = window.location.href; setTimeout(scanAndAddButtons, 1000); } }, 2000); // 页面关闭前清理 window.addEventListener('beforeunload', () => { if (crossTabSyncInterval) { clearInterval(crossTabSyncInterval); } }); } // 加载稍后再看列表 function loadReadLaterList() { const saved = GM_getValue('readLaterList', '[]'); const savedSortMode = GM_getValue('sortMode', 'oldest-first'); try { readLaterList = JSON.parse(saved); currentSortMode = savedSortMode; // 计算数据校验和 lastDataChecksum = calculateChecksum(readLaterList); console.log('[稍后再看] 数据加载完成,校验和:', lastDataChecksum.substring(0, 8)); } catch (e) { readLaterList = []; lastDataChecksum = ''; } } // 计算数据校验和(简单的字符串哈希) function calculateChecksum(data) { const str = JSON.stringify(data); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // 转换为32位整数 } return hash.toString(36); } // 启动跨标签页数据同步 function startCrossTabSync() { console.log('[稍后再看] 启动跨标签页数据同步'); // 定期检查数据是否被其他标签页修改 crossTabSyncInterval = setInterval(() => { checkCrossTabDataChanges(); }, 1000); // 每秒检查一次 } // 检查跨标签页数据变化 function checkCrossTabDataChanges() { try { const saved = GM_getValue('readLaterList', '[]'); const savedData = JSON.parse(saved); const currentChecksum = calculateChecksum(savedData); // 如果校验和不同,说明数据被其他标签页修改了 if (currentChecksum !== lastDataChecksum) { console.log('[稍后再看] 检测到其他标签页的数据变化'); console.log('[稍后再看] 旧校验和:', lastDataChecksum.substring(0, 8)); console.log('[稍后再看] 新校验和:', currentChecksum.substring(0, 8)); // 更新本地数据 const oldCount = readLaterList.length; readLaterList = savedData; lastDataChecksum = currentChecksum; // 更新UI updateBadge(); updateAllButtonStates(); // 如果管理面板打开,更新内容 const panel = document.getElementById('read-later-panel'); if (panel && panel.classList.contains('show')) { updatePanelContent(); } const newCount = readLaterList.length; console.log('[稍后再看] 跨标签页同步完成:', oldCount, '→', newCount); // 显示提示(可选) if (Math.abs(newCount - oldCount) > 0) { showToast(`数据已同步:${newCount} 个帖子`); } } } catch (error) { console.error('[稍后再看] 跨标签页数据检查失败:', error); } } // 保存稍后再看列表 - 添加修改时间记录 function saveReadLaterList() { GM_setValue('readLaterList', JSON.stringify(readLaterList)); GM_setValue('sortMode', currentSortMode); // 保存排序模式 // 记录本地修改时间 const now = Date.now(); GM_setValue('lastLocalModified', now); console.log('[稍后再看] 本地数据已保存,修改时间:', new Date(now).toLocaleString()); // 如果启用了同步,标记需要同步 if (syncConfig.enabled && syncConfig.autoSync) { GM_setValue('needSync', 'true'); } } // 加载同步配置 function loadSyncConfig() { const saved = GM_getValue('syncConfig', '{}'); try { const savedConfig = JSON.parse(saved); syncConfig = { ...syncConfig, ...savedConfig }; } catch (e) { console.error('[稍后再看] 加载同步配置失败:', e); } } // 保存同步配置 function saveSyncConfig() { GM_setValue('syncConfig', JSON.stringify(syncConfig)); } // 开始观察页面变化 function startObserving() { // 停止之前的观察者 if (observer) { observer.disconnect(); } observer = new MutationObserver((mutations) => { let shouldScan = false; let hasRemovals = false; mutations.forEach((mutation) => { // 检查是否有新内容添加 if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { for (let node of mutation.addedNodes) { if (node.nodeType === 1) { // Element node if (node.matches && ( node.matches('.topic-list-item') || node.matches('.latest-topic-list-item') || node.matches('.topic-list-body tr') || node.querySelector('.topic-list-item, .latest-topic-list-item, .topic-list-body tr') )) { shouldScan = true; console.log(`[稍后再看] 检测到新增帖子元素:`, node); break; } } } } // 检查是否有按钮被移除 if (mutation.type === 'childList' && mutation.removedNodes.length > 0) { for (let node of mutation.removedNodes) { if (node.nodeType === 1 && ( node.matches && node.matches('.read-later-add-btn') || node.querySelector && node.querySelector('.read-later-add-btn') )) { hasRemovals = true; console.warn(`[稍后再看] 检测到按钮被移除:`, node); break; } } } }); if (hasRemovals) { console.warn(`[稍后再看] 按钮被外部移除,将重新扫描`); shouldScan = true; } if (shouldScan) { // 延迟执行,避免频繁触发 clearTimeout(window.readLaterScanTimeout); window.readLaterScanTimeout = setTimeout(() => { console.log(`[稍后再看] 触发重新扫描`); scanAndAddButtons(); }, 500); } }); observer.observe(document.body, { childList: true, subtree: true }); // console.log(`[稍后再看] 开始监听页面变化`); } // 扫描页面并添加按钮 function scanAndAddButtons() { // console.log(`[稍后再看] 开始扫描页面...`); // 检查现有按钮数量 const existingButtons = document.querySelectorAll('.read-later-add-btn'); console.log(`[稍后再看] 发现 ${existingButtons.length} 个现有按钮`); // 获取所有帖子链接 const topicSelectors = [ // 首页帖子列表 '.topic-list-item .main-link a.title', '.latest-topic-list-item .main-link a.title', // 表格形式的帖子列表 '.topic-list-body tr .main-link a.title', // 搜索结果页面 '.fps-result .topic .title a', // 分类页面 '.category-list .topic-list .main-link a.title', // 用户页面的帖子 '.user-stream .item .title a' ]; let totalLinks = 0; let processedLinks = 0; let addedCount = 0; topicSelectors.forEach(selector => { const links = document.querySelectorAll(selector); totalLinks += links.length; links.forEach(link => { if (link.dataset.readLaterProcessed) { processedLinks++; // 检查是否还有对应的按钮 const existingBtn = link.parentNode?.querySelector('.read-later-add-btn'); if (!existingBtn) { console.log(`[稍后再看] 发现丢失的按钮,重新添加: ${link.textContent.trim()}`); addButtonToLink(link); addedCount++; } } else { addButtonToLink(link); link.dataset.readLaterProcessed = 'true'; addedCount++; } }); }); console.log(`[稍后再看] 扫描完成 - 总链接: ${totalLinks}, 已处理: ${processedLinks}, 新添加: ${addedCount}`); // 验证按钮是否真的存在 setTimeout(() => { const finalButtons = document.querySelectorAll('.read-later-add-btn'); console.log(`[稍后再看] 验证结果: ${finalButtons.length} 个按钮最终存在于页面中`); if (finalButtons.length !== addedCount + (existingButtons.length - processedLinks)) { console.warn(`[稍后再看] 警告: 按钮数量不匹配,可能被其他脚本或页面更新清除了`); } }, 100); } // 为链接添加按钮 function addButtonToLink(link) { try { // 检查是否已经有按钮了 const existingBtn = link.parentNode?.querySelector('.read-later-add-btn'); if (existingBtn) { console.log(`[稍后再看] 链接已有按钮,跳过: ${link.textContent.trim()}`); return; } // 解析链接获取帖子信息 const topicInfo = parseTopicLink(link); if (!topicInfo) { console.warn(`[稍后再看] 无法解析链接: ${link.href}`); return; } // 检查是否已添加 const isAdded = readLaterList.some(item => item.id === topicInfo.id); // 创建按钮 const button = document.createElement('button'); button.className = 'read-later-add-btn' + (isAdded ? ' added' : ''); button.innerHTML = isAdded ? '✓' : '+'; button.title = isAdded ? '已在稍后再看中' : '添加到稍后再看'; button.dataset.topicId = topicInfo.id; // 添加调试属性 button.dataset.debugTitle = topicInfo.title; // 绑定点击事件 button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // console.log(`[稍后再看] 按钮被点击: ${topicInfo.title}`); toggleReadLater(topicInfo, button); }); // 插入按钮到标题后面 if (link.parentNode) { link.parentNode.insertBefore(button, link.nextSibling); // console.log(`[稍后再看] 成功添加按钮: ${topicInfo.title} (ID: ${topicInfo.id})`); // 验证按钮是否真的被添加了 setTimeout(() => { const verifyBtn = link.parentNode?.querySelector('.read-later-add-btn'); if (!verifyBtn) { console.error(`[稍后再看] 按钮添加后立即消失: ${topicInfo.title}`); console.error(`[稍后再看] 父元素:`, link.parentNode); } }, 10); } else { console.error(`[稍后再看] 无法找到父元素来插入按钮: ${topicInfo.title}`); } } catch (error) { console.error('[稍后再看] 添加按钮失败:', error); console.error('[稍后再看] 链接信息:', { href: link.href, text: link.textContent.trim(), parentNode: link.parentNode }); } } // 解析帖子链接 function parseTopicLink(link) { const href = link.href; const title = link.textContent.trim(); // 匹配 /t/slug/id 格式 const match = href.match(/\/t\/([^\/]+)\/(\d+)/); if (!match) return null; const slug = match[1]; const id = match[2]; return { id: id, title: title, url: href, slug: slug, addedAt: new Date().toISOString() }; } // 切换稍后再看状态 function toggleReadLater(topicInfo, button) { const isAdded = readLaterList.some(item => item.id === topicInfo.id); if (isAdded) { // 移除 readLaterList = readLaterList.filter(item => item.id !== topicInfo.id); button.classList.remove('added'); button.innerHTML = '+'; button.title = '添加到稍后再看'; showToast('已从稍后再看中移除'); } else { // 添加 readLaterList.unshift(topicInfo); button.classList.add('added'); button.innerHTML = '✓'; button.title = '已在稍后再看中'; showToast('已添加到稍后再看'); } saveReadLaterList(); updateBadge(); // 如果管理面板打开,更新内容 const panel = document.getElementById('read-later-panel'); if (panel && panel.classList.contains('show')) { updatePanelContent(); } } // 从稍后再看中移除 function removeFromReadLater(id) { readLaterList = readLaterList.filter(item => item.id !== id); saveReadLaterList(); updateBadge(); // 更新页面上对应的按钮状态 const button = document.querySelector(`[data-topic-id="${id}"]`); if (button) { button.classList.remove('added'); button.innerHTML = '+'; button.title = '添加到稍后再看'; } showToast('已从稍后再看中移除'); } // 清空列表 function clearAllReadLater() { if (readLaterList.length === 0) return; if (confirm('确定要清空所有稍后再看的帖子吗?')) { readLaterList = []; saveReadLaterList(); updateBadge(); // 更新所有按钮状态 document.querySelectorAll('.read-later-add-btn.added').forEach(btn => { btn.classList.remove('added'); btn.innerHTML = '+'; btn.title = '添加到稍后再看'; }); updatePanelContent(); showToast('已清空稍后再看列表'); } } // 显示提示消息 function showToast(message) { const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: rgba(0,0,0,0.8); color: white; padding: 12px 20px; border-radius: 6px; font-size: 14px; z-index: 10001; transition: all 0.3s ease; `; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-20px)'; setTimeout(() => { if (toast.parentNode) { document.body.removeChild(toast); } }, 300); }, 2000); } // 切换设置面板 function toggleSettingsPanel() { const settingsPanel = document.getElementById('settings-panel'); const mainPanel = document.getElementById('read-later-panel'); // 关闭主面板 closePanel(mainPanel); const isShow = settingsPanel.classList.contains('show'); if (isShow) { closePanel(settingsPanel); } else { updateSettingsPanel(); settingsPanel.classList.add('show'); } } // 关闭面板的统一函数 function closePanel(panel) { if (panel && panel.classList.contains('show')) { panel.classList.remove('show'); // 关闭 Gist 下拉菜单(如果存在) const dropdown = document.getElementById('gist-dropdown'); if (dropdown) { dropdown.classList.remove('show'); } } } // 关闭所有面板的函数 function closeAllPanels() { const mainPanel = document.getElementById('read-later-panel'); const settingsPanel = document.getElementById('settings-panel'); closePanel(mainPanel); closePanel(settingsPanel); } // 创建浮动按钮 function createFloatingButton() { // 主容器 const container = document.createElement('div'); container.id = 'read-later-container'; // 主按钮 const button = document.createElement('button'); button.id = 'read-later-btn'; button.innerHTML = '📚'; button.title = '稍后再看管理'; // 数量徽章 const badge = document.createElement('span'); badge.className = 'read-later-badge'; badge.textContent = '0'; button.appendChild(badge); // 操作面板 const panel = document.createElement('div'); panel.id = 'read-later-panel'; // 设置面板 const settingsPanel = document.createElement('div'); settingsPanel.className = 'settings-panel'; settingsPanel.id = 'settings-panel'; // 隐藏按钮 const hideBtn = document.createElement('button'); hideBtn.className = 'hide-btn'; hideBtn.innerHTML = '×'; hideBtn.title = '隐藏'; // 设置按钮 const settingsBtn = document.createElement('button'); settingsBtn.className = 'settings-btn'; settingsBtn.innerHTML = '⚙'; settingsBtn.title = '同步设置'; container.appendChild(button); container.appendChild(panel); container.appendChild(settingsPanel); container.appendChild(hideBtn); container.appendChild(settingsBtn); document.body.appendChild(container); // 创建恢复按钮 const restoreBtn = document.createElement('button'); restoreBtn.className = 'restore-btn'; restoreBtn.innerHTML = '📚'; restoreBtn.title = '显示稍后再看'; document.body.appendChild(restoreBtn); // 绑定事件 button.addEventListener('click', togglePanel); hideBtn.addEventListener('click', hideContainer); restoreBtn.addEventListener('click', showContainer); settingsBtn.addEventListener('click', toggleSettingsPanel); // 拖拽功能 makeDraggable(container); // 初始化UI updateBadge(); // 修复后的点击外部关闭面板逻辑 document.addEventListener('click', (e) => { // 检查点击是否在容器内 if (!container.contains(e.target)) { // 点击在容器外部,关闭所有面板 closeAllPanels(); } }); // 阻止容器内的点击冒泡到document container.addEventListener('click', (e) => { e.stopPropagation(); }); } // 更新徽章数量 function updateBadge() { const badge = document.querySelector('.read-later-badge'); if (badge) { const count = readLaterList.length; badge.textContent = count > 99 ? '99+' : count.toString(); badge.style.display = count > 0 ? 'flex' : 'none'; } } // 切换面板显示 function togglePanel() { const panel = document.getElementById('read-later-panel'); const settingsPanel = document.getElementById('settings-panel'); const isShow = panel.classList.contains('show'); // 先关闭设置面板 closePanel(settingsPanel); if (isShow) { closePanel(panel); } else { updatePanelContent(); panel.classList.add('show'); // 加载用户的面板大小偏好 loadPanelSize(); } } // 隐藏容器 function hideContainer() { const container = document.getElementById('read-later-container'); const restoreBtn = document.querySelector('.restore-btn'); container.style.display = 'none'; restoreBtn.style.display = 'flex'; } // 显示容器 function showContainer() { const container = document.getElementById('read-later-container'); const restoreBtn = document.querySelector('.restore-btn'); container.style.display = 'block'; restoreBtn.style.display = 'none'; } // 获取排序后的列表 function getSortedList() { const list = [...readLaterList]; switch (currentSortMode) { case 'newest-first': return list.sort((a, b) => new Date(b.addedAt) - new Date(a.addedAt)); case 'oldest-first': return list.sort((a, b) => new Date(a.addedAt) - new Date(b.addedAt)); case 'title': return list.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')); default: return list.sort((a, b) => new Date(a.addedAt) - new Date(b.addedAt)); } } // 更新面板内容 function updatePanelContent() { const panel = document.getElementById('read-later-panel'); const currentTopicInfo = getCurrentTopicInfo(); const sortedList = getSortedList(); panel.innerHTML = ` <div class="panel-content"> <div class="panel-header"> <span>稍后再看管理</span> <div class="panel-resize-controls"> <button class="resize-btn" data-size="compact" title="紧凑">S</button> <button class="resize-btn" data-size="normal" title="正常">M</button> <button class="resize-btn" data-size="large" title="大尺寸">L</button> </div> <button class="panel-close">×</button> </div> ${getSyncStatusHTML()} <div class="topic-count-info"> 共 ${readLaterList.length} 个帖子 ${currentTopicInfo ? '<span class="current-topic-indicator">当前帖子已在列表中</span>' : ''} </div> ${readLaterList.length > 0 ? ` <div class="sort-controls"> <span class="sort-label">排序:</span> <button class="sort-btn ${currentSortMode === 'oldest-first' ? 'active' : ''}" data-sort="oldest-first">从旧到新</button> <button class="sort-btn ${currentSortMode === 'newest-first' ? 'active' : ''}" data-sort="newest-first">从新到旧</button> <button class="sort-btn ${currentSortMode === 'title' ? 'active' : ''}" data-sort="title">按标题</button> </div> ` : ''} <div class="read-later-list"> ${readLaterList.length > 0 ? sortedList.map(item => ` <div class="list-item" data-id="${item.id}"> <div class="item-content"> <h5 class="item-title">${item.title}</h5> <p class="item-meta"> <span>${formatTime(item.addedAt)}</span> <span>ID: ${item.id}</span> </p> </div> <div class="item-actions"> <button class="action-btn delete-btn" data-action="delete" data-id="${item.id}">×</button> </div> </div> `).join('') : '<div class="empty-state">暂无稍后再看的帖子<br>在帖子列表页面点击 + 按钮添加</div>' } </div> ${readLaterList.length > 0 ? ` <div class="export-section"> <div class="export-title">📤 导出数据</div> <div class="export-options"> <button class="export-format-btn ${selectedExportFormat === 'markdown' ? 'active' : ''}" data-format="markdown">MD</button> <button class="export-format-btn ${selectedExportFormat === 'html' ? 'active' : ''}" data-format="html">HTML</button> <button class="export-format-btn ${selectedExportFormat === 'json' ? 'active' : ''}" data-format="json">JSON</button> </div> <div class="export-actions"> <button class="export-btn" id="export-download-btn">📥 下载</button> <button class="export-copy-btn" id="export-copy-btn">📋 复制</button> </div> <div class="export-info"> 格式: <strong>${selectedExportFormat.toUpperCase()}</strong> • ${readLaterList.length} 个帖子 </div> </div> <button class="clear-all-btn">🗑️ 清空所有</button> ` : ''} </div> `; // 设置面板样式,但不强制 display 属性 const currentStyle = panel.style.cssText; panel.style.cssText = ` position: absolute !important; right: 60px !important; top: 0 !important; width: 450px !important; background: white !important; border-radius: 12px !important; box-shadow: 0 8px 32px rgba(0,0,0,0.1) !important; border: 1px solid #e1e8ed !important; overflow: visible !important; max-height: none !important; height: auto !important; z-index: 10000 !important; `; // 重新绑定事件 bindPanelEvents(panel); } // 调整面板大小 function resizePanel(size) { const panel = document.getElementById('read-later-panel'); // 移除所有大小类 panel.classList.remove('panel-compact', 'panel-normal', 'panel-large'); // 添加新的大小类 panel.classList.add(`panel-${size}`); // 保存用户偏好 GM_setValue('panelSize', size); // 显示提示 const sizeNames = { compact: '紧凑模式', normal: '正常模式', large: '大尺寸模式' }; showToast(`已切换到${sizeNames[size]}`); } // 加载面板大小偏好 function loadPanelSize() { const savedSize = GM_getValue('panelSize', 'normal'); const panel = document.getElementById('read-later-panel'); if (panel) { panel.classList.add(`panel-${savedSize}`); } } // 绑定面板事件 function bindPanelEvents(panel) { const closeBtn = panel.querySelector('.panel-close'); const clearAllBtn = panel.querySelector('.clear-all-btn'); const listItems = panel.querySelectorAll('.list-item'); const deleteButtons = panel.querySelectorAll('.delete-btn'); const syncBtns = panel.querySelectorAll('.sync-btn'); // 大小调整按钮 const resizeBtns = panel.querySelectorAll('.resize-btn'); // 导出相关元素 const exportFormatBtns = panel.querySelectorAll('.export-format-btn'); const exportDownloadBtn = panel.querySelector('#export-download-btn'); const exportCopyBtn = panel.querySelector('#export-copy-btn'); // 排序按钮 const sortBtns = panel.querySelectorAll('.sort-btn'); // 关闭按钮事件 closeBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closePanel(panel); }); clearAllBtn?.addEventListener('click', clearAllReadLater); // 绑定大小调整按钮事件 resizeBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const size = btn.dataset.size; resizePanel(size); }); }); // 处理同步按钮点击 syncBtns.forEach(btn => { btn.addEventListener('click', (e) => { const action = btn.dataset.action; if (action === 'sync') { handleSyncAction(); } else if (action === 'settings') { toggleSettingsPanel(); } }); }); // 列表项点击事件 listItems.forEach(item => { item.addEventListener('click', (e) => { if (e.target.closest('.item-actions')) return; const id = item.dataset.id; const post = readLaterList.find(p => p.id === id); if (post) { window.open(post.url, '_blank'); } }); }); // 删除按钮事件 deleteButtons.forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const id = btn.dataset.id; removeFromReadLater(id); updatePanelContent(); }); }); // 导出格式选择事件 exportFormatBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const format = btn.dataset.format; selectedExportFormat = format; // 更新按钮状态 exportFormatBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // 更新信息显示 const infoDiv = panel.querySelector('.export-info'); if (infoDiv) { infoDiv.innerHTML = `格式: <strong>${format.toUpperCase()}</strong> • ${readLaterList.length} 个帖子`; } }); }); // 下载文件事件 exportDownloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); exportToFile(selectedExportFormat); }); // 复制内容事件 exportCopyBtn?.addEventListener('click', (e) => { e.stopPropagation(); copyExportContent(selectedExportFormat); }); // 排序按钮事件 sortBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const sortMode = btn.dataset.sort; changeSortMode(sortMode); }); }); } // 调整面板大小函数 function resizePanel(size) { const panel = document.getElementById('read-later-panel'); if (!panel) { console.error('[稍后再看] 找不到面板元素'); return; } // 移除所有大小类 panel.classList.remove('panel-compact', 'panel-normal', 'panel-large'); // 根据大小设置不同的宽度 let width, listHeight; switch (size) { case 'compact': width = '320px'; listHeight = '250px'; break; case 'large': width = '500px'; listHeight = '450px'; break; default: // normal width = '380px'; listHeight = '350px'; } // 直接设置样式 panel.style.width = width; // 调整列表高度 const listElement = panel.querySelector('.read-later-list'); if (listElement) { listElement.style.maxHeight = listHeight; } // 保存用户偏好 GM_setValue('panelSize', size); // 显示提示 const sizeNames = { compact: '紧凑模式', normal: '正常模式', large: '大尺寸模式' }; showToast(`已切换到${sizeNames[size]} (${width})`); } // 获取同步状态HTML function getSyncStatusHTML() { if (!syncConfig.enabled) { return ` <div class="sync-status"> <span>未启用同步</span> <button class="sync-btn" data-action="settings">设置</button> </div> `; } const lastSyncText = syncConfig.lastSync ? `上次同步: ${formatTime(new Date(syncConfig.lastSync).toISOString())}` : '未同步'; const needSync = GM_getValue('needSync', 'false') === 'true'; const statusClass = needSync ? 'error' : 'success'; const statusText = needSync ? '需要同步' : '已同步'; return ` <div class="sync-status ${statusClass}"> <span>${statusText} • ${lastSyncText}</span> <button class="sync-btn" data-action="sync">立即同步</button> </div> `; } // 处理同步操作 async function handleSyncAction() { const syncBtn = document.querySelector('.sync-btn[data-action="sync"]'); if (!syncBtn) return; try { syncBtn.disabled = true; syncBtn.textContent = '同步中...'; await performSync(); syncBtn.textContent = '同步成功'; setTimeout(() => { updatePanelContent(); }, 1000); } catch (error) { console.error('[稍后再看] 同步失败:', error); syncBtn.textContent = '同步失败'; showToast('同步失败: ' + error.message); setTimeout(() => { updatePanelContent(); }, 2000); } } // 获取当前帖子信息(如果在帖子页面) function getCurrentTopicInfo() { const topicMatch = window.location.pathname.match(/^\/t\/([^\/]+)\/(\d+)/); if (!topicMatch) return null; const topicId = topicMatch[2]; return readLaterList.find(item => item.id === topicId); } // 使元素可拖拽 function makeDraggable(element) { let pos = GM_getValue('buttonPosition', null); if (pos) { try { pos = JSON.parse(pos); element.style.top = pos.top; element.style.right = pos.right; element.style.transform = 'none'; } catch (e) { // 使用默认位置 } } element.addEventListener('mousedown', startDrag); document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag); function startDrag(e) { if (e.target.closest('#read-later-panel') || e.target.closest('.hide-btn') || e.target.closest('.settings-panel')) { return; } isDragging = true; element.classList.add('dragging'); const rect = element.getBoundingClientRect(); dragOffset.x = e.clientX - rect.left; dragOffset.y = e.clientY - rect.top; e.preventDefault(); } function drag(e) { if (!isDragging) return; const x = e.clientX - dragOffset.x; const y = e.clientY - dragOffset.y; // 边界检测 const maxX = window.innerWidth - element.offsetWidth; const maxY = window.innerHeight - element.offsetHeight; const newX = Math.max(0, Math.min(x, maxX)); const newY = Math.max(0, Math.min(y, maxY)); element.style.left = newX + 'px'; element.style.top = newY + 'px'; element.style.right = 'auto'; element.style.transform = 'none'; } function stopDrag() { if (!isDragging) return; isDragging = false; element.classList.remove('dragging'); // 保存位置 const style = window.getComputedStyle(element); GM_setValue('buttonPosition', JSON.stringify({ top: style.top, right: style.right })); } } // 格式化时间 function formatTime(isoString) { const date = new Date(isoString); const now = new Date(); const diff = now - date; const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (minutes < 1) return '刚刚'; if (minutes < 60) return `${minutes}分钟前`; if (hours < 24) return `${hours}小时前`; if (days < 7) return `${days}天前`; return date.toLocaleDateString('zh-CN'); } // 切换排序模式 function changeSortMode(mode) { if (currentSortMode === mode) return; currentSortMode = mode; saveReadLaterList(); // 重新渲染面板内容 updatePanelContent(); // 显示提示 const modeNames = { 'oldest-first': '从旧到新', 'newest-first': '从新到旧', 'title': '按标题排序' }; showToast(`排序已切换:${modeNames[mode]}`); } // ===== 导出功能 ===== // 生成导出内容 function generateExportContent(format) { const timestamp = new Date().toLocaleString('zh-CN'); const count = readLaterList.length; switch (format) { case 'markdown': return generateMarkdown(timestamp, count); case 'html': return generateHTML(timestamp, count); case 'json': return generateJSON(timestamp, count); default: return ''; } } // 生成 Markdown 格式 function generateMarkdown(timestamp, count) { const header = `# Linux.do 稍后再看列表 > 导出时间: ${timestamp} > 帖子数量: ${count} --- `; const sortedList = getSortedList(); const content = sortedList.map((item, index) => { const addedDate = new Date(item.addedAt).toLocaleDateString('zh-CN'); return `## ${index + 1}. ${item.title} - **链接**: [${item.title}](${item.url}) - **帖子ID**: ${item.id} - **添加时间**: ${addedDate} - **URL**: \`${item.url}\` `; }).join(''); const footer = `--- *由 Linux.do 稍后再看脚本导出*`; return header + content + footer; } // 生成 HTML 格式 function generateHTML(timestamp, count) { const header = `<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Linux.do 稍后再看列表</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; background: #f8f9fa; } .container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } h1 { color: #333; border-bottom: 3px solid #667eea; padding-bottom: 10px; } .meta { background: #e9ecef; padding: 15px; border-radius: 5px; margin-bottom: 20px; } .item { margin-bottom: 25px; padding: 20px; border: 1px solid #dee2e6; border-radius: 5px; background: #f8f9fa; } .item h3 { margin: 0 0 10px 0; color: #495057; } .item a { color: #667eea; text-decoration: none; font-weight: 500; } .item a:hover { text-decoration: underline; } .details { font-size: 14px; color: #6c757d; margin-top: 10px; } .details span { margin-right: 15px; } .footer { text-align: center; margin-top: 30px; color: #6c757d; font-size: 14px; } </style> </head> <body> <div class="container"> <h1>📚 Linux.do 稍后再看列表</h1> <div class="meta"> <strong>导出时间:</strong> ${timestamp}<br> <strong>帖子数量:</strong> ${count} </div> `; const sortedList = getSortedList(); const content = sortedList.map((item, index) => { const addedDate = new Date(item.addedAt).toLocaleDateString('zh-CN'); return ` <div class="item"> <h3>${index + 1}. <a href="${item.url}" target="_blank">${item.title}</a></h3> <div class="details"> <span><strong>帖子ID:</strong> ${item.id}</span> <span><strong>添加时间:</strong> ${addedDate}</span> </div> </div> `; }).join(''); const footer = ` <div class="footer"> <em>由 Linux.do 稍后再看脚本导出</em> </div> </div> </body> </html>`; return header + content + footer; } // 生成 JSON 格式 function generateJSON(timestamp, count) { const exportData = { metadata: { title: 'Linux.do 稍后再看列表', exportTime: timestamp, exportTimestamp: Date.now(), count: count, version: '2.3', source: 'Linux.do 稍后再看脚本' }, data: getSortedList().map(item => ({ id: item.id, title: item.title, url: item.url, slug: item.slug, addedAt: item.addedAt, addedTimestamp: new Date(item.addedAt).getTime() })) }; return JSON.stringify(exportData, null, 2); } // 导出到文件 function exportToFile(format) { try { const content = generateExportContent(format); const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); let filename, mimeType; switch (format) { case 'markdown': filename = `linux-do-readlater-${timestamp}.md`; mimeType = 'text/markdown'; break; case 'html': filename = `linux-do-readlater-${timestamp}.html`; mimeType = 'text/html'; break; case 'json': filename = `linux-do-readlater-${timestamp}.json`; mimeType = 'application/json'; break; default: throw new Error('不支持的导出格式'); } // 创建下载链接 const blob = new Blob([content], { type: mimeType + ';charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); // 清理 URL setTimeout(() => URL.revokeObjectURL(url), 1000); showToast(`已导出 ${format.toUpperCase()} 文件: ${filename}`); } catch (error) { console.error('[稍后再看] 导出文件失败:', error); showToast('导出文件失败: ' + error.message); } } // 复制导出内容到剪贴板 async function copyExportContent(format) { try { const content = generateExportContent(format); if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(content); showToast(`已复制 ${format.toUpperCase()} 内容到剪贴板`); } else { // 降级方案:使用 textarea const textarea = document.createElement('textarea'); textarea.value = content; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); showToast(`已复制 ${format.toUpperCase()} 内容到剪贴板(兼容模式)`); } } catch (error) { console.error('[稍后再看] 复制内容失败:', error); showToast('复制失败: ' + error.message); } } // ===== 同步功能 ===== // 更新设置面板 - 修复版本,添加 Gist 选择功能 function updateSettingsPanel() { const panel = document.getElementById('settings-panel'); panel.innerHTML = ` <div class="panel-header"> <span>同步设置</span> <button class="panel-close">×</button> </div> <div class="settings-form"> <div class="form-group"> <label class="form-label"> <input type="checkbox" class="form-checkbox" id="sync-enabled" ${syncConfig.enabled ? 'checked' : ''}> 启用跨设备同步 </label> <div class="form-help">通过 GitHub Gist 在不同设备间同步稍后再看列表</div> </div> <div class="form-group"> <label class="form-label" for="github-token">GitHub Token</label> <input type="password" class="form-input" id="github-token" placeholder="ghp_xxxxxxxxxxxx" value="${syncConfig.token}"> <div class="form-help"> 需要创建一个有 gist 权限的 GitHub Token<br> <a href="https://github.com/settings/tokens/new?scopes=gist" target="_blank">点击创建 Token</a> </div> </div> <div class="form-group"> <label class="form-label" for="gist-id">Gist ID</label> <div class="gist-input-group"> <input type="text" class="form-input" id="gist-id" placeholder="请输入已存在的 Gist ID 或留空创建新的" value="${syncConfig.gistId}"> <button type="button" class="gist-select-btn" id="gist-select-btn">选择</button> </div> <div class="gist-dropdown" id="gist-dropdown"></div> <div class="form-help"> <strong style="color: #ff6b6b;">重要:</strong>如果这是第二台设备,请从第一台设备复制 Gist ID 到这里!<br> Gist ID 可以在 GitHub Gist URL 中找到:https://gist.github.com/<strong>YOUR_GIST_ID</strong><br> ${syncConfig.gistId ? `<span style="color: #4CAF50;">当前 Gist ID: ${syncConfig.gistId}</span>` : '<span style="color: #ff9800;">未设置 Gist ID,将创建新的</span>'} </div> </div> <div class="form-group"> <label class="form-label"> <input type="checkbox" class="form-checkbox" id="auto-sync" ${syncConfig.autoSync ? 'checked' : ''}> 自动同步 </label> <div class="form-help">每5分钟自动与云端同步数据</div> </div> <div class="form-actions"> <button class="btn-primary" id="save-settings">保存设置</button> <button class="btn-secondary" id="test-sync">测试连接</button> <button class="btn-danger" id="reset-sync">重置同步</button> </div> <div id="sync-test-result" style="margin-top: 15px; font-size: 12px;"></div> </div> `; // 绑定事件 const closeBtn = panel.querySelector('.panel-close'); const saveBtn = panel.querySelector('#save-settings'); const testBtn = panel.querySelector('#test-sync'); const resetBtn = panel.querySelector('#reset-sync'); const gistSelectBtn = panel.querySelector('#gist-select-btn'); closeBtn.addEventListener('click', () => closePanel(panel)); saveBtn.addEventListener('click', saveSettings); testBtn.addEventListener('click', testSync); resetBtn.addEventListener('click', resetSync); gistSelectBtn.addEventListener('click', toggleGistDropdown); } // 切换 Gist 下拉菜单 async function toggleGistDropdown() { const dropdown = document.getElementById('gist-dropdown'); const selectBtn = document.getElementById('gist-select-btn'); const tokenInput = document.getElementById('github-token'); const token = tokenInput.value.trim(); if (!token) { showToast('请先填入 GitHub Token'); return; } const isShow = dropdown.classList.contains('show'); if (isShow) { dropdown.classList.remove('show'); return; } // 显示加载状态 console.log('[稍后再看] 开始加载 Gist 列表'); dropdown.innerHTML = '<div class="gist-dropdown-loading">正在加载 Gist 列表...</div>'; dropdown.classList.add('show'); // 强制显示下拉菜单 dropdown.style.display = 'block'; dropdown.style.visibility = 'visible'; dropdown.style.opacity = '1'; selectBtn.disabled = true; selectBtn.textContent = '加载中...'; try { const gists = await fetchUserGists(token); console.log('[稍后再看] 获取到 Gist 列表:', gists.length, '个'); displayGistDropdown(gists); } catch (error) { console.error('[稍后再看] 获取 Gist 列表失败:', error); dropdown.innerHTML = `<div class="gist-dropdown-error">加载失败: ${error.message}<br><small>请检查 Token 是否正确</small></div>`; // 显示错误 5 秒后自动关闭 setTimeout(() => { dropdown.classList.remove('show'); dropdown.style.display = ''; dropdown.style.visibility = ''; dropdown.style.opacity = ''; }, 5000); } finally { selectBtn.disabled = false; selectBtn.textContent = '选择'; } } // 获取用户的 Gist 列表 async function fetchUserGists(token) { try { const response = await fetch('https://api.github.com/gists?per_page=50', { headers: { 'Authorization': `token ${token}`, 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { const errorText = await response.text(); console.error('[稍后再看] GitHub API 错误响应:', errorText); if (response.status === 401) { throw new Error('GitHub Token 无效或已过期'); } else if (response.status === 403) { throw new Error('GitHub Token 权限不足,需要 gist 权限'); } else { throw new Error(`GitHub API 错误: ${response.status}`); } } const allGists = await response.json(); console.log('[稍后再看] 获取到所有 Gist:', allGists.length, '个'); // 筛选出稍后再看相关的 Gist const readLaterGists = allGists.filter(gist => { const hasReadLaterFile = gist.files && gist.files['readlater.json']; const hasReadLaterDesc = gist.description && ( gist.description.includes('稍后再看') || gist.description.includes('Linux.do') ); return hasReadLaterFile || hasReadLaterDesc; }); console.log('[稍后再看] 筛选后的相关 Gist:', readLaterGists.length, '个'); return readLaterGists; } catch (error) { console.error('[稍后再看] fetchUserGists 错误:', error); throw error; } } // 显示 Gist 下拉菜单 function displayGistDropdown(gists) { const dropdown = document.getElementById('gist-dropdown'); console.log('[稍后再看] 显示下拉菜单,Gist 数量:', gists.length); if (gists.length === 0) { dropdown.innerHTML = ` <div class="gist-dropdown-empty"> 未找到稍后再看相关的 Gist<br> <small>保存设置时将自动创建新的</small><br> <button class="btn-secondary" style="margin-top: 8px; font-size: 11px; padding: 4px 8px;" onclick="document.getElementById('gist-dropdown').classList.remove('show')">关闭</button> </div> `; return; } const gistItems = gists.map(gist => { const createDate = new Date(gist.created_at).toLocaleDateString('zh-CN'); const description = gist.description || '无描述'; const truncatedDesc = description.length > 50 ? description.substring(0, 50) + '...' : description; return ` <div class="gist-dropdown-item" data-gist-id="${gist.id}" title="点击选择此 Gist"> <div class="gist-item-id">${gist.id}</div> <div class="gist-item-desc">${truncatedDesc}</div> <div class="gist-item-date">创建于 ${createDate}</div> </div> `; }).join(''); dropdown.innerHTML = gistItems; // 确保下拉菜单可见 dropdown.classList.add('show'); dropdown.style.cssText = ` display: block !important; visibility: visible !important; opacity: 1 !important; position: absolute !important; z-index: 99999 !important; background: white !important; border: 2px solid #667eea !important; border-radius: 6px !important; box-shadow: 0 8px 24px rgba(0,0,0,0.3) !important; max-height: 250px !important; overflow-y: auto !important; top: calc(100% + 2px) !important; left: 0 !important; right: 0 !important; `; // 绑定点击事件 dropdown.querySelectorAll('.gist-dropdown-item').forEach((item, index) => { item.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); console.log('[稍后再看] 选择 Gist:', item.dataset.gistId); const gistId = item.dataset.gistId; const gistInput = document.getElementById('gist-id'); gistInput.value = gistId; // 关闭下拉菜单 dropdown.classList.remove('show'); dropdown.style.cssText = ''; showToast(`已选择 Gist: ${gistId.substring(0, 8)}...`); }); }); } // 保存设置 function saveSettings() { const enabled = document.getElementById('sync-enabled').checked; const token = document.getElementById('github-token').value.trim(); const gistId = document.getElementById('gist-id').value.trim(); const autoSync = document.getElementById('auto-sync').checked; if (enabled && !token) { alert('请填入 GitHub Token'); return; } // 验证 Gist ID 格式(如果填写了的话) if (gistId && !/^[a-f0-9]{32}$/.test(gistId)) { alert('Gist ID 格式不正确,应该是32位的十六进制字符串'); return; } syncConfig.enabled = enabled; syncConfig.token = token; syncConfig.gistId = gistId; syncConfig.autoSync = autoSync; saveSyncConfig(); showToast('设置已保存'); // 重启自动同步 startAutoSync(); // 关闭面板和下拉菜单 closeAllPanels(); } // 测试同步连接 async function testSync() { const resultDiv = document.getElementById('sync-test-result'); const testBtn = document.getElementById('test-sync'); const token = document.getElementById('github-token').value.trim(); if (!token) { resultDiv.innerHTML = '<span style="color: red;">请先填入 GitHub Token</span>'; return; } try { testBtn.disabled = true; testBtn.textContent = '测试中...'; resultDiv.innerHTML = '<span style="color: blue;">正在测试连接...</span>'; // 测试 GitHub API 连接 const response = await fetch('https://api.github.com/user', { headers: { 'Authorization': `token ${token}`, 'Accept': 'application/vnd.github.v3+json' } }); if (response.ok) { const user = await response.json(); resultDiv.innerHTML = `<span style="color: green;">✓ 连接成功!用户: ${user.login}</span>`; } else { throw new Error(`GitHub API 错误: ${response.status}`); } } catch (error) { console.error('[稍后再看] 测试同步失败:', error); resultDiv.innerHTML = `<span style="color: red;">✗ 连接失败: ${error.message}</span>`; } finally { testBtn.disabled = false; testBtn.textContent = '测试连接'; } } // 重置同步 function resetSync() { if (confirm('确定要重置所有同步设置吗?这将清除 Token 和 Gist ID,但不会删除本地数据。')) { syncConfig = { enabled: false, token: '', gistId: '', lastSync: 0, autoSync: true, syncInterval: 5 * 60 * 1000 }; saveSyncConfig(); GM_setValue('needSync', 'false'); showToast('同步设置已重置'); updateSettingsPanel(); } } // 执行同步 - 修复删除同步问题 async function performSync() { if (!syncConfig.enabled || !syncConfig.token) { throw new Error('同步未启用或缺少 Token'); } console.log('[稍后再看] 开始同步...'); try { // 如果没有 Gist ID,先尝试查找现有的 Gist if (!syncConfig.gistId) { const existingGist = await findExistingGist(); if (existingGist) { syncConfig.gistId = existingGist.id; saveSyncConfig(); console.log('[稍后再看] 找到现有 Gist:', existingGist.id); showToast(`找到现有 Gist: ${existingGist.id.substring(0, 8)}...`); } else { await createGist(); showToast('创建了新的同步 Gist'); } } // 获取远程数据 const remoteData = await getRemoteData(); // 合并数据 const mergedData = mergeData(readLaterList, remoteData); // 检查是否有变化 const hasChanges = JSON.stringify(mergedData) !== JSON.stringify(readLaterList); if (hasChanges) { console.log('[稍后再看] 检测到数据变化,更新本地数据'); readLaterList = mergedData; // 不调用 saveReadLaterList(),避免更新修改时间 GM_setValue('readLaterList', JSON.stringify(readLaterList)); updateBadge(); // 更新页面上的按钮状态 setTimeout(updateAllButtonStates, 100); } // 总是上传当前数据到远程(确保远程是最新的) await uploadData(readLaterList); // 更新同步状态 syncConfig.lastSync = Date.now(); saveSyncConfig(); GM_setValue('needSync', 'false'); // 记录远程同步时间 GM_setValue('lastRemoteSync', Date.now()); console.log('[稍后再看] 同步完成'); return true; } catch (error) { console.error('[稍后再看] 同步失败:', error); throw error; } } // 查找现有的稍后再看 Gist async function findExistingGist() { try { const response = await fetch('https://api.github.com/gists', { headers: { 'Authorization': `token ${syncConfig.token}`, 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { return null; } const gists = await response.json(); const readLaterGist = gists.find(gist => gist.files['readlater.json'] && ( gist.description?.includes('稍后再看') || gist.description?.includes('Linux.do') ) ); return readLaterGist || null; } catch (error) { console.error('[稍后再看] 查找现有 Gist 失败:', error); return null; } } // 创建新的 Gist async function createGist() { try { const response = await fetch('https://api.github.com/gists', { method: 'POST', headers: { 'Authorization': `token ${syncConfig.token}`, 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json' }, body: JSON.stringify({ description: `Linux.do 稍后再看数据 - 创建于 ${new Date().toLocaleString()} - 设备: ${navigator.platform}`, public: false, files: { 'readlater.json': { content: JSON.stringify({ version: '1.0', data: readLaterList, lastModified: Date.now(), device: navigator.userAgent, createdAt: new Date().toISOString() }, null, 2) } } }) }); if (!response.ok) { throw new Error(`创建 Gist 失败: ${response.status}`); } const gist = await response.json(); syncConfig.gistId = gist.id; saveSyncConfig(); // 更新 README try { await fetch(`https://api.github.com/gists/${gist.id}`, { method: 'PATCH', headers: { 'Authorization': `token ${syncConfig.token}`, 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json' }, body: JSON.stringify({ files: { 'README.md': { content: `# Linux.do 稍后再看数据 这个 Gist 存储了您在 Linux.do 论坛的稍后再看列表。 **重要提示:** - 如果您要在多个设备间同步,请将此 Gist ID 复制到其他设备的设置中 - **Gist ID: \`${gist.id}\`** - 请勿手动修改 readlater.json 文件内容 创建时间: ${new Date().toLocaleString()} 设备信息: ${navigator.platform} ## 如何在其他设备使用 1. 在其他设备安装相同的脚本 2. 打开同步设置 3. 填入相同的 GitHub Token 4. 在 "Gist ID" 字段填入: \`${gist.id}\` 5. 保存设置即可开始同步 ` } } }) }); } catch (error) { console.error('[稍后再看] 更新 README 失败:', error); } } catch (error) { console.error('[稍后再看] 创建 Gist 失败:', error); throw error; } } // 获取远程数据 async function getRemoteData() { const response = await fetch(`https://api.github.com/gists/${syncConfig.gistId}`, { headers: { 'Authorization': `token ${syncConfig.token}`, 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { if (response.status === 404) { console.log('[稍后再看] Gist 不存在,将创建新的'); syncConfig.gistId = ''; saveSyncConfig(); return []; } throw new Error(`获取远程数据失败: ${response.status}`); } const gist = await response.json(); const fileContent = gist.files['readlater.json']?.content; if (!fileContent) { return []; } try { const data = JSON.parse(fileContent); return data.data || []; } catch (error) { console.error('[稍后再看] 解析远程数据失败:', error); return []; } } // 上传数据到远程 - 包含时间戳 async function uploadData(data) { const now = Date.now(); const response = await fetch(`https://api.github.com/gists/${syncConfig.gistId}`, { method: 'PATCH', headers: { 'Authorization': `token ${syncConfig.token}`, 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json' }, body: JSON.stringify({ files: { 'readlater.json': { content: JSON.stringify({ version: '1.0', data: data, lastModified: now, device: navigator.userAgent, count: data.length, uploadTime: new Date(now).toISOString() }, null, 2) } } }) }); if (!response.ok) { throw new Error(`上传数据失败: ${response.status}`); } console.log('[稍后再看] 数据已上传到远程,时间:', new Date(now).toLocaleString()); } // 合并本地和远程数据 - 修复删除同步问题 function mergeData(localData, remoteData) { // 获取本地和远程的最后修改时间 const localLastModified = GM_getValue('lastLocalModified', 0); const remoteLastModified = GM_getValue('lastRemoteSync', 0); console.log('[稍后再看] 合并数据 - 本地修改时间:', new Date(localLastModified).toLocaleString()); console.log('[稍后再看] 合并数据 - 远程同步时间:', new Date(remoteLastModified).toLocaleString()); console.log('[稍后再看] 本地数据:', localData.length, '项'); console.log('[稍后再看] 远程数据:', remoteData.length, '项'); // 如果本地数据更新,以本地为准 if (localLastModified > remoteLastModified) { console.log('[稍后再看] 本地数据较新,以本地为准'); return [...localData]; } // 如果远程数据更新,以远程为准 if (remoteLastModified > localLastModified) { console.log('[稍后再看] 远程数据较新,以远程为准'); return [...remoteData]; } // 如果时间相同,进行智能合并 console.log('[稍后再看] 时间相同,进行智能合并'); const localIds = new Set(localData.map(item => item.id)); const remoteIds = new Set(remoteData.map(item => item.id)); // 创建合并后的数据集合 const mergedMap = new Map(); // 添加本地数据 localData.forEach(item => { mergedMap.set(item.id, { ...item, source: 'local' }); }); // 添加远程独有的数据 remoteData.forEach(remoteItem => { if (!localIds.has(remoteItem.id)) { mergedMap.set(remoteItem.id, { ...remoteItem, source: 'remote' }); } }); // 转换为数组并按添加时间排序 const merged = Array.from(mergedMap.values()).map(item => { // 移除临时的 source 属性 const { source, ...cleanItem } = item; return cleanItem; }); merged.sort((a, b) => new Date(b.addedAt) - new Date(a.addedAt)); console.log('[稍后再看] 合并完成,最终数据:', merged.length, '项'); return merged; } // 更新所有按钮状态 function updateAllButtonStates() { document.querySelectorAll('.read-later-add-btn').forEach(btn => { const topicId = btn.dataset.topicId; const isAdded = readLaterList.some(item => item.id === topicId); if (isAdded && !btn.classList.contains('added')) { btn.classList.add('added'); btn.innerHTML = '✓'; btn.title = '已在稍后再看中'; } else if (!isAdded && btn.classList.contains('added')) { btn.classList.remove('added'); btn.innerHTML = '+'; btn.title = '添加到稍后再看'; } }); } // 启动自动同步 function startAutoSync() { // 清除现有的定时器 if (window.readLaterSyncInterval) { clearInterval(window.readLaterSyncInterval); } if (!syncConfig.enabled || !syncConfig.autoSync) { return; } // 设置定时同步 window.readLaterSyncInterval = setInterval(async () => { const needSync = GM_getValue('needSync', 'false') === 'true'; if (needSync) { try { await performSync(); console.log('[稍后再看] 自动同步完成'); } catch (error) { console.error('[稍后再看] 自动同步失败:', error); } } }, syncConfig.syncInterval); console.log('[稍后再看] 自动同步已启动,间隔:', syncConfig.syncInterval / 1000, '秒'); } // 启动脚本 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();