按发帖时间排序的时间线视图,显示真正的最新发布帖子, 按ESC快速唤起
// ==UserScript==
// @name LINUX DO Timeline
// @namespace https://linux.do/
// @version 1.24
// @description 按发帖时间排序的时间线视图,显示真正的最新发布帖子, 按ESC快速唤起
// @author ccc9527-c
// @match https://linux.do/*
// @icon https://linux.do/uploads/default/original/4X/3/5/7/357c4a83c6bc02fb6d72d63d546beb0a198832a3.png
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_notification
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const scriptVersion = "1.24";
let isDrawerOpen = false;
let isLoading = false;
// ========== 跨标签页通信 ==========
const tabId = Date.now() + Math.random().toString(36).slice(2);
let drawerChannel = null;
let otherTabHasDrawer = false;
function initBroadcastChannel() {
try {
drawerChannel = new BroadcastChannel("timeline_drawer_channel");
drawerChannel.onmessage = (e) => {
const { type, from } = e.data;
if (from === tabId) return; // 忽略自己发的消息
if (type === "drawer_opened") {
// 其他标签页打开了抽屉,关闭当前标签页的抽屉
if (isDrawerOpen) {
closeDrawer();
}
} else if (type === "query_drawer_status") {
// 其他标签页询问抽屉状态
if (isDrawerOpen) {
drawerChannel.postMessage({
type: "drawer_status_response",
from: tabId,
isOpen: true,
});
}
} else if (type === "drawer_status_response" && e.data.isOpen) {
// 收到其他标签页的响应,有标签页已经打开了抽屉
otherTabHasDrawer = true;
}
};
} catch (e) {
console.log("[时间线] BroadcastChannel 不可用,跳过跨标签页同步");
}
}
function broadcastDrawerOpened() {
if (drawerChannel) {
drawerChannel.postMessage({ type: "drawer_opened", from: tabId });
}
}
async function checkOtherTabHasDrawer() {
if (!drawerChannel) return false;
otherTabHasDrawer = false;
drawerChannel.postMessage({ type: "query_drawer_status", from: tabId });
// 等待 100ms 收集响应
await new Promise((resolve) => setTimeout(resolve, 100));
return otherTabHasDrawer;
}
let allTopics = [];
let usersMap = {};
let currentPage = 0;
let hasMorePages = true;
let isLoadingMore = false;
let loadedTopicIds = new Set();
let countdownTimer = null;
let remainingSeconds = 0;
let currentTab = "all"; // 当前选中的 Tab: "all" 或具体的 tabId
let currentCategoryId = null; // 当前分类 ID
let currentFilter = "all"; // 当前本地过滤条件: "all", "unseen", "read"
let autoLoadCount = 0; // 自动递归加载次数,防止无限加载
let myUserName = "";
function getCsrfToken() {
return (
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") || ""
);
}
// ========== 分类配置映射表 ==========
const CATEGORY_CONFIG = {
4: { name: "开发调优", icon: "code", color: "#32c3c3", tabId: "develop" },
20: { name: "开发调优, Lv1", icon: "code", color: "#32c3c3" },
31: { name: "开发调优, Lv2", icon: "code", color: "#32c3c3" },
88: { name: "开发调优, Lv3", icon: "code", color: "#32c3c3" },
98: {
name: "国产替代",
icon: "seedling",
color: "#D12C25",
tabId: "domestic",
},
99: { name: "国产替代, Lv1", icon: "seedling", color: "#D12C25" },
100: { name: "国产替代, Lv2", icon: "seedling", color: "#D12C25" },
101: { name: "国产替代, Lv3", icon: "seedling", color: "#D12C25" },
14: {
name: "资源荟萃",
icon: "square-share-nodes",
color: "#12A89D",
tabId: "resource",
},
83: { name: "资源荟萃, Lv1", icon: "square-share-nodes", color: "#12A89D" },
84: { name: "资源荟萃, Lv2", icon: "square-share-nodes", color: "#12A89D" },
85: { name: "资源荟萃, Lv3", icon: "square-share-nodes", color: "#12A89D" },
94: { name: "网盘资源", icon: "hard-drive", color: "#16b176" },
95: { name: "网盘资源, Lv1", icon: "hard-drive", color: "#16b176" },
96: { name: "网盘资源, Lv2", icon: "hard-drive", color: "#16b176" },
97: { name: "网盘资源, Lv3", icon: "hard-drive", color: "#16b176" },
42: { name: "文档共建", icon: "book", color: "#9cb6c4", tabId: "wiki" },
75: { name: "文档共建, Lv1", icon: "book", color: "#9cb6c4" },
76: { name: "文档共建, Lv2", icon: "book", color: "#9cb6c4" },
77: { name: "文档共建, Lv3", icon: "book", color: "#9cb6c4" },
10: { name: "跳蚤市场", icon: "coins", color: "#ED207B", tabId: "trade" },
106: {
name: "积分乐园",
icon: "credit-card",
color: "#fcca44",
tabId: "credit",
},
107: { name: "积分乐园, Lv1", icon: "credit-card", color: "#fcca44" },
108: { name: "积分乐园, Lv2", icon: "credit-card", color: "#fcca44" },
109: { name: "积分乐园, Lv3", icon: "credit-card", color: "#fcca44" },
27: { name: "非我莫属", icon: "briefcase", color: "#a8c6fe", tabId: "job" },
72: { name: "非我莫属, Lv1", icon: "briefcase", color: "#a8c6fe" },
73: { name: "非我莫属, Lv2", icon: "briefcase", color: "#a8c6fe" },
74: { name: "非我莫属, Lv3", icon: "briefcase", color: "#a8c6fe" },
32: {
name: "读书成诗",
icon: "book-open-reader",
color: "#e0d900",
tabId: "reading",
},
69: { name: "读书成诗, Lv1", icon: "book-open-reader", color: "#e0d900" },
70: { name: "读书成诗, Lv2", icon: "book-open-reader", color: "#e0d900" },
71: { name: "读书成诗, Lv3", icon: "book-open-reader", color: "#e0d900" },
46: {
name: "扬帆起航",
icon: "rocket",
color: "#ff9838",
tabId: "startup",
},
66: { name: "扬帆起航, Lv1", icon: "rocket", color: "#ff9838" },
67: { name: "扬帆起航, Lv2", icon: "rocket", color: "#ff9838" },
68: { name: "扬帆起航, Lv3", icon: "rocket", color: "#ff9838" },
34: {
name: "前沿快讯",
icon: "newspaper",
color: "#BB8FCE",
tabId: "news",
},
78: { name: "前沿快讯, Lv1", icon: "newspaper", color: "#BB8FCE" },
79: { name: "前沿快讯, Lv2", icon: "newspaper", color: "#BB8FCE" },
80: { name: "前沿快讯, Lv3", icon: "newspaper", color: "#BB8FCE" },
36: {
name: "福利羊毛",
icon: "piggy-bank",
color: "#E45735",
tabId: "welfare",
},
60: { name: "福利羊毛, Lv1", icon: "piggy-bank", color: "#E45735" },
61: { name: "福利羊毛, Lv2", icon: "piggy-bank", color: "#E45735" },
62: { name: "福利羊毛, Lv3", icon: "piggy-bank", color: "#E45735" },
11: {
name: "搞七捻三",
icon: "droplet",
color: "#3AB54A",
tabId: "gossip",
},
35: { name: "搞七捻三, Lv1", icon: "droplet", color: "#3AB54A" },
89: { name: "搞七捻三, Lv2", icon: "droplet", color: "#3AB54A" },
21: { name: "搞七捻三, Lv3", icon: "droplet", color: "#3AB54A" },
102: {
name: "社区孵化",
icon: "lightbulb",
color: "#ffbb00",
tabId: "incubation",
},
103: { name: "社区孵化, Lv1", icon: "lightbulb", color: "#ffbb00" },
104: { name: "社区孵化, Lv2", icon: "lightbulb", color: "#ffbb00" },
105: { name: "社区孵化, Lv3", icon: "lightbulb", color: "#ffbb00" },
110: {
name: "虫洞广场",
icon: "hurricane",
color: "#ff00f7",
tabId: "square",
},
2: {
name: "运营反馈",
icon: "comments",
color: "#808281",
tabId: "feedback",
},
63: { name: "运营反馈, Lv1", icon: "comments", color: "#808281" },
64: { name: "运营反馈, Lv2", icon: "comments", color: "#808281" },
65: { name: "运营反馈, Lv3", icon: "comments", color: "#808281" },
45: { name: "深海幽域", icon: "water", color: "#45B7D1", tabId: "muted" },
57: { name: "深海幽域, Lv1", icon: "water", color: "#45B7D1" },
58: { name: "深海幽域, Lv2", icon: "water", color: "#45B7D1" },
59: { name: "深海幽域, Lv3", icon: "water", color: "#45B7D1" },
};
// 获取分类名称
function getCategoryName(categoryId) {
return CATEGORY_CONFIG[categoryId]?.name || `分类${categoryId}`;
}
// 获取分类图标
function getCategoryIcon(categoryId) {
return CATEGORY_CONFIG[categoryId]?.icon || "folder";
}
// 获取分类颜色
function getCategoryColor(categoryId) {
return CATEGORY_CONFIG[categoryId]?.color || "#888888";
}
// ========== 核心价值观点击特效 ==========
const CORE_VALUES = ["真诚", "友善", "团结", "专业"];
let coreValueIndex = 0;
// 初始化核心价值观点击特效
function initCoreValueEffect() {
document.addEventListener("click", function (event) {
if (!GM_getValue("timeline_enable_slogan", false)) return;
const textElement = document.createElement("span");
textElement.className = "core-value-text-effect";
textElement.textContent = CORE_VALUES[coreValueIndex];
coreValueIndex = (coreValueIndex + 1) % CORE_VALUES.length;
document.body.appendChild(textElement);
const xOffset = -textElement.offsetWidth / 2;
const yOffset = -textElement.offsetHeight;
textElement.style.left = `${event.pageX + xOffset}px`;
textElement.style.top = `${event.pageY + yOffset}px`;
setTimeout(() => {
textElement.remove();
}, 500);
});
}
// ========== CSS 样式 ==========
GM_addStyle(`
/* 悬浮按钮 */
.timeline-float-btn {
position: fixed;
width: 30px;
height: 30px;
border-radius: 50%;
background: #099dd7; /* 接近 Linux.do Icon 的深色底层 */
color: white;
border: 2px solid #099dd7; /* 第一层黑环 (2px) */
cursor: grab;
box-shadow:
0 0 0 2px #099dd7, /* 第二层白环 (2px) */
0 0 0 4px #099dd7; /* 第三层黄环 (2px) */
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
touch-action: none;
padding: 0;
outline: none;
}
.timeline-float-btn text {
transition: fill 1s ease;
}
.timeline-float-btn:hover {
transform: scale(1.1);
background: #099dd7;
box-shadow:
0 0 0 2px #099dd7,
0 0 0 4px #099dd7,
0 0 20px 5px rgba(252, 202, 4, 0.4); /* 发光效果 */
}
.timeline-float-btn:hover text {
/* 移除聚焦变黄,保持渐变色 */
}
.timeline-float-btn:active {
cursor: grabbing;
transform: scale(0.95);
}
.timeline-float-btn.docked {
opacity: 0.7;
transform: scale(0.9);
}
.timeline-float-btn.docked:hover {
opacity: 1;
transform: scale(1.1);
}
.timeline-float-btn.dragging {
cursor: grabbing;
transform: scale(1.1);
transition: none !important;
}
/* 侧边栏抽屉 - 移除遮罩层相关样式 */
@keyframes timeline-glow-breath {
0% { box-shadow: 0 0 3px rgba(252, 202, 4, 0.2); }
50% { box-shadow: 0 0 12px 3px rgba(252, 202, 4, 0.5); }
100% { box-shadow: 0 0 3px rgba(252, 202, 4, 0.2); }
}
/* 核心价值观特效样式 */
.core-value-text-effect {
position: absolute;
font-size: 20px;
font-weight: bold;
color: #ff69b4;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
z-index: 99999;
animation: fadeAndMoveUp 1s ease-out forwards;
pointer-events: none;
user-select: none;
}
@keyframes fadeAndMoveUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-50px);
}
}
.timeline-header-icon-glow {
animation: timeline-glow-breath 4s infinite ease-in-out;
position: relative;
}
.timeline-update-badge {
position: absolute;
top: -6px;
right: -8px;
background: #ff4d4f;
color: white;
font-size: 9px;
padding: 1px 4px;
border-radius: 4px;
font-weight: bold;
line-height: 1.2;
display: none;
animation: timeline-badge-pulse 1.5s infinite ease-in-out;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 10;
cursor: pointer;
pointer-events: auto;
}
@keyframes timeline-badge-pulse {
0% { transform: scale(0.9); }
50% { transform: scale(1.1); }
100% { transform: scale(0.9); }
}
.timeline-drawer {
position: fixed;
top: 0;
right: calc(-1 * var(--timeline-width) - 20px); /* 动态计算偏移,留出阴影缓冲区 */
width: var(--timeline-width);
height: 100vh;
background: var(--secondary, #fff);
z-index: 300;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
transition: right 0.3s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 调整轴 */
.timeline-drawer-resizer {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
cursor: ew-resize;
z-index: 10002;
transition: background 0.2s;
}
.timeline-drawer-resizer:hover, .timeline-drawer-resizer.resizing {
background: var(--tertiary, #08c);
}
.timeline-drawer.open {
right: 0;
}
.timeline-drawer-header {
padding: 16px 20px;
border-bottom: 1px solid var(--primary-low, #e9e9e9);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.timeline-drawer-title {
font-size: 18px;
font-weight: 600;
color: var(--primary, #222);
display: flex;
align-items: center;
gap: 8px;
}
.timeline-drawer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.timeline-drawer-refresh {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--primary-medium, #666);
transition: background 0.2s;
}
.timeline-drawer-refresh:hover {
background: var(--primary-very-low, #f0f0f0);
}
.timeline-drawer-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--primary-medium, #666);
transition: background 0.2s;
}
.timeline-drawer-close:hover {
background: var(--primary-very-low, #f0f0f0);
}
.timeline-drawer-content {
flex: 1;
overflow-y: auto;
padding: 0;
min-height: 200px;
}
.timeline-refresh-settings {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--primary-medium, #888);
margin-right: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.timeline-refresh-settings:hover {
background: var(--primary-very-low, #f0f0f0);
color: var(--tertiary, #08c);
}
.timeline-countdown {
font-variant-numeric: tabular-nums;
font-weight: 500;
min-width: 25px;
}
.timeline-countdown.active {
color: var(--tertiary, #08c);
}
/* Tabs 样式 */
.timeline-tabs-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--secondary, #fff);
border-bottom: 1px solid var(--primary-low, #e9e9e9);
overflow-x: auto;
flex-shrink: 0;
scrollbar-width: none; /* Firefox */
}
.timeline-tabs-container::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.timeline-tabs-container.grabbing {
cursor: grabbing;
scroll-behavior: auto;
}
.timeline-tab {
user-select: none;
}
/* 过滤工具栏 */
.timeline-filter-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--primary-very-low, #f8f8f8);
border-bottom: 1px solid var(--primary-low, #e9e9e9);
font-size: 12px;
color: var(--primary-medium, #888);
flex-shrink: 0;
}
.timeline-filter-item {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
}
.timeline-filter-item:hover {
color: var(--tertiary, #08c);
background: var(--primary-low, #eee);
}
.timeline-filter-item.active {
color: #fff;
background: var(--tertiary, #08c);
}
.timeline-tab {
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
color: var(--primary-medium, #666);
background: var(--primary-very-low, #f0f0f0);
transition: all 0.2s;
border: 1px solid transparent;
}
.timeline-tab:hover {
color: var(--primary, #222);
background: var(--primary-low, #e9e9e9);
}
.timeline-tab.active {
color: white;
background: var(--tertiary, #08c);
border-color: var(--tertiary, #08c);
}
/* 下拉管理相关 */
.timeline-tabs-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--secondary, #fff);
border-bottom: 1px solid var(--primary-low, #e9e9e9);
}
.timeline-tabs-container {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
overflow-x: auto;
scrollbar-width: none;
}
.timeline-tabs-more-btn {
width: 32px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: var(--secondary, #fff);
box-shadow: -10px 0 10px -5px rgba(0,0,0,0.05);
z-index: 10;
color: var(--primary-medium, #666);
border-left: 1px solid var(--primary-low, #e9e9e9);
}
.timeline-tabs-more-btn:hover {
color: var(--tertiary, #08c);
}
.timeline-tabs-modal {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--secondary, #fff);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 16px;
display: none;
max-height: 300px;
overflow-y: auto;
}
.timeline-tabs-modal.open {
display: block;
}
.timeline-tabs-modal-header {
font-size: 12px;
color: var(--primary-medium, #888);
margin-bottom: 12px;
display: flex;
justify-content: space-between;
}
.timeline-tabs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 10px;
}
.timeline-grid-item {
padding: 6px 4px;
background: var(--primary-very-low, #f0f0f0);
border-radius: 4px;
font-size: 12px;
text-align: center;
cursor: move;
user-select: none;
border: 1px solid transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timeline-grid-item:hover {
border-color: var(--tertiary, #08c);
}
.timeline-grid-item.dragging {
opacity: 0.5;
background: var(--tertiary-low, #e6f3ff);
border: 1px dashed var(--tertiary, #08c);
}
.timeline-grid-item.drop-target {
border: 1px solid var(--tertiary, #08c);
transform: scale(1.05);
background: var(--tertiary-low, #e6f3ff);
}
.timeline-grid-item.active {
color: var(--tertiary, #08c);
font-weight: bold;
background: var(--tertiary-low, #e6f3ff);
}
.timeline-topic-list {
list-style: none;
margin: 0;
padding: 0;
}
.timeline-topic-item {
padding: 12px 20px;
border-bottom: 1px solid var(--primary-very-low, #f0f0f0);
cursor: pointer;
transition: background 0.2s;
position: relative;
}
.timeline-topic-item:hover {
background: var(--primary-very-low, #f8f8f8);
}
.timeline-unseen-dot {
position: absolute;
top: 12px;
right: 12px;
width: 8px;
height: 8px;
background: var(--tertiary, #08c);
border-radius: 50%;
}
.timeline-topic-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.timeline-topic-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
object-fit: cover;
}
.timeline-topic-avatar.square-avatar,
.timeline-user-avatar.square-avatar {
border-radius: 4px;
}
.timeline-topic-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.timeline-topic-user-info {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.timeline-topic-username {
font-size: 13px;
color: var(--primary, #222);
font-weight: 500;
cursor: pointer;
}
.timeline-topic-username:hover {
color: var(--tertiary, #08c);
}
.timeline-topic-name {
font-size: 12px;
color: var(--primary-medium, #888);
}
.timeline-topic-time {
font-size: 12px;
color: var(--primary-medium, #888);
}
.timeline-topic-title {
font-size: 14px;
color: var(--primary, #222);
line-height: 1.4;
margin: 0;
word-break: break-word;
}
.timeline-topic-title:hover {
color: var(--tertiary, #08c);
}
.timeline-topic-stats {
display: flex;
gap: 12px;
margin-top: 8px;
font-size: 12px;
color: var(--primary-medium, #888);
}
.timeline-topic-stat {
display: flex;
align-items: center;
gap: 4px;
}
.timeline-loading-2 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--primary-medium, #888);
width: 100%;
box-sizing: border-box;
}
.timeline-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--primary-low, #e9e9e9);
border-top-color: var(--tertiary, #08c);
border-radius: 50%;
animation: timeline-spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes timeline-spin {
to { transform: rotate(360deg); }
}
.timeline-load-more {
padding: 16px 20px;
text-align: center;
color: var(--primary-medium, #888);
font-size: 14px;
}
.timeline-load-more-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--primary-low, #e9e9e9);
border-top-color: var(--tertiary, #08c);
border-radius: 50%;
animation: timeline-spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
.timeline-no-more {
padding: 16px 20px;
text-align: center;
color: var(--primary-low-mid, #aaa);
font-size: 13px;
}
.timeline-error {
padding: 40px 20px;
text-align: center;
color: var(--danger, #e45735);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.timeline-error-icon {
font-size: 40px;
}
.timeline-error-msg {
font-size: 16px;
font-weight: 600;
}
.timeline-error-detail {
font-size: 12px;
color: var(--primary-medium, #888);
max-width: 300px;
word-break: break-word;
}
.timeline-retry-btn {
margin-top: 8px;
padding: 8px 20px;
background: var(--tertiary, #08c);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
}
.timeline-retry-btn:hover {
opacity: 0.85;
}
.timeline-category {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: var(--primary-very-low, #f0f0f0);
color: var(--primary-medium, #666);
margin-right: 6px;
}
.timeline-category-icon {
width: 12px;
height: 12px;
fill: var(--category-color, #888);
}
.timeline-topic-category-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-top: 6px;
}
.timeline-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.timeline-tag {
display: inline-block;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: var(--primary-very-low, #f0f0f0);
color: var(--primary-medium, #666);
}
/* 挤压模式相关样式 */
:root {
--timeline-width: 400px;
}
body {
transition: padding-right 0.3s ease;
}
body.timeline-drawer-push {
padding-right: var(--timeline-width) !important;
}
/* 适配 Discourse 顶栏 */
.d-header {
transition: right 0.3s ease !important;
}
body.timeline-drawer-push .d-header {
right: var(--timeline-width) !important;
}
/* 适配移动端或小屏 */
@media screen and (max-width: 768px) {
:root {
--timeline-width: 90vw !important;
}
.timeline-drawer {
width: var(--timeline-width) !important;
z-index: 10001 !important;
box-shadow: -10px 0 30px rgba(0,0,0,0.3);
}
body.timeline-drawer-push {
padding-right: 0 !important;
}
body.timeline-drawer-push .d-header {
right: 0 !important;
}
.timeline-drawer-resizer {
display: none; /* 移动端禁用手动调宽 */
}
}
/* 遮罩层 */
.timeline-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: none;
backdrop-filter: blur(2px);
}
.timeline-overlay.active {
display: block;
}
/* 强提醒高亮 - 使用与悬浮按钮相同的黄色 */
@keyframes timeline-new-pulse {
0% {
box-shadow: inset 0 0 0 2px #fcca04;
background: rgba(252, 202, 4, 0.15);
}
100% {
box-shadow: inset 0 0 0 0px transparent;
background: transparent;
}
}
.timeline-topic-item.new-topic-highlight {
animation: timeline-new-pulse 10s ease-out forwards;
position: relative;
}
/* 设置弹窗样式 - 限制在抽屉内 */
.timeline-settings-modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
width: 90%;
max-width: 320px;
background: var(--secondary, #fff);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
z-index: 10005;
display: none;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding: 20px;
color: var(--primary, #222);
border: 1px solid var(--primary-low, #e9e9e9);
animation: timeline-glow-breath 4s infinite ease-in-out; /* 添加发光动画 */
}
.timeline-settings-modal.open {
display: block;
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.timeline-settings-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-settings-close {
cursor: pointer;
font-size: 20px;
color: var(--primary-medium, #888);
}
.timeline-settings-group {
margin-bottom: 16px;
}
.timeline-following-container {
margin-top: 10px;
padding: 8px;
background: var(--primary-very-low, #f0f0f0);
border-radius: 6px;
border: 1px solid var(--primary-low, #e9e9e9);
}
.timeline-following-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--primary-medium, #888);
}
.timeline-following-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 160px;
overflow-y: auto;
}
.timeline-following-item {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
background: var(--primary-very-low, #eee);
}
.timeline-following-item.selected {
border-color: var(--tertiary, #3b82f6);
}
.timeline-following-item.selected::after {
content: "✓";
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
background: var(--tertiary, #3b82f6);
color: white;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid var(--secondary, #fff);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
z-index: 1;
}
.timeline-following-item:hover {
border-color: var(--tertiary, #3b82f6);
transform: scale(1.1);
}
.timeline-following-item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
display: block;
}
.timeline-import-btn {
font-size: 11px;
color: var(--tertiary, #08c);
cursor: pointer;
text-decoration: underline;
}
.timeline-import-btn:hover {
color: var(--tertiary-hover, #005f88);
}
.timeline-settings-label {
display: block;
font-size: 14px;
margin-bottom: 8px;
color: var(--primary-medium, #666);
}
.timeline-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* Toggle Switch */
.timeline-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.timeline-switch input {
opacity: 0;
width: 0;
height: 0;
}
.timeline-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.timeline-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .timeline-slider {
background-color: var(--tertiary, #08c);
}
input:checked + .timeline-slider:before {
transform: translateX(20px);
}
.timeline-input {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--primary-low, #e9e9e9);
border-radius: 4px;
font-size: 14px;
text-align: center;
background: var(--secondary, #fff);
color: var(--primary, #222);
}
.timeline-settings-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--primary-medium, #666);
transition: background 0.2s;
}
.timeline-settings-btn:hover {
background: var(--primary-very-low, #f0f0f0);
color: var(--tertiary, #08c);
}
/* 用户搜索和列表 */
.timeline-user-search-container {
position: relative;
display: flex;
gap: 8px;
align-items: center;
}
.timeline-user-search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--primary-low, #e9e9e9);
border-radius: 8px;
background: var(--secondary, #fff);
color: var(--primary, #222);
font-size: 13px;
outline: none;
margin-bottom: 0 !important;
}
/* 关注的人按钮 */
.timeline-following-trigger {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
height: 34px;
background: var(--primary-very-low, #f0f0f0);
border: 1px solid var(--primary-low, #e9e9e9);
border-radius: 8px;
cursor: pointer;
color: var(--primary-medium, #666);
transition: all 0.2s;
white-space: nowrap;
font-size: 13px;
gap: 4px;
}
.timeline-following-trigger:hover {
background: var(--primary-low, #e9e9e9);
color: var(--primary, #222);
}
/* 子弹窗样式 */
.timeline-sub-modal {
position: absolute;
top: 40px;
right: 0;
width: 280px;
background: var(--secondary, #fff);
border: 1px solid var(--primary-low, #e9e9e9);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 1001;
display: none;
flex-direction: column;
overflow: hidden;
backdrop-filter: blur(10px);
background: rgba(var(--secondary-rgb, 255, 255, 255), 0.95);
}
.timeline-sub-modal.open {
display: flex;
animation: timeline-fade-in 0.2s ease-out;
}
.timeline-sub-modal-header {
padding: 10px 12px;
border-bottom: 1px solid var(--primary-low, #e9e9e9);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--primary-very-low, #f9f9f9);
}
.timeline-sub-modal-title {
font-size: 13px;
font-weight: 600;
}
.timeline-sub-modal-close {
cursor: pointer;
font-size: 14px;
color: var(--primary-low-mid, #aaa);
}
.timeline-sub-modal-content {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.timeline-user-search-input:focus {
border-color: var(--tertiary, #08c);
box-shadow: 0 0 0 2px var(--tertiary-low, rgba(0, 136, 204, 0.2));
}
.timeline-user-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--secondary, #fff);
border: 1px solid var(--primary-low, #e9e9e9);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10006;
max-height: 200px;
overflow-y: auto;
display: none;
margin-top: 4px;
}
.timeline-user-search-results.active {
display: block;
}
.timeline-user-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
}
.timeline-user-search-item:hover {
background: var(--primary-very-low, #f0f0f0);
}
.timeline-user-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
max-height: 160px;
overflow-y: auto;
padding-right: 4px;
}
/* 滚动条美化 */
.timeline-user-list::-webkit-scrollbar {
width: 4px;
}
.timeline-user-list::-webkit-scrollbar-thumb {
background: var(--primary-low, #eee);
border-radius: 4px;
}
.timeline-selected-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--primary-very-low, #f8f8f8);
border-radius: 8px;
font-size: 13px;
border: 1px solid var(--primary-low, #eee);
}
.timeline-selected-user-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.timeline-selected-user-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary, #222);
}
.timeline-selected-user-username {
font-size: 11px;
color: var(--primary-medium, #666);
}
.timeline-user-remove {
cursor: pointer;
color: var(--primary-medium, #999);
font-size: 14px;
padding: 4px;
transition: color 0.2s;
}
.timeline-user-remove:hover {
color: var(--danger, #e45735);
}
.timeline-user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
background: #eee;
}
`);
// ========== 悬浮按钮相关变量 ==========
let btn = null;
// ========== 创建悬浮按钮 ==========
function createFloatButton() {
if (document.querySelector(".timeline-float-btn")) return;
btn = document.createElement("button");
btn.className = "timeline-float-btn";
// 艺术体 L 字母 SVG,使用三层颜色渐变
btn.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="l-gradient-btn" x1="0" y1="0" x2="0" y2="1">
<stop offset="33.33%" stop-color="#000" />
<stop offset="33.33%" stop-color="#fff" />
<stop offset="66.66%" stop-color="#fff" />
<stop offset="66.66%" stop-color="#fcca04" />
</linearGradient>
</defs>
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-family="'Georgia', serif" font-weight="bold" font-style="italic" font-size="20" fill="url(#l-gradient-btn)">L</text>
</svg>`;
btn.title = "时间线视图 (ESC)";
document.body.appendChild(btn);
// 应用保存的位置
applyBtnPosFromStore();
// 绑定拖拽事件
bindBtnDrag();
}
// 从存储中读取并应用按钮位置
function applyBtnPosFromStore() {
const pos = GM_getValue("timeline_btn_pos", null);
if (pos && typeof pos.x === "number" && typeof pos.y === "number") {
const clamped = clampBtnPos(pos.x, pos.y);
btn.style.left = `${clamped.x}px`;
btn.style.top = `${clamped.y}px`;
} else {
// 默认位置:右上角
const w = btn.offsetWidth || 30;
const margin = 18;
const x = Math.max(margin, window.innerWidth - w - margin);
const y = 100;
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
saveBtnPos(x, y);
}
}
// 保存按钮位置
function saveBtnPos(x, y) {
GM_setValue("timeline_btn_pos", { x, y });
}
// 限制按钮位置在屏幕内
function clampBtnPos(x, y) {
const w = btn.offsetWidth || 30;
const h = btn.offsetHeight || 30;
const margin = 8;
const maxX = Math.max(margin, window.innerWidth - w - margin);
const maxY = Math.max(margin, window.innerHeight - h - margin);
return {
x: Math.max(margin, Math.min(maxX, x)),
y: Math.max(margin, Math.min(maxY, y)),
};
}
// 绑定按钮拖拽事件
function bindBtnDrag() {
let dragging = false;
let moved = false;
let startX = 0,
startY = 0;
let origLeft = 0,
origTop = 0;
let pointerId = null;
let dockTimer = null;
let lastPointerUpTime = 0; // 记录上次 pointerup 触发 toggleDrawer 的时间
const DOCK_DELAY = 500; // 500毫秒后自动吸附
const DOCK_OFFSET = 20; // 大屏幕下吸附时露出的像素
// 小屏幕下不遮挡,返回按钮尺寸;大屏幕下半隐藏
const isSmallScreen = () => window.innerWidth <= 768;
const getLeftTop = () => {
const r = btn.getBoundingClientRect();
return { left: r.left, top: r.top };
};
// 清除吸附状态
const clearDockState = () => {
btn.classList.remove(
"docked",
"docked-left",
"docked-right",
"docked-top",
"docked-bottom",
);
};
// 计算并应用吸附
const applyDock = () => {
const lt = getLeftTop();
const w = btn.offsetWidth || 30;
const h = btn.offsetHeight || 30;
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
// 计算到各边的距离
const distLeft = lt.left;
const distRight = vw - lt.left - w;
const distTop = lt.top;
const distBottom = vh - lt.top - h;
// 找最近的边
const minDist = Math.min(distLeft, distRight, distTop, distBottom);
clearDockState();
btn.classList.add("docked");
// 小屏幕下不遮挡,只贴边;大屏幕下半隐藏
if (isSmallScreen()) {
// 小屏幕:贴边但完全露出
if (minDist === distLeft) {
btn.style.left = `${margin}px`;
btn.classList.add("docked-left");
} else if (minDist === distRight) {
btn.style.left = `${vw - w - margin}px`;
btn.classList.add("docked-right");
} else if (minDist === distTop) {
btn.style.top = `${margin}px`;
btn.classList.add("docked-top");
} else {
btn.style.top = `${vh - h - margin}px`;
btn.classList.add("docked-bottom");
}
} else {
// 大屏幕:半隐藏
if (minDist === distLeft) {
btn.style.left = `${-w + DOCK_OFFSET}px`;
btn.classList.add("docked-left");
} else if (minDist === distRight) {
btn.style.left = `${vw - DOCK_OFFSET}px`;
btn.classList.add("docked-right");
} else if (minDist === distTop) {
btn.style.top = `${-h + DOCK_OFFSET}px`;
btn.classList.add("docked-top");
} else {
btn.style.top = `${vh - DOCK_OFFSET}px`;
btn.classList.add("docked-bottom");
}
}
// 保存吸附后的位置
saveBtnPos(parseFloat(btn.style.left), parseFloat(btn.style.top));
};
// 取消吸附,恢复到正常位置
const undock = () => {
if (!btn.classList.contains("docked")) return;
const w = btn.offsetWidth || 30;
const h = btn.offsetHeight || 30;
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
let x = parseFloat(btn.style.left) || 0;
let y = parseFloat(btn.style.top) || 0;
// 根据吸附方向恢复位置
if (btn.classList.contains("docked-left")) {
x = margin;
} else if (btn.classList.contains("docked-right")) {
x = vw - w - margin;
} else if (btn.classList.contains("docked-top")) {
y = margin;
} else if (btn.classList.contains("docked-bottom")) {
y = vh - h - margin;
}
clearDockState();
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
saveBtnPos(x, y);
// 重新启动吸附定时器
startDockTimer();
};
// 启动吸附定时器
const startDockTimer = () => {
clearTimeout(dockTimer);
dockTimer = setTimeout(() => {
if (!dragging && !btn.matches(":hover") && !isDrawerOpen) {
applyDock();
}
}, DOCK_DELAY);
};
// 鼠标进入时取消吸附
btn.addEventListener("mouseenter", () => {
clearTimeout(dockTimer);
undock();
});
// 鼠标离开时启动吸附定时器
btn.addEventListener("mouseleave", () => {
if (!dragging && !isDrawerOpen) {
startDockTimer();
}
});
const onPointerDown = (e) => {
if (e.button !== undefined && e.button !== 0) return;
if (dragging) return; // 防止重复触发
clearTimeout(dockTimer);
undock(); // 取消吸附状态
dragging = true;
moved = false;
pointerId = e.pointerId;
btn.classList.add("dragging");
const lt = getLeftTop();
origLeft = lt.left;
origTop = lt.top;
startX = e.clientX;
startY = e.clientY;
try {
btn.setPointerCapture(e.pointerId);
} catch {}
e.preventDefault();
e.stopPropagation();
};
const onPointerMove = (e) => {
if (!dragging || e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// 检测是否真的移动了(增加阈值)
if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
moved = true;
}
if (moved) {
const nx = origLeft + dx;
const ny = origTop + dy;
const clamped = clampBtnPos(nx, ny);
btn.style.left = `${clamped.x}px`;
btn.style.top = `${clamped.y}px`;
}
e.preventDefault();
e.stopPropagation();
};
const onPointerUp = (e) => {
if (!dragging || e.pointerId !== pointerId) return;
dragging = false;
btn.classList.remove("dragging");
if (moved) {
// 只有在拖动后才保存位置
const lt = getLeftTop();
const clamped = clampBtnPos(lt.left, lt.top);
btn.style.left = `${clamped.x}px`;
btn.style.top = `${clamped.y}px`;
saveBtnPos(clamped.x, clamped.y);
// 拖动结束后启动吸附定时器
if (!isDrawerOpen) {
startDockTimer();
}
} else {
// 只有在没有移动时才触发点击
lastPointerUpTime = Date.now();
toggleDrawer();
}
try {
btn.releasePointerCapture(e.pointerId);
} catch {}
pointerId = null;
e.preventDefault();
e.stopPropagation();
};
// 移动端可能触发 pointercancel 而不是 pointerup
const onPointerCancel = (e) => {
if (!dragging || e.pointerId !== pointerId) return;
dragging = false;
btn.classList.remove("dragging");
// 如果没有移动,视为点击
if (!moved) {
lastPointerUpTime = Date.now();
toggleDrawer();
}
try {
btn.releasePointerCapture(e.pointerId);
} catch {}
pointerId = null;
};
const onResize = () => {
if (dragging) return; // 拖动时不触发resize调整
// 如果处于吸附状态,重新计算吸附位置
if (btn.classList.contains("docked")) {
const w = btn.offsetWidth || 30;
const h = btn.offsetHeight || 30;
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
if (isSmallScreen()) {
// 小屏幕:贴边但完全露出
if (btn.classList.contains("docked-right")) {
btn.style.left = `${vw - w - margin}px`;
} else if (btn.classList.contains("docked-bottom")) {
btn.style.top = `${vh - h - margin}px`;
}
} else {
// 大屏幕:半隐藏
if (btn.classList.contains("docked-right")) {
btn.style.left = `${vw - DOCK_OFFSET}px`;
} else if (btn.classList.contains("docked-bottom")) {
btn.style.top = `${vh - DOCK_OFFSET}px`;
}
}
} else {
const lt = getLeftTop();
const clamped = clampBtnPos(lt.left, lt.top);
btn.style.left = `${clamped.x}px`;
btn.style.top = `${clamped.y}px`;
saveBtnPos(clamped.x, clamped.y);
}
};
btn.addEventListener("pointerdown", onPointerDown, { passive: false });
window.addEventListener("pointermove", onPointerMove, { passive: false });
window.addEventListener("pointerup", onPointerUp, { passive: false });
window.addEventListener("pointercancel", onPointerCancel, {
passive: false,
});
window.addEventListener("resize", onResize);
// 初始化时启动吸附定时器
startDockTimer();
}
// ========== 创建抽屉 ==========
function createDrawer() {
if (document.querySelector(".timeline-drawer")) return;
// 抽屉
const drawer = document.createElement("div");
drawer.className = "timeline-drawer";
// 初始化宽度
const savedWidth = GM_getValue("timeline_width", 400);
document.documentElement.style.setProperty(
"--timeline-width",
`${savedWidth}px`,
);
drawer.innerHTML = `
<div class="timeline-drawer-resizer"></div>
<div class="timeline-drawer-header">
<div class="timeline-drawer-title">
<span class="timeline-header-icon-glow" style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: #099dd7; border-radius: 50%; cursor: pointer;" title="回到顶部">
<svg viewBox="0 0 24 24" width="22" height="22" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="l-gradient-header" x1="0" y1="0" x2="0" y2="1">
<stop offset="33.33%" stop-color="#000" />
<stop offset="33.33%" stop-color="#fff" />
<stop offset="66.66%" stop-color="#fff" />
<stop offset="66.66%" stop-color="#fcca04" />
</linearGradient>
</defs>
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-family="'Georgia', serif" font-weight="bold" font-style="italic" font-size="18" fill="url(#l-gradient-header)">L</text>
</svg>
<div class="timeline-update-badge">new</div>
</span>
</div>
<div class="timeline-drawer-actions">
<div class="timeline-refresh-settings" title="点击设置自动刷新间隔 (秒)">
<span>⏱️</span>
<span class="timeline-countdown"></span>
</div>
<button class="timeline-drawer-refresh" title="刷新">🔄</button>
<button class="timeline-settings-btn" title="设置">⚙️</button>
<button class="timeline-drawer-close" title="关闭">×</button>
</div>
</div>
<div class="timeline-tabs-wrapper">
<div class="timeline-tabs-container">
<div class="timeline-tab ${
currentTab === "all" ? "active" : ""
}" data-tab="all">全部</div>
${(function () {
const savedOrder = GM_getValue("timeline_tabs_order", []);
const tabConfigs = Object.entries(CATEGORY_CONFIG)
.filter(([_, cfg]) => cfg.tabId)
.map(([id, cfg]) => ({ id, ...cfg }));
// 排序逻辑
if (savedOrder.length > 0) {
tabConfigs.sort((a, b) => {
const idxA = savedOrder.indexOf(a.id);
const idxB = savedOrder.indexOf(b.id);
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
}
return tabConfigs
.map(
(cfg) =>
`<div class="timeline-tab" data-tab="${cfg.tabId}" data-id="${cfg.id}">${cfg.name}</div>`,
)
.join("");
})()}
</div>
<div class="timeline-tabs-more-btn" title="更多分类/排序">
<span style="transform: rotate(90deg); display: inline-block;">❯</span>
</div>
<div class="timeline-tabs-modal">
<div class="timeline-tabs-modal-header">
<span>按住并拖动以排序</span>
<span class="timeline-tabs-close-modal" style="cursor:pointer">✕</span>
</div>
<div class="timeline-tabs-grid">
<div class="timeline-grid-item-fixed ${
currentTab === "all" ? "active" : ""
}" data-tab="all" style="padding: 6px 4px; background: var(--primary-low, #eee); border-radius: 4px; font-size: 12px; text-align: center; color: var(--primary-medium);">全部</div>
${(function () {
const savedOrder = GM_getValue(
"timeline_tabs_order",
[],
);
const tabConfigs = Object.entries(CATEGORY_CONFIG)
.filter(([_, cfg]) => cfg.tabId)
.map(([id, cfg]) => ({ id, ...cfg }));
if (savedOrder.length > 0) {
tabConfigs.sort((a, b) => {
const idxA = savedOrder.indexOf(a.id);
const idxB = savedOrder.indexOf(b.id);
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
}
return tabConfigs
.map(
(cfg) =>
`<div class="timeline-grid-item" draggable="true" data-id="${cfg.id}" title="${cfg.name}">${cfg.name}</div>`,
)
.join("");
})()}
</div>
</div>
</div>
<div class="timeline-filter-bar">
<span class="timeline-filter-item ${
currentFilter === "all" ? "active" : ""
}" data-filter="all">全部</span>
<span class="timeline-filter-item ${
currentFilter === "unseen" ? "active" : ""
}" data-filter="unseen">未读</span>
<span class="timeline-filter-item ${
currentFilter === "read" ? "active" : ""
}" data-filter="read">已读</span>
</div>
<div class="timeline-drawer-content">
<div class="timeline-loading-2">
<div class="timeline-spinner"></div>
<span>加载中...</span>
</div>
</div>
`;
const refreshSettings = drawer.querySelector(".timeline-refresh-settings");
refreshSettings.addEventListener("click", () => {
showSettingsModal();
});
const settingsBtn = drawer.querySelector(".timeline-settings-btn");
settingsBtn.addEventListener("click", () => {
showSettingsModal();
});
updateCountdownDisplay();
startAutoRefresh();
// 绑定标题图标点击回顶事件
const titleIcon = drawer.querySelector(".timeline-header-icon-glow");
if (titleIcon) {
titleIcon.addEventListener("click", () => {
const content = drawer.querySelector(".timeline-drawer-content");
if (content) {
content.scrollTo({ top: 0, behavior: "smooth" });
}
});
}
// 拖拽缩放逻辑
const resizer = drawer.querySelector(".timeline-drawer-resizer");
let isResizing = false;
resizer.addEventListener("mousedown", (e) => {
isResizing = true;
resizer.classList.add("resizing");
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
// 拖拽时暂时关闭 transition
drawer.style.transition = "none";
document.body.style.transition = "none";
const header = document.querySelector(".d-header");
if (header) header.style.transition = "none";
});
document.addEventListener("mousemove", (e) => {
if (!isResizing) return;
const width = window.innerWidth - e.clientX;
const finalWidth = Math.min(
Math.max(width, 300),
window.innerWidth * 0.8,
);
document.documentElement.style.setProperty(
"--timeline-width",
`${finalWidth}px`,
);
});
document.addEventListener("mouseup", () => {
if (!isResizing) return;
isResizing = false;
resizer.classList.remove("resizing");
document.body.style.cursor = "";
document.body.style.userSelect = "";
drawer.style.transition = "right 0.3s ease";
document.body.style.transition = "padding-right 0.3s ease";
const header = document.querySelector(".d-header");
if (header) header.style.transition = "right 0.3s ease";
const currentWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
"--timeline-width",
),
);
GM_setValue("timeline_width", currentWidth);
});
drawer
.querySelector(".timeline-drawer-close")
.addEventListener("click", closeDrawer);
drawer
.querySelector(".timeline-drawer-refresh")
.addEventListener("click", () => {
loadTimelineTopics();
});
// Tab 切换逻辑
function bindTabEvents() {
drawer
.querySelectorAll(
".timeline-tab, .timeline-grid-item, .timeline-grid-item-fixed",
)
.forEach((tab) => {
tab.addEventListener("click", (e) => {
const tabIdValue = tab.dataset.tab;
const idValue = tab.dataset.id;
// 处理 Modal 里的点击
if (
tab.classList.contains("timeline-grid-item") ||
tab.classList.contains("timeline-grid-item-fixed")
) {
modal.classList.remove("open");
}
if (tab.classList.contains("active")) return;
drawer
.querySelectorAll(".timeline-tab.active")
.forEach((el) => el.classList.remove("active"));
drawer
.querySelectorAll(".timeline-grid-item.active")
.forEach((el) => el.classList.remove("active"));
// 同步高亮
let targetTab, targetGrid;
if (idValue) {
targetTab = drawer.querySelector(
`.timeline-tab[data-id="${idValue}"]`,
);
targetGrid = drawer.querySelector(
`.timeline-grid-item[data-id="${idValue}"]`,
);
} else {
targetTab = drawer.querySelector(
`.timeline-tab[data-tab="${tabIdValue}"]`,
);
targetGrid = drawer.querySelector(
`.timeline-grid-item-fixed[data-tab="${tabIdValue}"]`,
);
}
if (targetTab) {
targetTab.classList.add("active");
// 关键:点击 Modal 里的 Tab 时,让顶部的 Tab 栏同步滚动到可视区域
if (
tab.classList.contains("timeline-grid-item") ||
tab.classList.contains("timeline-grid-item-fixed")
) {
targetTab.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}
if (targetGrid) targetGrid.classList.add("active");
currentTab =
tabIdValue || (idValue ? CATEGORY_CONFIG[idValue].tabId : "all");
if (!currentTab) currentTab = "all"; // 兜底处理
currentCategoryId = idValue || null;
loadTimelineTopics(0, false, true); // 切换 Tab 时也触发自动补全(如果当前有过滤条件)
startAutoRefresh(); // 切换 Tab 后重新计算定时器状态
});
});
// 绑定过滤事件
drawer.querySelectorAll(".timeline-filter-item").forEach((item) => {
item.addEventListener("click", () => {
if (item.classList.contains("active")) return;
drawer
.querySelectorAll(".timeline-filter-item")
.forEach((el) => el.classList.remove("active"));
item.classList.add("active");
currentFilter = item.dataset.filter;
loadTimelineTopics(0, false, true); // 切换过滤条件时重新请求并触发补全
});
});
}
bindTabEvents();
// 更多 Tab 弹窗逻辑
const modal = drawer.querySelector(".timeline-tabs-modal");
const moreBtn = drawer.querySelector(".timeline-tabs-more-btn");
const closeBtn = drawer.querySelector(".timeline-tabs-close-modal");
moreBtn.addEventListener("click", () => modal.classList.toggle("open"));
closeBtn.addEventListener("click", () => modal.classList.remove("open"));
// 拖拽排序逻辑
const grid = drawer.querySelector(".timeline-tabs-grid");
let dragItem = null;
grid.addEventListener("dragstart", (e) => {
if (!e.target.classList.contains("timeline-grid-item")) return;
dragItem = e.target;
e.target.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
grid.addEventListener("dragend", (e) => {
if (!dragItem) return;
dragItem.classList.remove("dragging");
grid
.querySelectorAll(".drop-target")
.forEach((el) => el.classList.remove("drop-target"));
dragItem = null;
});
grid.addEventListener("dragover", (e) => {
e.preventDefault();
const target = e.target.closest(".timeline-grid-item");
if (target && target !== dragItem) {
grid
.querySelectorAll(".drop-target")
.forEach((el) => el.classList.remove("drop-target"));
target.classList.add("drop-target");
}
});
grid.addEventListener("dragleave", (e) => {
const target = e.target.closest(".timeline-grid-item");
if (target) target.classList.remove("drop-target");
});
grid.addEventListener("drop", (e) => {
e.preventDefault();
const target = e.target.closest(".timeline-grid-item");
if (!target || !dragItem || target === dragItem) return;
const orderArr = Array.from(
grid.querySelectorAll(".timeline-grid-item"),
).map((item) => item.dataset.id);
const idxSource = orderArr.indexOf(dragItem.dataset.id);
const idxTarget = orderArr.indexOf(target.dataset.id);
// 互换位置
[orderArr[idxSource], orderArr[idxTarget]] = [
orderArr[idxTarget],
orderArr[idxSource],
];
GM_setValue("timeline_tabs_order", orderArr);
// 重新刷新显示
refreshTabLayout();
modal.classList.add("open"); // 保持 Modal 开启状态以查看结果
});
function refreshTabLayout() {
const barContainer = drawer.querySelector(".timeline-tabs-container");
const gridContainer = drawer.querySelector(".timeline-tabs-grid");
const activeId = currentCategoryId;
const activeTab = currentTab;
const order = GM_getValue("timeline_tabs_order", []);
const configs = Object.entries(CATEGORY_CONFIG)
.filter(([_, cfg]) => cfg.tabId)
.map(([id, cfg]) => ({ id, ...cfg }));
if (order.length > 0) {
configs.sort((a, b) => {
const idxA = order.indexOf(a.id);
const idxB = order.indexOf(b.id);
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
}
// 刷新顶部 Tab 栏
let barHtml = `<div class="timeline-tab ${
activeTab === "all" ? "active" : ""
}" data-tab="all">全部</div>`;
barHtml += configs
.map(
(cfg) =>
`<div class="timeline-tab ${
activeId == cfg.id ? "active" : ""
}" data-tab="${cfg.tabId}" data-id="${cfg.id}">${cfg.name}</div>`,
)
.join("");
barContainer.innerHTML = barHtml;
// 同时更新弹窗 Grid 里的列表以匹配新顺序
let gridHtml = `<div class="timeline-grid-item-fixed ${
activeTab === "all" ? "active" : ""
}" data-tab="all" style="padding: 6px 4px; background: var(--primary-low, #eee); border-radius: 4px; font-size: 12px; text-align: center; color: var(--primary-medium);">全部</div>`;
gridHtml += configs
.map(
(cfg) =>
`<div class="timeline-grid-item ${
activeId == cfg.id ? "active" : ""
}" draggable="true" data-id="${cfg.id}" title="${cfg.name}">${
cfg.name
}</div>`,
)
.join("");
gridContainer.innerHTML = gridHtml;
bindTabEvents(); // 重新绑定事件
}
// Tab 栏拖拽滚动逻辑
const tabsContainer = drawer.querySelector(".timeline-tabs-container");
let isMoving = false;
let startX;
let scrollLeft;
let hasMoved = false;
tabsContainer.addEventListener("mousedown", (e) => {
isMoving = true;
hasMoved = false;
startX = e.pageX - tabsContainer.offsetLeft;
scrollLeft = tabsContainer.scrollLeft;
// 延迟添加样式,避免误触
tabsContainer.classList.add("grabbing");
});
document.addEventListener("mousemove", (e) => {
if (!isMoving) return;
const x = e.pageX - tabsContainer.offsetLeft;
const walk = (x - startX) * 1.5;
if (Math.abs(walk) > 5) {
hasMoved = true;
}
tabsContainer.scrollLeft = scrollLeft - walk;
});
document.addEventListener("mouseup", () => {
if (!isMoving) return;
isMoving = false;
tabsContainer.classList.remove("grabbing");
});
// 防止拖拽时触发 Tab 点击
tabsContainer.addEventListener(
"click",
(e) => {
if (hasMoved) {
e.preventDefault();
e.stopImmediatePropagation();
}
},
{ capture: true },
);
// 鼠标滚轮左右滚动
tabsContainer.addEventListener(
"wheel",
(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
tabsContainer.scrollLeft += e.deltaY;
}
},
{ passive: false },
);
refreshTabLayout(); // 初始化布局
// 滚动加载更多
const content = drawer.querySelector(".timeline-drawer-content");
content.addEventListener("scroll", () => {
if (!isDrawerOpen || isLoadingMore || !hasMorePages) return;
const scrollTop = content.scrollTop;
const scrollHeight = content.scrollHeight;
const clientHeight = content.clientHeight;
if (scrollTop + clientHeight >= scrollHeight - 100) {
autoLoadCount = 0; // 手动触发滚动加载时,重置自动计数
loadMoreTopics();
}
});
document.body.appendChild(drawer);
// 遮罩层 (小屏使用)
let overlay = document.querySelector(".timeline-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.className = "timeline-overlay";
// overlay.addEventListener("click", (e) => {
// // 只有点击 overlay 本身才关闭,防止事件冒泡导致误触发
// if (e.target === overlay) {
// closeDrawer();
// }
// });
document.body.appendChild(overlay);
}
createSettingsModal(drawer);
}
// ========== 创建设置弹窗 ==========
function createSettingsModal(parent = document.body) {
if (document.querySelector(".timeline-settings-modal")) return;
const modal = document.createElement("div");
modal.className = "timeline-settings-modal";
const enableNotif = GM_getValue("timeline_enable_notification", false);
const enableSlogan = GM_getValue("timeline_enable_slogan", false);
const interval = GM_getValue("timeline_refresh_interval", 0);
const notifMode = GM_getValue("timeline_notif_mode", "all"); // "all" 或 "specified"
const notifUsersRaw = GM_getValue("timeline_notif_users", "");
let selectedUsers = [];
const migrateUsers = (raw) => {
if (!raw) return [];
if (typeof raw === "string" && raw.trim().startsWith("[")) {
try {
return JSON.parse(raw);
} catch (e) {
return [];
}
} else if (typeof raw === "string") {
return raw
.split(",")
.map((u) => ({
username: u.trim(),
name: u.trim(),
avatar_template: "",
}))
.filter((u) => u.username);
}
return Array.isArray(raw) ? raw : [];
};
selectedUsers = migrateUsers(notifUsersRaw);
modal.innerHTML = `
<div class="timeline-settings-header">
<span>偏好设置</span>
<span class="timeline-settings-close">✕</span>
</div>
<div class="timeline-settings-group">
<div class="timeline-settings-row">
<span class="timeline-settings-label" style="margin-bottom: 0;">自动刷新通知</span>
<label class="timeline-switch">
<input type="checkbox" id="timeline-notif-toggle" ${
enableNotif ? "checked" : ""
}>
<span class="timeline-slider"></span>
</label>
</div>
<p style="font-size: 11px; color: var(--primary-low-mid, #aaa); margin-top: 4px;">开启后,后台自动刷出的新帖将通过系统通知提醒</p>
</div>
<div class="timeline-settings-group timeline-notif-mode-group" style="display: ${
enableNotif ? "block" : "none"
};">
<label class="timeline-settings-label">通知范围</label>
<div class="timeline-settings-row" style="gap: 16px;">
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="radio" name="timeline-notif-mode" value="all" ${
notifMode === "all" ? "checked" : ""
}>
<span style="font-size: 13px;">全部用户</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="radio" name="timeline-notif-mode" value="specified" ${
notifMode === "specified" ? "checked" : ""
}>
<span style="font-size: 13px;">指定用户</span>
</label>
</div>
</div>
<div class="timeline-settings-group timeline-notif-users-group" style="display: ${
enableNotif && notifMode === "specified" ? "block" : "none"
};">
<label class="timeline-settings-label">指定用户通知</label>
<div class="timeline-user-search-container">
<input type="text" class="timeline-user-search-input" placeholder="搜索用户名添加...">
<div class="timeline-following-trigger" title="关注的人">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>关注</span>
</div>
<div class="timeline-user-search-results"></div>
<!-- 关注的人子弹窗 -->
<div class="timeline-sub-modal" id="timeline-following-modal">
<div class="timeline-sub-modal-header">
<span class="timeline-sub-modal-title">关注的人</span>
<div style="display: flex; gap: 8px; align-items: center;">
<span class="timeline-import-btn" id="timeline-import-all-following" style="font-size: 11px; padding: 2px 6px;">一键导入</span>
<span class="timeline-sub-modal-close">✕</span>
</div>
</div>
<div class="timeline-sub-modal-content">
<div class="timeline-following-list" id="timeline-following-list">
<div style="font-size: 11px; color: var(--primary-low-mid); padding: 12px; text-align: center; width: 100%;">正在获取关注列表...</div>
</div>
</div>
</div>
</div>
<div class="timeline-user-list" id="timeline-notif-user-list"></div>
<p style="font-size: 11px; color: var(--primary-low-mid, #aaa); margin-top: 8px;">只有这些用户发帖时才会收到通知</p>
</div>
<div class="timeline-settings-group">
<div class="timeline-settings-row">
<span class="timeline-settings-label" style="margin-bottom: 0;">核心价值观特效</span>
<label class="timeline-switch">
<input type="checkbox" id="timeline-slogan-toggle" ${
enableSlogan ? "checked" : ""
}>
<span class="timeline-slider"></span>
</label>
</div>
<p style="font-size: 11px; color: var(--primary-low-mid, #aaa); margin-top: 4px;">开启后,点击鼠标将显示"真诚、友善、团结、专业"特效</p>
</div>
<div class="timeline-settings-group">
<label class="timeline-settings-label">自动刷新间隔 (秒)</label>
<div class="timeline-settings-row">
<input type="number" id="timeline-interval-input" class="timeline-input" min="0" value="${interval}">
<span style="font-size: 13px; color: var(--primary-medium, #888);">0 为关闭</span>
</div>
</div>
<div style="margin-top: 24px; display: flex; justify-content: flex-end;">
<button class="timeline-retry-btn" id="timeline-settings-save" style="margin-top: 0; padding: 6px 16px;">确定</button>
</div>
`;
const getAvatarUrl = (template, size = 60) => {
if (!template)
return "https://linux.do/letter_avatar_proxy/v4/letter/n/48db5f/64.png";
if (template.startsWith("http")) return template;
return "https://linux.do" + template.replace("{size}", size);
};
// 预定义引用,供后续逻辑和回调使用
const followingModal = modal.querySelector("#timeline-following-modal");
const followingTrigger = modal.querySelector(".timeline-following-trigger");
const followingList = modal.querySelector("#timeline-following-list");
let renderFollowing = () => {};
const renderUsers = () => {
const list = modal.querySelector("#timeline-notif-user-list");
if (!list) return;
list.innerHTML = selectedUsers
.map(
(user) => `
<div class="timeline-selected-user">
<img src="${getAvatarUrl(
user.avatar_template,
)}" class="timeline-user-avatar ${
user.username === "neo" ? "square-avatar" : ""
}">
<div class="timeline-selected-user-info">
<div class="timeline-selected-user-name">${escapeHtml(
user.name || user.username,
)}</div>
<div class="timeline-selected-user-username">@${escapeHtml(
user.username,
)}</div>
</div>
<div class="timeline-user-remove" data-username="${escapeHtml(
user.username,
)}">✕</div>
</div>
`,
)
.join("");
list.querySelectorAll(".timeline-user-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const username = btn.dataset.username;
selectedUsers = selectedUsers.filter((u) => u.username !== username);
renderUsers();
if (followingModal && followingModal.classList.contains("open")) {
renderFollowing();
}
});
});
};
// 将更新函数挂载到 DOM 元素上,方便 showSettingsModal 调用
modal.refreshUsers = (raw) => {
selectedUsers = migrateUsers(raw);
renderUsers();
if (followingModal && followingModal.classList.contains("open")) {
renderFollowing();
}
};
renderUsers();
const searchInput = modal.querySelector(".timeline-user-search-input");
const resultsContainer = modal.querySelector(
".timeline-user-search-results",
);
let searchTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimer);
const term = searchInput.value.trim();
if (!term) {
resultsContainer.classList.remove("active");
return;
}
searchTimer = setTimeout(async () => {
try {
const response = await fetch(
`/search/query.json?term=${encodeURIComponent(
term,
)}&type_filter=exclude_topics`,
{
headers: {
// "X-CSRF-Token": getCsrfToken(),
"X-Requested-With": "XMLHttpRequest",
},
},
);
const data = await response.json();
const users = data.users || [];
if (users.length > 0) {
resultsContainer.innerHTML = users
.map(
(user) => `
<div class="timeline-user-search-item" data-user='${JSON.stringify(
{
username: user.username,
name: user.name,
avatar_template: user.avatar_template,
},
).replace(/'/g, "'")}'>
<img src="${getAvatarUrl(
user.avatar_template,
)}" class="timeline-user-avatar ${
user.username === "neo" ? "square-avatar" : ""
}">
<div style="display:flex; flex-direction:column; min-width:0;">
<div style="font-weight:600; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(
user.name || user.username,
)}</div>
<div style="font-size:11px; color:var(--primary-medium);">@${escapeHtml(
user.username,
)}</div>
</div>
</div>
`,
)
.join("");
resultsContainer.classList.add("active");
resultsContainer
.querySelectorAll(".timeline-user-search-item")
.forEach((item) => {
item.addEventListener("click", () => {
const user = JSON.parse(item.dataset.user);
if (
!selectedUsers.find((u) => u.username === user.username)
) {
selectedUsers.push(user);
renderUsers();
}
searchInput.value = "";
resultsContainer.classList.remove("active");
});
});
} else {
resultsContainer.innerHTML =
'<div style="padding: 10px; text-align: center; font-size: 13px; color: var(--primary-medium);">未找到用户</div>';
resultsContainer.classList.add("active");
}
} catch (e) {
console.error("Search failed", e);
}
}, 300);
});
document.addEventListener("click", (e) => {
if (
!searchInput.contains(e.target) &&
!resultsContainer.contains(e.target)
) {
resultsContainer.classList.remove("active");
}
});
// 关注的人逻辑处理
let followingUsers = [];
// 移除重复声明,直接使用上方定义的变量
followingTrigger.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = followingModal.classList.contains("open");
// 关闭结果搜索
resultsContainer.classList.remove("active");
if (!isOpen) {
followingModal.classList.add("open");
fetchFollowing();
} else {
followingModal.classList.remove("open");
}
});
modal
.querySelector(".timeline-sub-modal-close")
.addEventListener("click", (e) => {
e.stopPropagation();
followingModal.classList.remove("open");
});
const fetchFollowing = async () => {
try {
// 获取当前用户名
const username = myUserName;
if (!username) {
followingList.innerHTML =
'<div style="font-size: 11px; color: var(--primary-medium); padding: 12px; text-align: center; width: 100%;">未登录或无法获取用户名</div>';
return;
}
const followRes = await fetch(`/u/${username}/follow/following`, {
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (!followRes.ok) throw new Error("Fetch failed");
const data = await followRes.json();
// 适配数据结构:Discourse 接口通常返回 {following: [...]}
followingUsers = data.following || (Array.isArray(data) ? data : []);
if (followingUsers.length > 0) {
renderFollowing();
} else {
followingList.innerHTML =
'<div style="font-size: 11px; color: var(--primary-medium); padding: 12px; text-align: center; width: 100%;">暂无关注的人</div>';
}
} catch (e) {
console.error("[时间线] 获取关注列表失败", e);
followingList.innerHTML =
'<div style="font-size: 11px; color: var(--danger); padding: 12px; text-align: center; width: 100%;">加载失败,请重试</div>';
}
};
// 真正的 renderFollowing 定义
renderFollowing = () => {
followingList.innerHTML = followingUsers
.map((user) => {
const isSelected = selectedUsers.some(
(u) => u.username === user.username,
);
return `
<div class="timeline-following-item ${
isSelected ? "selected" : ""
}" title="${escapeHtml(user.name || user.username)} (@${escapeHtml(
user.username,
)})" data-user='${JSON.stringify({
username: user.username,
name: user.name,
avatar_template: user.avatar_template,
}).replace(/'/g, "'")}'>
<img src="${getAvatarUrl(user.avatar_template, 48)}" class="${
user.username === "neo" ? "square-avatar" : ""
}">
</div>
`;
})
.join("");
followingList
.querySelectorAll(".timeline-following-item")
.forEach((item) => {
item.addEventListener("click", (e) => {
e.stopPropagation(); // 阻止冒泡,防止全局点击逻辑误关弹窗
const user = JSON.parse(item.dataset.user);
const index = selectedUsers.findIndex(
(u) => u.username === user.username,
);
if (index > -1) {
// 已存在则移除 (取消勾选)
selectedUsers.splice(index, 1);
} else {
// 不存在则添加 (新增勾选)
selectedUsers.push(user);
}
renderUsers();
renderFollowing(); // 点击后实时更新勾选状态
});
});
};
// 绑定一键导入
modal
.querySelector("#timeline-import-all-following")
.addEventListener("click", () => {
let addedCount = 0;
followingUsers.forEach((user) => {
if (!selectedUsers.find((u) => u.username === user.username)) {
selectedUsers.push({
username: user.username,
name: user.name,
avatar_template: user.avatar_template,
});
addedCount++;
}
});
if (addedCount > 0) {
renderUsers();
renderFollowing(); // 刷新勾选状态
}
});
parent.appendChild(modal);
// 通知开关联动显示/隐藏通知模式选项
const notifToggle = modal.querySelector("#timeline-notif-toggle");
const notifModeGroup = modal.querySelector(".timeline-notif-mode-group");
const notifUsersGroup = modal.querySelector(".timeline-notif-users-group");
notifToggle.addEventListener("change", () => {
notifModeGroup.style.display = notifToggle.checked ? "block" : "none";
const selectedMode =
modal.querySelector('input[name="timeline-notif-mode"]:checked')
?.value || "all";
notifUsersGroup.style.display =
notifToggle.checked && selectedMode === "specified" ? "block" : "none";
});
// 通知模式切换联动显示/隐藏用户输入框
modal
.querySelectorAll('input[name="timeline-notif-mode"]')
.forEach((radio) => {
radio.addEventListener("change", () => {
const isSpecified = radio.value === "specified";
notifUsersGroup.style.display = isSpecified ? "block" : "none";
// 移除自动触发 fetchFollowing,改为由用户点击按钮触发
});
});
// 点击外部关闭子弹窗
document.addEventListener("click", (e) => {
if (
followingModal.classList.contains("open") &&
!followingModal.contains(e.target) &&
e.target !== followingTrigger
) {
followingModal.classList.remove("open");
}
});
modal
.querySelector(".timeline-settings-close")
.addEventListener("click", () => {
modal.classList.remove("open");
});
modal
.querySelector("#timeline-settings-save")
.addEventListener("click", () => {
const newEnableNotif = modal.querySelector(
"#timeline-notif-toggle",
).checked;
const newEnableSlogan = modal.querySelector(
"#timeline-slogan-toggle",
).checked;
const newInterval =
parseInt(modal.querySelector("#timeline-interval-input").value) || 0;
const newNotifMode =
modal.querySelector('input[name="timeline-notif-mode"]:checked')
?.value || "all";
GM_setValue("timeline_enable_notification", newEnableNotif);
GM_setValue("timeline_enable_slogan", newEnableSlogan);
GM_setValue(
"timeline_refresh_interval",
newInterval >= 0 ? newInterval : 0,
);
GM_setValue("timeline_notif_mode", newNotifMode);
GM_setValue("timeline_notif_users", JSON.stringify(selectedUsers));
modal.classList.remove("open");
updateCountdownDisplay();
startAutoRefresh();
});
}
// ========== 显示设置弹窗 ==========
function showSettingsModal() {
const modal = document.querySelector(".timeline-settings-modal");
if (!modal) {
createSettingsModal();
}
const m = document.querySelector(".timeline-settings-modal");
// 更新当前值
const enableNotif = GM_getValue("timeline_enable_notification", false);
const notifMode = GM_getValue("timeline_notif_mode", "all");
const notifUsers = GM_getValue("timeline_notif_users", "");
m.querySelector("#timeline-notif-toggle").checked = enableNotif;
m.querySelector("#timeline-slogan-toggle").checked = GM_getValue(
"timeline_enable_slogan",
false,
);
m.querySelector("#timeline-interval-input").value = GM_getValue(
"timeline_refresh_interval",
0,
);
// 更新通知模式相关
const notifModeGroup = m.querySelector(".timeline-notif-mode-group");
const notifUsersGroup = m.querySelector(".timeline-notif-users-group");
notifModeGroup.style.display = enableNotif ? "block" : "none";
const modeRadios = m.querySelectorAll('input[name="timeline-notif-mode"]');
modeRadios.forEach((radio) => {
radio.checked = radio.value === notifMode;
});
// 更新用户列表
if (m.refreshUsers) {
m.refreshUsers(notifUsers);
}
notifUsersGroup.style.display =
enableNotif && notifMode === "specified" ? "block" : "none";
m.classList.add("open");
}
// ========== 打开/关闭抽屉 ==========
let lastToggleTime = 0;
function toggleDrawer() {
// 防抖:300ms 内不重复触发
const now = Date.now();
if (now - lastToggleTime < 300) {
return;
}
lastToggleTime = now;
if (isDrawerOpen) {
closeDrawer();
} else {
openDrawer();
}
}
// ========== 版本比较 ==========
function compareVersions(v1, v2) {
const a = v1.split(".");
const b = v2.split(".");
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const n1 = parseInt(a[i] || 0);
const n2 = parseInt(b[i] || 0);
if (n1 > n2) return 1;
if (n1 < n2) return -1;
}
return 0;
}
// ========== 检查更新 ==========
function checkUpdate() {
fetch(
"https://update.greasyfork.org/scripts/564655/LINUX%20DO%20Timeline.meta.js",
)
.then((r) => r.text())
.then((text) => {
const match = text.match(/@version\s+([\d.]+)/);
if (match) {
const latestVersion = match[1];
if (compareVersions(latestVersion, scriptVersion) > 0) {
showUpdateBadge(latestVersion);
}
}
})
.catch((e) => console.log("[时间线] 检查更新失败", e));
}
// ========== 显示更新标记 ==========
function showUpdateBadge(latestVersion) {
const badge = document.querySelector(".timeline-update-badge");
if (badge) {
badge.style.display = "block";
badge.title = `发现新版本 v${latestVersion},点击前往更新`;
badge.onclick = (e) => {
e.stopPropagation();
window.open(
"https://greasyfork.org/zh-CN/scripts/564655-linux-do-timeline",
);
};
}
}
// ========== 更新倒计时显示 ==========
function updateCountdownDisplay() {
const el = document.querySelector(".timeline-countdown");
if (!el) return;
const interval = GM_getValue("timeline_refresh_interval", 0);
if (interval <= 0) {
el.textContent = "手动";
el.classList.remove("active");
} else {
el.textContent = `${remainingSeconds}s`;
el.classList.add("active");
}
}
// ========== 开启自动刷新 ==========
function startAutoRefresh() {
stopAutoRefresh();
const interval = GM_getValue("timeline_refresh_interval", 0);
if (interval > 0) {
remainingSeconds = interval;
updateCountdownDisplay();
countdownTimer = setInterval(() => {
remainingSeconds--;
if (remainingSeconds <= 0) {
remainingSeconds = interval;
if (isDrawerOpen && !isLoading && !isLoadingMore) {
console.log("[时间线] 自动刷新中...");
loadTimelineTopics(0, true);
}
}
updateCountdownDisplay();
}, 1000);
} else {
updateCountdownDisplay();
}
}
// ========== 停止自动刷新 ==========
function stopAutoRefresh() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
// ========== 打开抽屉 ==========
async function openDrawer() {
createDrawer();
isDrawerOpen = true;
GM_setValue("timeline_drawer_open", true);
// 通知其他标签页关闭抽屉
broadcastDrawerOpened();
document.querySelector(".timeline-drawer").classList.add("open");
// 如果是小屏,显示遮罩层,且不应用 push 类
if (window.innerWidth <= 768) {
document.querySelector(".timeline-overlay")?.classList.add("active");
} else {
document.body.classList.add("timeline-drawer-push");
}
// 加载数据
if (allTopics.length > 0) {
renderTopics();
const savedScroll = GM_getValue("timeline_last_scroll_top", 0);
const content = document.querySelector(".timeline-drawer-content");
if (content && savedScroll > 0) {
// 使用 setTimeout 确保渲染完成后滚动
setTimeout(() => {
content.scrollTop = savedScroll;
}, 50);
}
loadTimelineTopics(0, true); // 静默刷新
} else {
loadTimelineTopics();
}
updateCountdownDisplay();
startAutoRefresh();
checkUpdate();
}
// ========== 关闭抽屉 ==========
function closeDrawer() {
const content = document.querySelector(".timeline-drawer-content");
if (content) {
GM_setValue("timeline_last_scroll_top", content.scrollTop);
}
isDrawerOpen = false;
GM_setValue("timeline_drawer_open", false);
const drawer = document.querySelector(".timeline-drawer");
if (drawer) drawer.classList.remove("open");
document.body.classList.remove("timeline-drawer-push");
document.querySelector(".timeline-overlay")?.classList.remove("active");
updateCountdownDisplay();
stopAutoRefresh();
}
// ========== 加载帖子 ==========
async function loadTimelineTopics(
retryCount = 0,
isSilent = false,
triggerAuto = false,
) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 500;
if (isLoading) return;
isLoading = true;
const content = document.querySelector(".timeline-drawer-content");
if (!content) {
isLoading = false;
return;
}
if (!isSilent) {
currentPage = 0;
hasMorePages = true;
allTopics = [];
loadedTopicIds.clear();
autoLoadCount = 0; // 重置自动加载计数
content.innerHTML = `
<div class="timeline-loading-2">
<div class="timeline-spinner"></div>
<span>${
retryCount > 0
? `重试中 (${retryCount}/${MAX_RETRIES})...`
: "加载中..."
}</span>
</div>
`;
}
try {
let url = "/latest.json?order=created";
if (currentTab !== "all" && currentCategoryId) {
url = `/c/${currentTab}/${currentCategoryId}/l/latest.json?filter=latest`;
}
const response = await fetch(url);
// 获取响应头的x-discourse-username
myUserName = response.headers.get("x-discourse-username");
// 检查响应状态
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data?.users) {
data.users.forEach((user) => {
usersMap[user.id] = user;
});
}
if (data?.topic_list?.topics) {
const topics = data.topic_list.topics;
if (isSilent) {
// 静默刷新:添加新帖子 + 更新旧帖未读状态
let hasNew = false;
let hasStatusChange = false;
let newTopicsList = [];
// 构建最新数据的映射表,用于对比和同步旧帖子数据
const topicUpdateMap = new Map();
topics.forEach((t) => {
topicUpdateMap.set(t.id, t);
});
// 更新已有帖子的各种动态指标(阅读、回复、点赞、未读状态)
allTopics.forEach((existingTopic) => {
if (topicUpdateMap.has(existingTopic.id)) {
const latest = topicUpdateMap.get(existingTopic.id);
// 使用 Object.assign 同步所有属性,同时保持对象引用不变
Object.assign(existingTopic, latest);
hasStatusChange = true;
}
});
// 添加新帖子
topics.forEach((t) => {
if (!loadedTopicIds.has(t.id)) {
loadedTopicIds.add(t.id);
allTopics.unshift(t);
newTopicsList.push(t);
hasNew = true;
}
});
if (hasNew || hasStatusChange) {
allTopics.sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at),
);
renderTopics(newTopicsList.map((t) => t.id));
// 发送系统通知(仅新帖子)
if (hasNew && GM_getValue("timeline_enable_notification", false)) {
newTopicsList.forEach((topic) => {
notifyNewPost(topic);
});
}
}
} else {
allTopics = topics.filter((t) => {
if (loadedTopicIds.has(t.id)) return false;
loadedTopicIds.add(t.id);
return true;
});
// 按发帖时间排序
allTopics.sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at),
);
renderTopics();
}
}
} catch (e) {
console.error(
`[时间线] 加载失败 (尝试 ${retryCount + 1}/${MAX_RETRIES + 1}):`,
e,
);
// 自动重试
if (retryCount < MAX_RETRIES) {
isLoading = false;
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
return loadTimelineTopics(retryCount + 1, isSilent, triggerAuto);
}
showError(e.message || "未知错误");
}
isLoading = false;
// 如果过滤后的列表太少(少于10条),且还有更多页,自动加载下一页
if (
hasMorePages &&
!isLoadingMore &&
(triggerAuto || currentFilter !== "all")
) {
const filteredCount = (function () {
if (currentFilter === "unseen")
return allTopics.filter((t) => t.unseen).length;
if (currentFilter === "read")
return allTopics.filter((t) => !t.unseen).length;
return allTopics.length;
})();
if (filteredCount < 10) {
console.log(
`[时间线] 过滤后仅 ${filteredCount} 条,自动尝试加载更多 (autoLoadCount: ${autoLoadCount})`,
);
loadMoreTopics(true);
}
}
}
// ========== 显示错误信息 ==========
function showError(errorMsg) {
const content = document.querySelector(".timeline-drawer-content");
if (!content) return;
content.innerHTML = `
<div class="timeline-error">
<div class="timeline-error-icon">⚠️</div>
<div class="timeline-error-msg">加载失败</div>
<div class="timeline-error-detail">${escapeHtml(errorMsg)}</div>
<button class="timeline-retry-btn">重试</button>
</div>
`;
content
.querySelector(".timeline-retry-btn")
?.addEventListener("click", () => {
loadTimelineTopics();
});
}
// ========== 加载更多 ==========
async function loadMoreTopics(fromAuto = false) {
if (isLoadingMore || !hasMorePages) return;
isLoadingMore = true;
currentPage++;
const content = document.querySelector(".timeline-drawer-content");
const list = content?.querySelector(".timeline-topic-list");
if (!list) {
isLoadingMore = false;
return;
}
// 添加加载提示
let loadingEl = document.querySelector(".timeline-load-more");
if (!loadingEl) {
loadingEl = document.createElement("div");
loadingEl.className = "timeline-load-more";
loadingEl.innerHTML =
'<span class="timeline-load-more-spinner"></span>加载更多...';
content.appendChild(loadingEl);
}
try {
let url = `/latest.json?order=created&page=${currentPage}`;
if (currentTab !== "all" && currentCategoryId) {
url = `/c/${currentTab}/${currentCategoryId}/l/latest.json?filter=latest&page=${currentPage}`;
}
const data = await fetch(url).then((r) => r.json());
loadingEl?.remove();
if (!data?.topic_list?.topics || data.topic_list.topics.length === 0) {
hasMorePages = false;
showNoMore();
isLoadingMore = false;
return;
}
if (data?.users) {
data.users.forEach((user) => {
usersMap[user.id] = user;
});
}
const newTopics = data.topic_list.topics.filter((t) => {
if (loadedTopicIds.has(t.id)) return false;
loadedTopicIds.add(t.id);
return true;
});
if (newTopics.length === 0) {
hasMorePages = false;
showNoMore();
isLoadingMore = false;
return;
}
newTopics.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
allTopics = allTopics.concat(newTopics);
renderTopics(); // 渲染完整列表
// 如果是自动加载触发的,且加载后过滤结果依然不足10条,继续递归加载
if (fromAuto && hasMorePages) {
const filteredCount = (function () {
if (currentFilter === "unseen")
return allTopics.filter((t) => t.unseen).length;
if (currentFilter === "read")
return allTopics.filter((t) => !t.unseen).length;
return allTopics.length;
})();
if (filteredCount < 10) {
if (autoLoadCount >= 5) {
console.log(
`[时间线] 已连续自动加载 ${autoLoadCount} 次,停止递归。`,
);
autoLoadCount = 0;
isLoadingMore = false;
return;
}
console.log(
`[时间线] 加载后过滤结果 ${filteredCount} 条,继续递归加载 (第 ${
autoLoadCount + 1
} 次)...`,
);
autoLoadCount++;
isLoadingMore = false; // 暂时重置以允许下一次调用
return loadMoreTopics(true);
} else {
autoLoadCount = 0; // 满足条件,重置计数
}
}
} catch (e) {
console.error("[时间线] 加载更多失败:", e);
loadingEl?.remove();
}
isLoadingMore = false;
}
// ========== 显示没有更多 ==========
function showNoMore() {
const content = document.querySelector(".timeline-drawer-content");
if (content && !content.querySelector(".timeline-no-more")) {
const noMore = document.createElement("div");
noMore.className = "timeline-no-more";
noMore.textContent = "没有更多了";
content.appendChild(noMore);
}
}
// ========== 渲染帖子列表 ==========
function renderTopics(newTopicIds = []) {
const content = document.querySelector(".timeline-drawer-content");
if (!content) return;
content.innerHTML = "";
// 应用本地过滤
let filteredTopics = allTopics;
if (currentFilter === "unseen") {
filteredTopics = allTopics.filter((t) => t.unseen);
} else if (currentFilter === "read") {
// 这里的逻辑要严谨:Discourse 的 unseen 可能为 undefined/false,两者都代表已读
filteredTopics = allTopics.filter((t) => !t.unseen);
}
if (filteredTopics.length === 0) {
content.innerHTML = `
<div class="timeline-empty-hint" style="padding: 40px 20px; text-align: center; color: var(--primary-medium, #888);">
${
currentFilter === "unseen"
? "暂无未读帖子"
: currentFilter === "read"
? "暂无已读帖子"
: "暂无帖子"
}
${
hasMorePages
? '<div style="font-size: 11px; margin-top: 8px; opacity: 0.7;">正在尝试加载更多...</div>'
: ""
}
</div>
`;
return;
}
const list = document.createElement("ul");
list.className = "timeline-topic-list";
filteredTopics.forEach((topic) => {
const isNew = newTopicIds.includes(topic.id);
const item = createTopicItem(topic, isNew);
list.appendChild(item);
});
content.appendChild(list);
}
// ========== 发送系统通知 ==========
function notifyNewPost(topic) {
let name = "";
let username = "";
let avatarUrl = "";
if (topic.posters && topic.posters.length > 0) {
const userId = topic.posters[0].user_id;
const user = usersMap[userId];
if (user) {
name = user.name || "";
username = user.username;
if (user.avatar_template) {
avatarUrl = user.avatar_template.replace("{size}", "120");
if (!avatarUrl.startsWith("http")) {
avatarUrl = "https://linux.do" + avatarUrl;
}
}
}
}
// 检查通知模式
const notifMode = GM_getValue("timeline_notif_mode", "all");
if (notifMode === "specified") {
const notifUsersVal = GM_getValue("timeline_notif_users", "");
let specifiedUsernames = [];
if (notifUsersVal.trim().startsWith("[")) {
try {
specifiedUsernames = JSON.parse(notifUsersVal).map((u) =>
u.username.toLowerCase(),
);
} catch (e) {
specifiedUsernames = [];
}
} else {
specifiedUsernames = notifUsersVal
.split(",")
.map((u) => u.trim().toLowerCase())
.filter((u) => u.length > 0);
}
// 如果指定用户列表为空,或者当前用户不在列表中,则不发送通知
if (
specifiedUsernames.length === 0 ||
!specifiedUsernames.includes(username.toLowerCase())
) {
return;
}
}
GM_notification({
title: `${name} (@${username}) 发布了新帖子`,
text: `【${getCategoryName(topic.category_id)}】${topic.title}`,
image: avatarUrl,
highlight: false, // 修复:设置为 false 防止通知发出时自动夺取浏览器焦点
silent: false,
timeout: 20000,
onclick: () => {
window.focus();
const path = `/t/${topic.slug}/${topic.id}`;
navigateTo(path);
},
});
}
// ========== 创建帖子项 ==========
function createTopicItem(topic, isNew = false) {
const item = document.createElement("li");
item.className = "timeline-topic-item";
if (isNew) {
item.classList.add("new-topic-highlight");
// 10秒后移除高亮类
setTimeout(() => {
item.classList.remove("new-topic-highlight");
}, 10000);
}
// 获取用户信息
let avatarUrl = "";
let name = "";
let username = "";
if (topic.posters && topic.posters.length > 0) {
const userId = topic.posters[0].user_id;
const user = usersMap[userId];
if (user) {
name = user.name;
username = user.username;
if (user.avatar_template) {
avatarUrl = user.avatar_template.replace("{size}", "45");
if (!avatarUrl.startsWith("http")) {
avatarUrl = "https://linux.do" + avatarUrl;
}
}
}
}
const createdTime = formatRelativeTime(new Date(topic.created_at));
const views =
topic.views >= 1000 ? (topic.views / 1000).toFixed(1) + "k" : topic.views;
const replies = topic.posts_count - 1;
// 获取分类名称、图标和颜色(优先使用映射表)
const categoryName = getCategoryName(topic.category_id);
const categoryIcon = getCategoryIcon(topic.category_id);
const categoryColor = getCategoryColor(topic.category_id);
// 生成分类 HTML(带 SVG 图标和颜色)
let categoryHtml = "";
if (categoryName) {
categoryHtml = `<span class="timeline-category" style="--category-color: ${categoryColor}"><svg class="timeline-category-icon"><use href="#${categoryIcon}"></use></svg>${escapeHtml(
categoryName,
)}</span>`;
}
// 生成标签 HTML
let tagsHtml = "";
if (topic.tags && topic.tags.length > 0) {
const tagItems = topic.tags
.map(
(tag) =>
`<span class="timeline-tag">${escapeHtml(
typeof tag === "string" ? tag : tag.name,
)}</span>`,
)
.join("");
tagsHtml = `<div class="timeline-tags">${tagItems}</div>`;
}
// 未读标识
const unseenDot = topic.unseen
? '<span class="timeline-unseen-dot"></span>'
: "";
// 显示名称(如果 name 不为空且与 username 不同)
const displayName =
name && name !== username
? `<span class="timeline-topic-name" data-user-card="${username}">${escapeHtml(
name,
)}</span>`
: "";
item.innerHTML = `
${unseenDot}
<div class="timeline-topic-header">
${
avatarUrl
? `<img class="timeline-topic-avatar ${
username === "neo" ? "square-avatar" : ""
}" src="${avatarUrl}" alt="${username}" data-user-card="${username}">`
: ""
}
<div class="timeline-topic-meta">
<div class="timeline-topic-user-info">
${displayName}
<span class="timeline-topic-username" data-user-card="${username}">${escapeHtml(
username,
)}</span>
</div>
<span class="timeline-topic-time">${createdTime}</span>
</div>
</div>
<h4 class="timeline-topic-title">${escapeHtml(topic.title)}</h4>
<div class="timeline-topic-category-tags">
${categoryHtml}
${tagsHtml}
</div>
<div class="timeline-topic-stats">
<span class="timeline-topic-stat">💬 ${replies}</span>
<span class="timeline-topic-stat">👁 ${views}</span>
<span class="timeline-topic-stat">❤️ ${
topic.like_count || 0
}</span>
</div>
`;
// 点击头像或用户名时显示用户卡片
item.querySelectorAll("[data-user-card]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const username = el.getAttribute("data-user-card");
showUserCard(username);
});
});
// 左键点击:当前页面跳转
item.addEventListener("click", (e) => {
if (e.button !== 0) return;
// 立即标记为已读
markTopicAsRead(topic, item);
const path = `/t/${topic.slug}/${topic.id}`;
navigateTo(path);
// 如果是小屏,点击后自动关闭抽屉
if (window.innerWidth <= 768) {
console.log("closeDrawer1111");
closeDrawer();
}
});
// 中键点击:新标签页打开
item.addEventListener("mousedown", (e) => {
if (e.button === 1) {
e.preventDefault(); // 阻止自动滚动
}
});
item.addEventListener("mouseup", (e) => {
if (e.button === 1) {
e.preventDefault();
// 立即标记为已读
markTopicAsRead(topic, item);
const url = `https://linux.do/t/${topic.slug}/${topic.id}`;
window.open(url, "_blank");
}
});
return item;
}
// ========== 标记帖子为已读 ==========
function markTopicAsRead(topic, itemElement) {
if (!topic.unseen) return; // 已经是已读状态
// 更新数据
topic.unseen = false;
// 更新 allTopics 中对应的帖子
const existingTopic = allTopics.find((t) => t.id === topic.id);
if (existingTopic) {
existingTopic.unseen = false;
}
// 移除未读小蓝点
const unseenDot = itemElement.querySelector(".timeline-unseen-dot");
if (unseenDot) {
unseenDot.remove();
}
}
// ========== 显示用户卡片 ==========
function showUserCard(username) {
const path = `/u/${username}/summary`;
navigateTo(path);
}
// ========== Discourse 路由跳转 ==========
function navigateTo(path) {
const script = document.createElement("script");
script.textContent = `window.require("discourse/lib/url").default.routeTo("${path}");`;
document.documentElement.appendChild(script);
script.remove();
}
// ========== 格式化时间 ==========
function formatRelativeTime(date) {
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return `${Math.max(1, seconds)}秒前`;
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}个月前`;
return `${Math.floor(months / 12)}年前`;
}
// ========== HTML 转义 ==========
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// ========== ESC 打开/关闭抽屉 ==========
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
toggleDrawer();
}
});
// ========== 启动 ==========
createFloatButton();
initCoreValueEffect();
initBroadcastChannel();
// 恢复上次的抽屉状态(先检查其他标签页是否已经打开了抽屉)
(async () => {
if (GM_getValue("timeline_drawer_open", false)) {
const otherHas = await checkOtherTabHasDrawer();
if (!otherHas) {
openDrawer();
}
}
})();
console.log("[时间线] v1.24 已加载 - 悬浮按钮模式");
})();