// ==UserScript==
// @name Linux.do 稍后再看
// @namespace http://tampermonkey.net/
// @version 2.5
// @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;
}
.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 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', '[]');
try {
readLaterList = JSON.parse(saved);
// 计算数据校验和
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));
// 记录本地修改时间
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 updatePanelContent() {
const panel = document.getElementById('read-later-panel');
const currentTopicInfo = getCurrentTopicInfo();
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>
<div class="read-later-list">
${readLaterList.length > 0 ?
readLaterList.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');
// 关闭按钮事件
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);
});
}
// 调整面板大小函数
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 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 content = readLaterList.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 content = readLaterList.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: readLaterList.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();
}
})();