// ==UserScript==
// @name LDStatus Pro
// @namespace http://tampermonkey.net/
// @version 3.5.0.7
// @description 在 Linux.do 和 IDCFlare 页面显示信任级别进度,支持历史趋势、里程碑通知、阅读时间统计、排行榜系统、我的活动查看。两站点均支持排行榜和云同步功能
// @author JackLiii
// @license MIT
// @match https://linux.do/*
// @match https://idcflare.com/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_info
// @grant GM_notification
// @connect connect.linux.do
// @connect linux.do
// @connect connect.idcflare.com
// @connect idcflare.com
// @connect github.com
// @connect raw.githubusercontent.com
// @connect ldstatus-pro-api.jackcai711.workers.dev
// @connect *.workers.dev
// @updateURL https://raw.githubusercontent.com/caigg188/LDStatusPro/main/LDStatusPro.user.js
// @downloadURL https://raw.githubusercontent.com/caigg188/LDStatusPro/main/LDStatusPro.user.js
// @icon https://linux.do/uploads/default/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_180x180.png
// ==/UserScript==
(function() {
'use strict';
// ==================== 尽早捕获 OAuth 登录结果 =====================
// 由于 Discourse 路由可能会处理掉 URL hash,需要在脚本最开始就提取
let _pendingOAuthData = null;
try {
const hash = window.location.hash;
console.log('[OAuth] Initial hash check:', hash ? hash.substring(0, 100) + '...' : '(empty)');
if (hash) {
const match = hash.match(/ldsp_oauth=([^&]+)/);
if (match) {
console.log('[OAuth] Found ldsp_oauth in hash, decoding...');
const encoded = match[1];
const decoded = JSON.parse(decodeURIComponent(atob(encoded)));
console.log('[OAuth] Decoded data:', { hasToken: !!decoded.t, hasUser: !!decoded.u, ts: decoded.ts });
// 检查时效性(5分钟内有效)
if (decoded.ts && Date.now() - decoded.ts < 5 * 60 * 1000) {
_pendingOAuthData = {
success: true,
token: decoded.t,
user: decoded.u,
isJoined: decoded.j === 1
};
console.log('[OAuth] ✅ Captured login data from URL hash, user:', decoded.u?.username);
} else {
console.log('[OAuth] ⚠️ Login data expired, age:', Date.now() - decoded.ts, 'ms');
}
// 立即清除 URL 中的登录参数
let newHash = hash.replace(/[#&]?ldsp_oauth=[^&]*/, '').replace(/^[#&]+/, '').replace(/[#&]+$/, '');
const newUrl = window.location.pathname + window.location.search + (newHash ? '#' + newHash : '');
history.replaceState(null, '', newUrl);
}
}
} catch (e) {
console.warn('[OAuth] Failed to capture OAuth data:', e);
}
// ==================== 浏览器兼容性检查 ====================
// 检测必需的 API 是否存在
if (typeof Map === 'undefined' || typeof Set === 'undefined' || typeof Promise === 'undefined') {
console.error('[LDStatus Pro] 浏览器版本过低,请升级浏览器');
return;
}
// 兼容性:requestIdleCallback polyfill(Firefox 和旧版浏览器)
const requestIdleCallback = window.requestIdleCallback || function(cb) {
const start = Date.now();
return setTimeout(() => cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 1);
};
const cancelIdleCallback = window.cancelIdleCallback || clearTimeout;
// ==================== 网站配置 ====================
const SITE_CONFIGS = {
'linux.do': {
name: 'Linux.do',
icon: 'https://linux.do/uploads/default/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_180x180.png',
apiUrl: 'https://connect.linux.do',
supportsLeaderboard: true
},
'idcflare.com': {
name: 'IDCFlare',
icon: 'https://idcflare.com/uploads/default/optimized/1X/8746f94a48ddc8140e8c7a52084742f38d3f5085_2_180x180.png',
apiUrl: 'https://connect.idcflare.com',
supportsLeaderboard: true
}
};
const CURRENT_SITE = (() => {
const hostname = window.location.hostname;
for (const [domain, config] of Object.entries(SITE_CONFIGS)) {
if (hostname === domain || hostname.endsWith(`.${domain}`)) {
return { domain, prefix: domain.replace('.', '_'), ...config };
}
}
return null;
})();
if (!CURRENT_SITE) {
console.warn('[LDStatus Pro] 不支持的网站');
return;
}
// ==================== 事件总线(跨模块通信) ====================
const EventBus = {
_listeners: new Map(),
// 订阅事件
on(event, callback) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
// 返回取消订阅函数
return () => this.off(event, callback);
},
// 取消订阅
off(event, callback) {
this._listeners.get(event)?.delete(callback);
},
// 发布事件
emit(event, data) {
this._listeners.get(event)?.forEach(cb => {
try { cb(data); } catch (e) { /* 静默失败 */ }
});
},
// 一次性订阅
once(event, callback) {
const wrapper = (data) => {
this.off(event, wrapper);
callback(data);
};
return this.on(event, wrapper);
},
// 清理所有监听器
clear() {
this._listeners.clear();
}
};
// ==================== 跨标签页领导者管理器(全局单例) ====================
// 确保同一时间只有一个标签页执行定时任务(阅读计时、同步、刷新等)
const TabLeader = {
LEADER_KEY: `ldsp_tab_leader_${CURRENT_SITE.prefix}`,
HEARTBEAT: 5000, // 5秒心跳
TIMEOUT: 10000, // 10秒超时(减少陈旧数据导致的等待时间)
_tabId: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
_isLeader: false,
_initialized: false,
_interval: null,
_callbacks: [], // 领导者状态变化回调
init() {
if (this._initialized) return;
this._initialized = true;
this._tryBecomeLeader();
this._interval = setInterval(() => this._tryBecomeLeader(), this.HEARTBEAT);
// 监听其他标签页的变化
this._storageHandler = (e) => {
if (e.key === this.LEADER_KEY) this._tryBecomeLeader();
};
window.addEventListener('storage', this._storageHandler);
// 页面卸载时释放领导者
this._unloadHandler = () => this._release();
window.addEventListener('beforeunload', this._unloadHandler);
},
_tryBecomeLeader() {
const now = Date.now();
let data = {};
// 检查 localStorage 是否可用(隐私模式、存储满等情况)
if (!this._storageAvailable) {
if (this._storageAvailable === undefined) {
try {
const testKey = '__ldsp_test__';
localStorage.setItem(testKey, '1');
localStorage.removeItem(testKey);
this._storageAvailable = true;
} catch (e) {
this._storageAvailable = false;
Logger.log('localStorage not available, becoming sole leader');
}
}
// localStorage 不可用,直接成为领导者
if (!this._storageAvailable) {
if (!this._isLeader) {
this._isLeader = true;
this._notifyCallbacks(true);
EventBus.emit('leader:change', { isLeader: true, tabId: this._tabId });
}
return;
}
}
try {
const stored = localStorage.getItem(this.LEADER_KEY);
if (stored) data = JSON.parse(stored);
} catch (e) { /* 解析失败视为无数据 */ }
const expired = !data.timestamp || (now - data.timestamp) > this.TIMEOUT;
const iAmLeader = data.tabId === this._tabId;
if (expired || iAmLeader) {
const wasLeader = this._isLeader;
this._isLeader = true;
try {
localStorage.setItem(this.LEADER_KEY, JSON.stringify({
tabId: this._tabId,
timestamp: now
}));
} catch (e) { /* 存储失败不影响逻辑 */ }
if (!wasLeader) {
this._notifyCallbacks(true);
EventBus.emit('leader:change', { isLeader: true, tabId: this._tabId });
}
} else if (this._isLeader) {
this._isLeader = false;
this._notifyCallbacks(false);
EventBus.emit('leader:change', { isLeader: false, tabId: this._tabId });
}
},
_release() {
if (this._isLeader) {
try {
const stored = localStorage.getItem(this.LEADER_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.tabId === this._tabId) {
localStorage.removeItem(this.LEADER_KEY);
}
}
} catch (e) { /* 静默失败 */ }
}
},
_notifyCallbacks(isLeader) {
this._callbacks.forEach(cb => {
try { cb(isLeader); } catch (e) { /* 静默失败 */ }
});
},
// 公开方法:检查是否是领导者
isLeader() {
return this._isLeader;
},
// 公开方法:获取当前标签页 ID
getTabId() {
return this._tabId;
},
// 公开方法:注册领导者状态变化回调
onLeaderChange(callback) {
if (typeof callback === 'function') {
this._callbacks.push(callback);
}
},
// 公开方法:销毁
destroy() {
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
if (this._storageHandler) {
window.removeEventListener('storage', this._storageHandler);
}
if (this._unloadHandler) {
window.removeEventListener('beforeunload', this._unloadHandler);
}
this._release();
this._callbacks = [];
this._initialized = false;
}
};
// ==================== 常量配置 ====================
const CONFIG = {
// 时间间隔(毫秒)- 优化版:减少请求频率
INTERVALS: {
REFRESH: 300000, // 数据刷新间隔
READING_TRACK: 10000, // 阅读追踪间隔
READING_SAVE: 30000, // 阅读保存间隔
READING_IDLE: 60000, // 空闲阈值
STORAGE_DEBOUNCE: 1000, // 存储防抖
READING_UPDATE: 2000, // 阅读时间UI更新(2秒,减少更新频率避免动画闪烁)
LEADERBOARD_SYNC: 900000, // 排行榜同步(15分钟,原10分钟)
CLOUD_UPLOAD: 3600000, // 云同步上传(60分钟,原30分钟)
CLOUD_DOWNLOAD: 43200000, // 云同步下载(12小时,原6小时)
CLOUD_CHECK: 600000, // 云同步检查(10分钟,原5分钟)
REQ_SYNC_INCREMENTAL: 3600000, // 升级要求增量同步(1小时)
REQ_SYNC_FULL: 43200000, // 升级要求全量同步(12小时,与reading同步间隔一致)
SYNC_RETRY_DELAY: 60000 // 同步失败后重试延迟(1分钟)
},
// 缓存配置
CACHE: {
MAX_HISTORY_DAYS: 365,
LRU_SIZE: 50,
VALUE_TTL: 5000,
SCREEN_TTL: 100,
YEAR_DATA_TTL: 5000,
HISTORY_TTL: 1000,
LEADERBOARD_DAILY_TTL: 600000, // 日榜缓存 10 分钟(减少请求频率)
LEADERBOARD_WEEKLY_TTL: 7200000, // 周榜缓存 2 小时
LEADERBOARD_MONTHLY_TTL: 21600000 // 月榜缓存 6 小时
},
// 网络配置
NETWORK: {
RETRY_COUNT: 3,
RETRY_DELAY: 1000,
TIMEOUT: 15000
},
// 里程碑配置
MILESTONES: {
'浏览话题': [100, 500, 1000, 2000, 5000],
'已读帖子': [500, 1000, 5000, 10000, 20000],
'获赞': [10, 50, 100, 500, 1000],
'送出赞': [50, 100, 500, 1000, 2000],
'回复': [10, 50, 100, 500, 1000]
},
// 趋势字段配置
TREND_FIELDS: [
{ key: '浏览话题', search: '浏览的话题', label: '浏览话题' },
{ key: '已读帖子', search: '已读帖子', label: '已读帖子' },
{ key: '点赞', search: '送出赞', label: '点赞' },
{ key: '回复', search: '回复', label: '回复' },
{ key: '获赞', search: '获赞', label: '获赞' }
],
// 阅读等级预设样式(图标、颜色、背景色固定,按索引匹配,共10级)
READING_LEVEL_PRESETS: [
{ icon: '🌱', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)' }, // 0: 灰色 - 刚起步
{ icon: '📖', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)' }, // 1: 蓝色 - 热身中
{ icon: '📚', color: '#34d399', bg: 'rgba(52,211,153,0.15)' }, // 2: 绿色 - 渐入佳境
{ icon: '🔥', color: '#fbbf24', bg: 'rgba(251,191,36,0.15)' }, // 3: 黄色 - 沉浸阅读
{ icon: '⚡', color: '#f97316', bg: 'rgba(249,115,22,0.15)' }, // 4: 橙色 - 深度学习
{ icon: '🏆', color: '#a855f7', bg: 'rgba(168,85,247,0.15)' }, // 5: 紫色 - LD达人
{ icon: '👑', color: '#ec4899', bg: 'rgba(236,72,153,0.15)' }, // 6: 粉色 - 超级水怪
{ icon: '💎', color: '#06b6d4', bg: 'rgba(6,182,212,0.15)' }, // 7: 青色 - 钻石级
{ icon: '🌟', color: '#eab308', bg: 'rgba(234,179,8,0.15)' }, // 8: 金色 - 传奇级
{ icon: '🚀', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' } // 9: 红色 - 神话级
],
// 阅读等级默认阈值和标签(与 PRESETS 索引对应)
READING_LEVELS_DEFAULT: [
{ min: 0, label: '刚起步' },
{ min: 30, label: '热身中' },
{ min: 90, label: '渐入佳境' },
{ min: 180, label: '沉浸阅读' },
{ min: 300, label: '深度学习' },
{ min: 450, label: 'LD达人' },
{ min: 600, label: '超级水怪' }
],
// 动态阅读等级配置(运行时从服务器加载)
READING_LEVELS: null,
// 阅读等级配置刷新间隔(24小时)
READING_LEVELS_REFRESH: 24 * 60 * 60 * 1000,
// 名称替换映射
NAME_MAP: new Map([
['已读帖子(所有时间)', '已读帖子'],
['浏览的话题(所有时间)', '浏览话题'],
['获赞:点赞用户数量', '点赞用户'],
['获赞:单日最高数量', '获赞天数'],
['被禁言(过去 6 个月)', '禁言'],
['被封禁(过去 6 个月)', '封禁'],
['发帖数量', '发帖'],
['回复数量', '回复'],
['被举报的帖子(过去 6 个月)', '被举报帖子'],
['发起举报的用户(过去 6 个月)', '发起举报']
]),
// 存储键
STORAGE_KEYS: {
position: 'position', collapsed: 'collapsed', theme: 'theme',
trendTab: 'trend_tab', history: 'history', milestones: 'milestones',
lastNotify: 'last_notify', lastVisit: 'last_visit', todayData: 'today_data',
userAvatar: 'user_avatar', readingTime: 'reading_time', currentUser: 'current_user',
lastCloudSync: 'last_cloud_sync', lastDownloadSync: 'last_download_sync',
lastUploadHash: 'last_upload_hash', leaderboardToken: 'leaderboard_token',
leaderboardUser: 'leaderboard_user', leaderboardJoined: 'leaderboard_joined',
leaderboardTab: 'leaderboard_tab',
readingLevels: 'reading_levels', readingLevelsTime: 'reading_levels_time',
websiteUrl: 'website_url', websiteUrlDate: 'website_url_date'
},
// 用户特定的存储键
USER_KEYS: new Set(['history', 'milestones', 'lastVisit', 'todayData', 'userAvatar', 'readingTime']),
// 周和月名称
WEEKDAYS: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
MONTHS: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
// API地址
LEADERBOARD_API: 'https://ldstatus-pro-api.jackcai711.workers.dev'
};
// 预编译正则
const PATTERNS = {
REVERSE: /被举报|发起举报|禁言|封禁/,
USERNAME: /\/u\/([^/]+)/,
TRUST_LEVEL: /(.*) - 信任级别 (\d+)/,
TRUST_LEVEL_H1: /你好,.*?\(([^)]+)\)\s*(\d+)级用户/, // 匹配 h1 中的 "你好,XX (username) X级用户"
VERSION: /@version\s+([\d.]+)/,
AVATAR_SIZE: /\/\d+\//,
NUMBER: /(\d+)/
};
// ==================== 调试与日志 ====================
const Logger = {
_enabled: false, // 生产环境默认关闭详细日志
_prefix: '[LDSP]',
enable() { this._enabled = true; },
disable() { this._enabled = false; },
log(...args) {
if (this._enabled) console.log(this._prefix, ...args);
},
warn(...args) {
console.warn(this._prefix, ...args);
},
error(...args) {
console.error(this._prefix, ...args);
},
// 带标签的日志(用于追踪特定模块)
tag(tag) {
return {
log: (...args) => this._enabled && console.log(`${this._prefix}[${tag}]`, ...args),
warn: (...args) => console.warn(`${this._prefix}[${tag}]`, ...args),
error: (...args) => console.error(`${this._prefix}[${tag}]`, ...args)
};
}
};
// ==================== 工具函数 ====================
const Utils = {
_nameCache: new Map(),
_htmlEntities: { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' },
// HTML 转义(防止 XSS)
escapeHtml(str) {
if (!str || typeof str !== 'string') return '';
return str.replace(/[&<>"']/g, c => this._htmlEntities[c]);
},
// 清理用户输入(移除控制字符,限制长度)
sanitize(str, maxLen = 100) {
if (!str || typeof str !== 'string') return '';
return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').substring(0, maxLen).trim();
},
// 安全的数值转换(防止 NaN 和 Infinity)
toSafeNumber(val, defaultVal = 0) {
const num = Number(val);
return Number.isFinite(num) ? num : defaultVal;
},
// 安全的整数转换
toSafeInt(val, defaultVal = 0) {
const num = parseInt(val, 10);
return Number.isFinite(num) ? num : defaultVal;
},
// 深度冻结对象(防止意外修改)
deepFreeze(obj) {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach(key => this.deepFreeze(obj[key]));
return Object.freeze(obj);
}
return obj;
},
// 版本比较
compareVersion(v1, v2) {
if (!v1 || !v2) return 0;
const [p1, p2] = [v1, v2].map(v => String(v).split('.').map(n => this.toSafeInt(n)));
const len = Math.max(p1.length, p2.length);
for (let i = 0; i < len; i++) {
const diff = (p1[i] || 0) - (p2[i] || 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
},
// 简化名称
simplifyName(name) {
if (this._nameCache.has(name)) return this._nameCache.get(name);
let result = CONFIG.NAME_MAP.get(name);
if (!result) {
for (const [from, to] of CONFIG.NAME_MAP) {
if (name.includes(from.split('(')[0])) {
result = name.replace(from, to);
break;
}
}
}
result = result || name;
this._nameCache.set(name, result);
return result;
},
// 格式化日期
formatDate(ts, format = 'short') {
const d = new Date(ts);
const [m, day] = [d.getMonth() + 1, d.getDate()];
if (format === 'short') return `${m}/${day}`;
if (format === 'time') return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
return `${m}月${day}日`;
},
// 格式化相对时间(将UTC时间转为本地时间并显示为xx前)
formatRelativeTime(utcStr) {
if (!utcStr) return '';
const d = new Date(utcStr); // 自动转换UTC到本地时区
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}天前`;
if (diff < 31536000) return `${d.getMonth() + 1}月${d.getDate()}日`;
return `${d.getFullYear()}年${d.getMonth() + 1}月`;
},
// 格式化完整日期时间(年月日时分)
formatDateTime(utcStr) {
if (!utcStr) return '';
const d = new Date(utcStr);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
},
// 获取今日键
getTodayKey: () => new Date().toDateString(),
// 格式化阅读时间
formatReadingTime(minutes) {
if (minutes < 1) return '< 1分钟';
if (minutes < 60) return `${Math.round(minutes)}分钟`;
const h = Math.floor(minutes / 60);
const m = Math.round(minutes % 60);
return m > 0 ? `${h}小时${m}分` : `${h}小时`;
},
// 获取阅读等级(合并服务端配置和预设样式)
getReadingLevel(minutes) {
const levels = CONFIG.READING_LEVELS || CONFIG.READING_LEVELS_DEFAULT;
const presets = CONFIG.READING_LEVEL_PRESETS;
for (let i = levels.length - 1; i >= 0; i--) {
if (minutes >= levels[i].min) {
const level = levels[i];
const preset = presets[i] || presets[presets.length - 1];
// 合并:使用服务端的 min/label,预设的 icon/color/bg
return {
min: level.min,
label: level.label,
icon: preset.icon,
color: preset.color,
bg: preset.bg
};
}
}
const first = levels[0];
const preset = presets[0];
return {
min: first.min,
label: first.label,
icon: preset.icon,
color: preset.color,
bg: preset.bg
};
},
// 获取热力图等级
getHeatmapLevel(minutes) {
if (minutes < 1) return 0;
if (minutes < 60) return 1;
if (minutes < 180) return 2;
if (minutes < 300) return 3;
return 4;
},
// 重排需求项(将举报相关项移到禁言前)
reorderRequirements(reqs) {
const reports = [], others = [];
reqs.forEach(r => {
(r.name.includes('被举报') || r.name.includes('发起举报') ? reports : others).push(r);
});
const banIdx = others.findIndex(r => r.name.includes('禁言'));
if (banIdx >= 0) others.splice(banIdx, 0, ...reports);
else others.push(...reports);
return others;
},
// 防抖(带取消功能)
debounce(fn, wait) {
let timer = null;
const debounced = function(...args) {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
};
debounced.cancel = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
},
// 节流(保证首次立即执行,后续按间隔执行)
throttle(fn, limit) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= limit) {
lastTime = now;
fn.apply(this, args);
}
};
},
// 安全执行(捕获异常)
safeCall(fn, fallback = null) {
try {
return fn();
} catch (e) {
return fallback;
}
},
// 安全的异步执行(捕获 Promise 异常)
async safeAsync(fn, fallback = null) {
try {
return await fn();
} catch (e) {
Logger.warn('Async operation failed:', e.message);
return fallback;
}
},
// 生成唯一 ID
uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
},
// 检查是否为有效的 URL
isValidUrl(str) {
if (!str || typeof str !== 'string') return false;
try {
const url = new URL(str);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
},
// 安全获取嵌套对象属性
get(obj, path, defaultVal = undefined) {
if (!obj || typeof path !== 'string') return defaultVal;
const keys = path.split('.');
let result = obj;
for (const key of keys) {
if (result == null || typeof result !== 'object') return defaultVal;
result = result[key];
}
return result !== undefined ? result : defaultVal;
},
// 克隆对象(浅拷贝,用于配置等)
clone(obj) {
if (!obj || typeof obj !== 'object') return obj;
return Array.isArray(obj) ? [...obj] : { ...obj };
}
};
// ==================== 屏幕工具 ====================
const Screen = {
_cache: null,
_cacheTime: 0,
// 动态计算面板配置 - 基于视口尺寸的相对计算
// 确保面板始终在窗口内显示
getConfig() {
const { innerWidth: vw, innerHeight: vh } = window;
// 面板宽度:视口宽度的一定比例,有最小最大值限制
// 基础宽度为视口宽度的18%,但限制在240-360px之间
const baseWidth = Math.round(vw * 0.18);
const width = Math.max(240, Math.min(360, baseWidth));
// 面板最大高度:视口高度减去边距,确保不超出屏幕
// 顶部预留空间根据视口高度动态计算
const topMargin = Math.max(30, Math.round(vh * 0.06)); // 最小30px,或视口6%
const bottomMargin = 20; // 底部预留20px
const maxHeight = vh - topMargin - bottomMargin;
// 字体大小:根据视口高度缩放,范围10-13px
const fontSize = Math.max(10, Math.min(13, Math.round(vh / 70)));
// 内边距:根据宽度缩放,范围8-14px
const padding = Math.max(8, Math.min(14, Math.round(width / 24)));
// 头像大小:根据宽度缩放,范围32-52px
const avatarSize = Math.max(32, Math.min(52, Math.round(width / 6.5)));
// 环形图大小:根据高度缩放,范围55-85px
const ringSize = Math.max(55, Math.min(85, Math.round(vh / 11)));
return {
width,
maxHeight,
fontSize,
padding,
avatarSize,
ringSize,
top: topMargin,
vw,
vh
};
}
};
// ==================== LRU 缓存 ====================
class LRUCache {
constructor(maxSize = CONFIG.CACHE.LRU_SIZE) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
this.cache.has(key) && this.cache.delete(key);
if (this.cache.size >= this.maxSize) {
this.cache.delete(this.cache.keys().next().value);
}
this.cache.set(key, value);
}
has(key) { return this.cache.has(key); }
clear() { this.cache.clear(); }
}
// ==================== 存储管理器 ====================
class Storage {
constructor() {
this._pending = new Map();
this._timer = null;
this._user = null;
this._keyCache = new Map();
this._valueCache = new Map();
this._valueCacheTime = new Map();
}
// 获取当前用户
getUser() {
if (this._user) return this._user;
const link = document.querySelector('.current-user a[href^="/u/"]');
if (link) {
const match = link.getAttribute('href').match(PATTERNS.USERNAME);
if (match) {
this._user = match[1];
GM_setValue(this._globalKey('currentUser'), this._user);
return this._user;
}
}
return this._user = GM_getValue(this._globalKey('currentUser'), null);
}
setUser(username) {
if (this._user !== username) {
this._user = username;
this._keyCache.clear(); // 用户变化时清除 key 缓存
GM_setValue(this._globalKey('currentUser'), username);
}
}
// 生成全局键
_globalKey(key) {
return `ldsp_${CURRENT_SITE.prefix}_${CONFIG.STORAGE_KEYS[key] || key}`;
}
// 生成用户键
_userKey(key) {
const cacheKey = `${key}_${this._user || ''}`;
if (this._keyCache.has(cacheKey)) return this._keyCache.get(cacheKey);
const base = CONFIG.STORAGE_KEYS[key] || key;
const user = this.getUser();
const result = user && CONFIG.USER_KEYS.has(key)
? `ldsp_${CURRENT_SITE.prefix}_${base}_${user}`
: `ldsp_${CURRENT_SITE.prefix}_${base}`;
this._keyCache.set(cacheKey, result);
return result;
}
// 获取用户数据
get(key, defaultValue = null) {
const storageKey = this._userKey(key);
const now = Date.now();
if (this._valueCache.has(storageKey)) {
const cacheTime = this._valueCacheTime.get(storageKey);
if ((now - cacheTime) < CONFIG.CACHE.VALUE_TTL) {
return this._valueCache.get(storageKey);
}
}
const value = GM_getValue(storageKey, defaultValue);
this._valueCache.set(storageKey, value);
this._valueCacheTime.set(storageKey, now);
return value;
}
// 设置用户数据(带防抖)
set(key, value) {
const storageKey = this._userKey(key);
this._valueCache.set(storageKey, value);
this._valueCacheTime.set(storageKey, Date.now());
this._pending.set(storageKey, value);
this._scheduleWrite();
}
// 立即设置用户数据
setNow(key, value) {
const storageKey = this._userKey(key);
this._valueCache.set(storageKey, value);
this._valueCacheTime.set(storageKey, Date.now());
GM_setValue(storageKey, value);
}
// 获取全局数据
getGlobal(key, defaultValue = null) {
return GM_getValue(this._globalKey(key), defaultValue);
}
// 设置全局数据(带防抖)
setGlobal(key, value) {
this._pending.set(this._globalKey(key), value);
this._scheduleWrite();
}
// 立即设置全局数据
setGlobalNow(key, value) {
GM_setValue(this._globalKey(key), value);
}
// 调度写入
_scheduleWrite() {
if (this._timer) return;
this._timer = setTimeout(() => {
this.flush();
this._timer = null;
}, CONFIG.INTERVALS.STORAGE_DEBOUNCE);
}
// 刷新所有待写入数据
flush() {
this._pending.forEach((value, key) => {
try { GM_setValue(key, value); } catch (e) { console.error('[Storage]', key, e); }
});
this._pending.clear();
}
// 清除缓存
invalidateCache(key) {
if (key) {
const storageKey = this._userKey(key);
this._valueCache.delete(storageKey);
this._valueCacheTime.delete(storageKey);
} else {
this._valueCache.clear();
this._valueCacheTime.clear();
}
}
// 迁移旧数据
migrate(username) {
const flag = `ldsp_migrated_v3_${username}`;
if (GM_getValue(flag, false)) return;
CONFIG.USER_KEYS.forEach(key => {
const oldKey = CONFIG.STORAGE_KEYS[key];
const newKey = `ldsp_${CURRENT_SITE.prefix}_${oldKey}_${username}`;
const oldData = GM_getValue(oldKey, null);
if (oldData !== null && GM_getValue(newKey, null) === null) {
GM_setValue(newKey, oldData);
}
});
this._migrateReadingTime(username);
GM_setValue(flag, true);
}
// 迁移阅读时间数据
_migrateReadingTime(username) {
const key = `ldsp_${CURRENT_SITE.prefix}_reading_time_${username}`;
const data = GM_getValue(key, null);
if (!data || typeof data !== 'object') return;
if (data.date && data.minutes !== undefined && !data.dailyData) {
GM_setValue(key, {
version: 3,
dailyData: { [data.date]: { totalMinutes: data.minutes || 0, lastActive: data.lastActive || Date.now(), sessions: [] } },
monthlyCache: {},
yearlyCache: {}
});
} else if (data.version === 2) {
data.version = 3;
data.monthlyCache = data.monthlyCache || {};
data.yearlyCache = data.yearlyCache || {};
if (data.dailyData) {
Object.entries(data.dailyData).forEach(([dateKey, dayData]) => {
try {
const d = new Date(dateKey);
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const yearKey = `${d.getFullYear()}`;
const minutes = dayData.totalMinutes || 0;
data.monthlyCache[monthKey] = (data.monthlyCache[monthKey] || 0) + minutes;
data.yearlyCache[yearKey] = (data.yearlyCache[yearKey] || 0) + minutes;
} catch (e) {}
});
}
GM_setValue(key, data);
}
}
}
// ==================== 自定义错误类型 ====================
class NetworkError extends Error {
constructor(message, code = 'NETWORK_ERROR', status = 0) {
super(message);
this.name = 'NetworkError';
this.code = code;
this.status = status;
}
get isTimeout() { return this.code === 'TIMEOUT'; }
get isAuth() { return this.code === 'UNAUTHORIZED' || this.status === 401; }
get isNotFound() { return this.status === 404; }
get isServerError() { return this.status >= 500; }
}
// ==================== 网络管理器 ====================
class Network {
constructor() {
this._pending = new Map();
this._apiCache = new Map();
this._apiCacheTime = new Map();
}
// 创建统一的错误对象
static createError(message, code = 'UNKNOWN', status = 0) {
return new NetworkError(message, code, status);
}
// 静态方法:加载阅读等级配置(从服务端获取,本地缓存24小时)
static async loadReadingLevels() {
const storageKey = `ldsp_reading_levels`;
const timeKey = `ldsp_reading_levels_time`;
try {
// 检查本地缓存是否过期(24小时刷新一次)
const cachedTime = GM_getValue(timeKey, 0);
const now = Date.now();
if (cachedTime && (now - cachedTime) < CONFIG.READING_LEVELS_REFRESH) {
// 缓存未过期,使用本地数据
const cached = GM_getValue(storageKey, null);
if (cached && Array.isArray(cached) && cached.length > 0) {
CONFIG.READING_LEVELS = cached;
return;
}
}
// 需要从服务端获取
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${CONFIG.LEADERBOARD_API}/api/config/reading-levels`,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
onload: res => {
if (res.status >= 200 && res.status < 300) {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(new Error('Parse error'));
}
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
if (response.success && response.data?.levels && Array.isArray(response.data.levels)) {
const levels = response.data.levels;
CONFIG.READING_LEVELS = levels;
GM_setValue(storageKey, levels);
GM_setValue(timeKey, now);
} else {
throw new Error('Invalid response format');
}
} catch (e) {
// 尝试使用本地缓存(即使过期也比没有好)
const cached = GM_getValue(storageKey, null);
if (cached && Array.isArray(cached) && cached.length > 0) {
CONFIG.READING_LEVELS = cached;
} else {
// 使用默认配置
CONFIG.READING_LEVELS = CONFIG.READING_LEVELS_DEFAULT;
}
}
}
async fetch(url, options = {}) {
if (this._pending.has(url)) return this._pending.get(url);
const promise = this._fetchWithRetry(url, options);
this._pending.set(url, promise);
try {
return await promise;
} finally {
this._pending.delete(url);
}
}
// 清除 API 缓存
clearApiCache(endpoint) {
if (endpoint) {
this._apiCache.delete(endpoint);
this._apiCacheTime.delete(endpoint);
} else {
this._apiCache.clear();
this._apiCacheTime.clear();
}
}
async _fetchWithRetry(url, options) {
const { maxRetries = CONFIG.NETWORK.RETRY_COUNT, timeout = CONFIG.NETWORK.TIMEOUT } = options;
for (let i = 0; i < maxRetries; i++) {
try {
return await this._doFetch(url, timeout);
} catch (e) {
if (i === maxRetries - 1) throw e;
await new Promise(r => setTimeout(r, CONFIG.NETWORK.RETRY_DELAY * Math.pow(2, i)));
}
}
}
async _doFetch(url, timeout) {
// 检测 GM_xmlhttpRequest 是否可用
const hasGM = typeof GM_xmlhttpRequest === 'function';
// 方法1: 尝试 GM_xmlhttpRequest(可绕过跨域)
if (hasGM) {
try {
const result = await new Promise((resolve, reject) => {
let settled = false;
const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
reject(new Error('Timeout'));
}
}, timeout);
try {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout,
onload: res => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
if (res.status >= 200 && res.status < 300) {
resolve(res.responseText);
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror: () => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
reject(new Error('Network error'));
},
ontimeout: () => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
reject(new Error('GM Timeout'));
}
});
} catch (gmCallError) {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
reject(gmCallError);
}
});
return result;
} catch (gmError) {
// 跨域请求不使用 native fetch fallback(会触发 CORS 错误)
const isCrossOrigin = !url.startsWith(location.origin);
if (isCrossOrigin) {
throw gmError; // 直接抛出错误,不 fallback
}
// 同源请求继续尝试 native fetch
}
}
// 方法2: native fetch 作为 fallback(仅同源请求)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, {
credentials: 'include',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.text();
}
// API 请求(带认证和缓存)
async api(endpoint, options = {}) {
const method = options.method || 'GET';
const cacheTtl = options.cacheTtl || 0;
// GET 请求支持缓存
if (method === 'GET' && cacheTtl > 0) {
const now = Date.now();
const cacheKey = `${endpoint}_${options.token || ''}`;
if (this._apiCache.has(cacheKey)) {
const cacheTime = this._apiCacheTime.get(cacheKey);
if (now - cacheTime < cacheTtl) {
return this._apiCache.get(cacheKey);
}
}
}
return new Promise((resolve, reject) => {
// 确保 body 是字符串
let bodyData = options.body;
if (bodyData && typeof bodyData === 'object') {
bodyData = JSON.stringify(bodyData);
}
GM_xmlhttpRequest({
method,
url: `${CONFIG.LEADERBOARD_API}${endpoint}`,
headers: {
'Content-Type': 'application/json',
'X-Client-Version': GM_info.script.version || 'unknown',
...(options.token ? { 'Authorization': `Bearer ${options.token}` } : {})
},
data: bodyData || undefined,
timeout: CONFIG.NETWORK.TIMEOUT,
onload: res => {
try {
const data = JSON.parse(res.responseText);
if (res.status >= 200 && res.status < 300) {
// 缓存成功响应
if (method === 'GET' && cacheTtl > 0) {
const cacheKey = `${endpoint}_${options.token || ''}`;
this._apiCache.set(cacheKey, data);
this._apiCacheTime.set(cacheKey, Date.now());
}
resolve(data);
} else {
// 构建错误消息,包含错误码便于识别
const errorCode = data.error?.code || '';
const errorMsg = data.error?.message || data.error || `HTTP ${res.status}`;
reject(new Error(`${errorCode}: ${errorMsg}`));
}
} catch (e) {
reject(new Error('Parse error'));
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
}
// 获取 JSON 数据(带 cookie 同源请求)
async fetchJson(url, options = {}) {
const timeout = options.timeout || CONFIG.NETWORK.TIMEOUT;
const headers = options.headers || {};
// 使用 GM_xmlhttpRequest 发送带 cookie 的请求
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error('Timeout')), timeout);
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
'Accept': 'application/json',
...headers
},
timeout,
withCredentials: true,
onload: res => {
clearTimeout(timeoutId);
try {
if (res.status >= 200 && res.status < 300) {
resolve(JSON.parse(res.responseText));
} else if (res.status === 403) {
reject(new Error('需要登录后访问'));
} else {
reject(new Error(`HTTP ${res.status}`));
}
} catch (e) {
reject(new Error('解析响应失败'));
}
},
onerror: () => {
clearTimeout(timeoutId);
reject(new Error('网络错误'));
},
ontimeout: () => {
clearTimeout(timeoutId);
reject(new Error('请求超时'));
}
});
});
}
}
// ==================== 历史数据管理器 ====================
class HistoryManager {
constructor(storage) {
this.storage = storage;
this.cache = new LRUCache();
this._history = null;
this._historyTime = 0;
}
getHistory() {
const now = Date.now();
if (this._history && (now - this._historyTime) < CONFIG.CACHE.HISTORY_TTL) {
return this._history;
}
const history = this.storage.get('history', []);
const cutoff = now - CONFIG.CACHE.MAX_HISTORY_DAYS * 86400000;
this._history = history.filter(h => h.ts > cutoff);
this._historyTime = now;
return this._history;
}
addHistory(data, readingTime = 0) {
const history = this.getHistory();
const now = Date.now();
const today = new Date().toDateString();
const record = { ts: now, data, readingTime };
const idx = history.findIndex(h => new Date(h.ts).toDateString() === today);
idx >= 0 ? history[idx] = record : history.push(record);
this.storage.set('history', history);
this._history = history;
this._historyTime = now;
this.cache.clear();
return history;
}
// 聚合每日增量
aggregateDaily(history, reqs, maxDays) {
const cacheKey = `daily_${maxDays}_${history.length}`;
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
const byDay = new Map();
history.forEach(h => {
const day = new Date(h.ts).toDateString();
byDay.has(day) ? byDay.get(day).push(h) : byDay.set(day, [h]);
});
const sortedDays = [...byDay.keys()].sort((a, b) => new Date(a) - new Date(b));
const result = new Map();
let prevData = null;
sortedDays.forEach(day => {
const latest = byDay.get(day).at(-1);
const dayData = {};
reqs.forEach(r => {
dayData[r.name] = (latest.data[r.name] || 0) - (prevData?.[r.name] || 0);
});
result.set(day, dayData);
prevData = { ...latest.data };
});
this.cache.set(cacheKey, result);
return result;
}
// 聚合每周增量
aggregateWeekly(history, reqs) {
const cacheKey = `weekly_${history.length}`;
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
const now = new Date();
const [year, month] = [now.getFullYear(), now.getMonth()];
const weeks = this._getWeeksInMonth(year, month);
const result = new Map();
const byWeek = new Map(weeks.map((_, i) => [i, []]));
history.forEach(h => {
const d = new Date(h.ts);
if (d.getFullYear() === year && d.getMonth() === month) {
weeks.forEach((week, i) => {
if (d >= week.start && d <= week.end) byWeek.get(i).push(h);
});
}
});
let prevData = null;
const lastMonth = history.filter(h => new Date(h.ts) < new Date(year, month, 1));
if (lastMonth.length) prevData = { ...lastMonth.at(-1).data };
weeks.forEach((week, i) => {
const records = byWeek.get(i);
const weekData = {};
if (records.length) {
const latest = records.at(-1);
reqs.forEach(r => {
weekData[r.name] = (latest.data[r.name] || 0) - (prevData?.[r.name] || 0);
});
prevData = { ...latest.data };
} else {
reqs.forEach(r => weekData[r.name] = 0);
}
result.set(i, { weekNum: i + 1, start: week.start, end: week.end, label: `第${i + 1}周`, data: weekData });
});
this.cache.set(cacheKey, result);
return result;
}
// 聚合每月增量
aggregateMonthly(history, reqs) {
const cacheKey = `monthly_${history.length}`;
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
const byMonth = new Map();
history.forEach(h => {
const d = new Date(h.ts);
const key = new Date(d.getFullYear(), d.getMonth(), 1).toDateString();
byMonth.has(key) ? byMonth.get(key).push(h) : byMonth.set(key, [h]);
});
const sortedMonths = [...byMonth.keys()].sort((a, b) => new Date(a) - new Date(b));
const result = new Map();
let prevData = null;
sortedMonths.forEach(month => {
const latest = byMonth.get(month).at(-1);
const monthData = {};
reqs.forEach(r => {
monthData[r.name] = (latest.data[r.name] || 0) - (prevData?.[r.name] || 0);
});
result.set(month, monthData);
prevData = { ...latest.data };
});
this.cache.set(cacheKey, result);
return result;
}
_getWeeksInMonth(year, month) {
const weeks = [];
const lastDay = new Date(year, month + 1, 0);
let start = new Date(year, month, 1);
while (start <= lastDay) {
let end = new Date(start);
end.setDate(end.getDate() + 6);
if (end > lastDay) end = new Date(lastDay);
weeks.push({ start: new Date(start), end });
start = new Date(end);
start.setDate(start.getDate() + 1);
}
return weeks;
}
}
// ==================== 阅读时间追踪器 ====================
class ReadingTracker {
constructor(storage) {
this.storage = storage;
this.isActive = true;
this.lastActivity = Date.now();
this.lastSave = Date.now();
this._intervals = [];
this._initialized = false;
this._yearCache = null;
this._yearCacheTime = 0;
}
init(username) {
if (this._initialized) return;
this.storage.migrate(username);
this._bindEvents();
// 始终启动活动状态追踪(用于 UI 显示)
// 但只有领导者才会执行数据保存(避免多标签页重复写入)
this._startTracking();
this._initialized = true;
}
_stopTracking() {
this._intervals.forEach(id => clearInterval(id));
this._intervals = [];
this._tracking = false;
// 停止前保存当前数据
this.save();
}
_bindEvents() {
try {
// 使用节流的活动处理器
// 普通事件:每秒最多触发一次
this._activityHandler = Utils.throttle(() => this._onActivity(), 1000);
// 高频事件(如 mousemove):每 3 秒最多触发一次
this._highFreqHandler = Utils.throttle(() => this._onActivity(), 3000);
// 监听用户活动事件
// 使用 capture: true 确保在事件捕获阶段就能获取,避免被其他脚本阻止
// 普通频率事件:点击、按键、触摸开始
this._normalEvents = ['mousedown', 'keydown', 'click', 'touchstart', 'pointerdown'];
this._normalEvents.forEach(e => {
document.addEventListener(e, this._activityHandler, { passive: true, capture: true });
});
// 高频事件:移动、滚动(使用更长的节流时间)
this._highFreqEvents = ['mousemove', 'scroll', 'wheel', 'touchmove', 'pointermove'];
this._highFreqEvents.forEach(e => {
document.addEventListener(e, this._highFreqHandler, { passive: true, capture: true });
});
// 页面可见性变化
this._visibilityHandler = () => {
if (document.hidden) {
this.save();
this.isActive = false;
} else {
// 页面恢复可见时,假定用户正在查看,恢复活动状态
// 如果用户60秒内无任何操作,定时器会自动设为 inactive
this.lastActivity = Date.now();
this.isActive = true;
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
// Safari/iOS 兼容:pageshow/pagehide 事件比 visibilitychange 更可靠
this._pageShowHandler = (e) => {
// e.persisted 表示页面从 bfcache 恢复
this.lastActivity = Date.now();
this.isActive = true;
};
this._pageHideHandler = () => {
this.save();
this.isActive = false;
};
window.addEventListener('pageshow', this._pageShowHandler);
window.addEventListener('pagehide', this._pageHideHandler);
// 窗口获得焦点时更新活动状态(同时监听 window 和 document)
this._focusHandler = () => {
this.lastActivity = Date.now();
// Safari 上 focus 事件更可靠,直接设置 active
this.isActive = true;
};
this._blurHandler = () => {
// 窗口失去焦点时保存数据(Safari 上 visibilitychange 可能不触发)
this.save();
};
window.addEventListener('focus', this._focusHandler);
window.addEventListener('blur', this._blurHandler);
document.addEventListener('focus', this._focusHandler);
// 页面卸载前保存
this._beforeUnloadHandler = () => this.save();
window.addEventListener('beforeunload', this._beforeUnloadHandler);
} catch (e) {
Logger.log('Failed to bind events:', e);
// 降级:即使事件绑定失败,也尝试启动基本功能
}
}
_onActivity() {
const now = Date.now();
if (!this.isActive) this.isActive = true;
this.lastActivity = now;
}
_startTracking() {
// 防止重复启动
if (this._tracking) return;
this._tracking = true;
// 用于检测系统休眠/恢复
let lastCheckTime = Date.now();
// 记录定时器启动时间,用于健康检查
this._trackingStartTime = Date.now();
this._intervals.push(
setInterval(() => {
const now = Date.now();
const checkGap = now - lastCheckTime;
// 检测系统休眠:如果两次检查间隔超过预期的 3 倍,说明可能休眠过
// 例如:READING_TRACK=10秒,如果间隔超过 30 秒,说明系统暂停过
if (checkGap > CONFIG.INTERVALS.READING_TRACK * 3) {
// 系统刚从休眠恢复,重置状态避免累积错误时间
this.isActive = false;
this.lastActivity = now;
this.lastSave = now;
lastCheckTime = now;
Logger.log('System resume detected, reset tracking state');
return; // 跳过本次空闲检测,等待用户新活动
}
lastCheckTime = now;
// 空闲检测逻辑:只负责将 active 状态设为 false
// isActive = true 只能通过用户活动事件触发(_onActivity)
const idle = now - this.lastActivity;
if (this.isActive && idle > CONFIG.INTERVALS.READING_IDLE) {
this.isActive = false;
}
// 注意:不再自动将 isActive 设为 true
// 用户必须有新活动才能恢复记录状态
}, CONFIG.INTERVALS.READING_TRACK),
setInterval(() => this.save(), CONFIG.INTERVALS.READING_SAVE)
);
// 健康检查:每 60 秒检查定时器是否还存活
// 如果定时器意外被清除,尝试重新启动
this._healthCheckId = setInterval(() => {
if (this._tracking && this._intervals.length === 0) {
Logger.log('Tracking timers died, restarting...');
this._tracking = false;
this._startTracking();
}
}, 60000);
}
save() {
if (!this.storage.getUser()) return;
const todayKey = Utils.getTodayKey();
const now = Date.now();
// 计算这次应该加的时间(基于本标签页的活动状态)
const elapsed = (now - this.lastSave) / 1000;
const idle = now - this.lastActivity;
// 防护:检测异常数据
// 1. elapsed 为负数(系统时间被调整)
// 2. elapsed 过大(超过 2 分钟,可能是休眠恢复)
// 3. idle 为负数(系统时间被调整)
if (elapsed < 0 || elapsed > 120 || idle < 0) {
// 重置状态,不记录这段异常时间
this.lastSave = now;
this.lastActivity = now;
this.isActive = false;
return;
}
let toAdd = 0;
if (elapsed > 0) {
// 计算有效的活动时间
// 如果用户一直活跃(idle <= 60秒),记录全部 elapsed 时间
// 如果用户空闲了,减去超出空闲阈值的部分
toAdd = idle <= CONFIG.INTERVALS.READING_IDLE
? elapsed
: Math.max(0, elapsed - (idle - CONFIG.INTERVALS.READING_IDLE) / 1000);
// 额外防护:单次保存不能超过保存间隔的 1.5 倍(正常约45秒)
const maxToAdd = CONFIG.INTERVALS.READING_SAVE / 1000 * 1.5;
toAdd = Math.min(toAdd, maxToAdd);
}
// 无论是否是领导者,都更新 lastSave(避免时间累积)
this.lastSave = now;
// 只有领导者才写入 storage
if (!TabLeader.isLeader()) return;
let stored = this.storage.get('readingTime', null);
if (!stored?.dailyData) {
stored = { version: 3, dailyData: {}, monthlyCache: {}, yearlyCache: {} };
}
let today = stored.dailyData[todayKey] || { totalMinutes: 0, lastActive: now, sessions: [] };
const minutes = toAdd / 60;
if (minutes > 0.1) {
today.totalMinutes += minutes;
today.lastActive = now;
today.sessions = (today.sessions || []).slice(-20); // 限制会话数量
today.sessions.push({ time: now, added: minutes });
stored.dailyData[todayKey] = today;
this._updateCache(stored, todayKey, minutes);
this._cleanOld(stored);
this.storage.set('readingTime', stored);
this._yearCache = null;
}
}
_updateCache(stored, dateKey, minutes) {
try {
const d = new Date(dateKey);
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const yearKey = `${d.getFullYear()}`;
stored.monthlyCache[monthKey] = (stored.monthlyCache[monthKey] || 0) + minutes;
stored.yearlyCache[yearKey] = (stored.yearlyCache[yearKey] || 0) + minutes;
} catch (e) {}
}
_cleanOld(stored) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - CONFIG.CACHE.MAX_HISTORY_DAYS);
Object.keys(stored.dailyData).forEach(key => {
if (new Date(key) < cutoff) delete stored.dailyData[key];
});
Object.keys(stored.monthlyCache || {}).forEach(key => {
const [y, m] = key.split('-');
if (new Date(+y, +m - 1, 1) < cutoff) delete stored.monthlyCache[key];
});
}
getTodayTime() {
if (!this.storage.getUser()) return 0;
const stored = this.storage.get('readingTime', null);
const saved = stored?.dailyData?.[Utils.getTodayKey()]?.totalMinutes || 0;
const now = Date.now();
const elapsed = (now - this.lastSave) / 1000;
const idle = now - this.lastActivity;
let unsaved = 0;
if (idle <= CONFIG.INTERVALS.READING_IDLE) {
unsaved = elapsed / 60;
} else {
unsaved = Math.max(0, elapsed - (idle - CONFIG.INTERVALS.READING_IDLE) / 1000) / 60;
}
return saved + Math.max(0, unsaved);
}
getTimeForDate(dateKey) {
return this.storage.get('readingTime', null)?.dailyData?.[dateKey]?.totalMinutes || 0;
}
getWeekHistory() {
const result = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const key = d.toDateString();
result.push({
date: key,
label: Utils.formatDate(d.getTime()),
day: CONFIG.WEEKDAYS[d.getDay()],
minutes: i === 0 ? this.getTodayTime() : this.getTimeForDate(key),
isToday: i === 0
});
}
return result;
}
getYearData() {
const now = Date.now();
if (this._yearCache && (now - this._yearCacheTime) < CONFIG.CACHE.YEAR_DATA_TTL) {
return this._yearCache;
}
const today = new Date();
const year = today.getFullYear();
const stored = this.storage.get('readingTime', null);
const daily = stored?.dailyData || {};
const result = new Map();
Object.entries(daily).forEach(([key, data]) => {
if (new Date(key).getFullYear() === year) {
result.set(key, data.totalMinutes || 0);
}
});
result.set(Utils.getTodayKey(), this.getTodayTime());
this._yearCache = result;
this._yearCacheTime = now;
return result;
}
getTotalTime() {
const stored = this.storage.get('readingTime', null);
if (!stored?.dailyData) return this.getTodayTime();
const todayKey = Utils.getTodayKey();
let total = 0;
Object.entries(stored.dailyData).forEach(([key, data]) => {
total += key === todayKey ? this.getTodayTime() : (data.totalMinutes || 0);
});
return total;
}
destroy() {
// 清除计时定时器
this._intervals.forEach(id => clearInterval(id));
this._intervals = [];
this._tracking = false;
// 清除健康检查定时器
if (this._healthCheckId) {
clearInterval(this._healthCheckId);
this._healthCheckId = null;
}
// 移除普通事件监听器(注意:capture 必须与添加时一致)
if (this._activityHandler && this._normalEvents) {
this._normalEvents.forEach(e => {
document.removeEventListener(e, this._activityHandler, { passive: true, capture: true });
});
}
// 移除高频事件监听器
if (this._highFreqHandler && this._highFreqEvents) {
this._highFreqEvents.forEach(e => {
document.removeEventListener(e, this._highFreqHandler, { passive: true, capture: true });
});
}
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
}
// 移除 Safari 兼容事件
if (this._pageShowHandler) {
window.removeEventListener('pageshow', this._pageShowHandler);
}
if (this._pageHideHandler) {
window.removeEventListener('pagehide', this._pageHideHandler);
}
// 移除焦点事件
if (this._focusHandler) {
window.removeEventListener('focus', this._focusHandler);
document.removeEventListener('focus', this._focusHandler);
}
if (this._blurHandler) {
window.removeEventListener('blur', this._blurHandler);
}
if (this._beforeUnloadHandler) {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
}
// 保存数据
this.save();
}
}
// ==================== 通知管理器 ====================
class Notifier {
constructor(storage) {
this.storage = storage;
}
check(reqs) {
const achieved = this.storage.get('milestones', {});
const newMilestones = [];
reqs.forEach(r => {
Object.entries(CONFIG.MILESTONES).forEach(([key, thresholds]) => {
if (r.name.includes(key)) {
thresholds.forEach(t => {
const k = `${key}_${t}`;
if (r.currentValue >= t && !achieved[k]) {
newMilestones.push({ name: key, threshold: t });
achieved[k] = true;
}
});
}
});
const reqKey = `req_${r.name}`;
if (r.isSuccess && !achieved[reqKey]) {
newMilestones.push({ name: r.name, type: 'req' });
achieved[reqKey] = true;
}
});
if (newMilestones.length) {
this.storage.set('milestones', achieved);
this._notify(newMilestones);
}
}
_notify(milestones) {
const last = this.storage.get('lastNotify', 0);
if (Date.now() - last < 60000) return;
this.storage.set('lastNotify', Date.now());
const msg = milestones.slice(0, 3).map(m =>
m.type === 'req' ? `✅ ${m.name}` : `🏆 ${m.name} → ${m.threshold}`
).join('\n');
typeof GM_notification !== 'undefined' && GM_notification({
title: '🎉 达成里程碑!',
text: msg,
timeout: 5000
});
}
}
// ==================== OAuth 管理器 ====================
class OAuthManager {
constructor(storage, network) {
this.storage = storage;
this.network = network;
}
getToken() { return this.storage.getGlobal('leaderboardToken', null); }
setToken(token) { this.storage.setGlobalNow('leaderboardToken', token); }
getUserInfo() { return this.storage.getGlobal('leaderboardUser', null); }
setUserInfo(user) { this.storage.setGlobalNow('leaderboardUser', user); }
/**
* 检查是否已登录且 Token 未过期
*/
isLoggedIn() {
const token = this.getToken();
const user = this.getUserInfo();
if (!token || !user) return false;
// 检查 token 是否过期
if (this._isTokenExpired(token)) {
Logger.log('Token expired, logging out');
this.logout();
return false;
}
return true;
}
/**
* 解析 JWT Token 检查是否过期
*/
_isTokenExpired(token) {
try {
const parts = token.split('.');
if (parts.length !== 3) return true;
// 解析 payload (base64url)
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const decoded = JSON.parse(atob(payload));
// 检查过期时间 (exp 是秒级时间戳)
if (!decoded.exp) return false; // 无过期时间则认为有效
const now = Math.floor(Date.now() / 1000);
// 提前 10 分钟判断为过期,增加容错时间避免边界情况
return decoded.exp < (now + 600);
} catch (e) {
console.error('[LDStatus Pro] Token parse error:', e);
return true; // 解析失败视为过期
}
}
isJoined() { return this.storage.getGlobal('leaderboardJoined', false); }
setJoined(v) { this.storage.setGlobalNow('leaderboardJoined', v); }
/**
* 检查 URL hash 中的登录结果
* 统一同窗口登录模式:回调后通过 URL hash 传递登录结果
*/
_checkUrlHashLogin() {
try {
const hash = window.location.hash;
if (!hash) return null;
// 查找 ldsp_oauth 参数
const match = hash.match(/ldsp_oauth=([^&]+)/);
if (!match) return null;
const encoded = match[1];
// 解码 base64
const decoded = JSON.parse(decodeURIComponent(atob(encoded)));
// 检查时效性(5分钟内有效)
if (decoded.ts && Date.now() - decoded.ts > 5 * 60 * 1000) {
console.log('[OAuth] URL login result expired');
this._clearUrlHash();
return null;
}
// 转换为标准格式
const result = {
success: true,
token: decoded.t,
user: decoded.u,
isJoined: decoded.j === 1
};
// 清除 URL 中的登录参数,保持 URL 干净
this._clearUrlHash();
return result;
} catch (e) {
console.error('[OAuth] Failed to parse URL hash login:', e);
this._clearUrlHash();
return null;
}
}
/**
* 清除 URL 中的 OAuth 登录参数
*/
_clearUrlHash() {
try {
const hash = window.location.hash;
if (!hash || !hash.includes('ldsp_oauth=')) return;
// 移除 ldsp_oauth 参数
let newHash = hash.replace(/[#&]?ldsp_oauth=[^&]*/, '');
// 清理多余的 # 和 &
newHash = newHash.replace(/^[#&]+/, '').replace(/[#&]+$/, '');
// 更新 URL(不触发页面刷新)
const newUrl = window.location.pathname + window.location.search + (newHash ? '#' + newHash : '');
history.replaceState(null, '', newUrl);
} catch (e) {
console.warn('[OAuth] Failed to clear URL hash:', e);
}
}
/**
* 统一同窗口登录
* 所有环境都使用同窗口跳转方式,避免弹窗拦截和跨窗口通信问题
*/
async login() {
// 检查是否有待处理的登录结果(从 URL hash 中获取)
const pendingResult = this._checkUrlHashLogin();
if (pendingResult?.success && pendingResult.token && pendingResult.user) {
this.setToken(pendingResult.token);
this.setUserInfo(pendingResult.user);
this.setJoined(pendingResult.isJoined || false);
return pendingResult.user;
}
// 获取授权链接并跳转(同窗口模式)
const siteParam = encodeURIComponent(CURRENT_SITE.domain);
// 使用不带 hash 的 URL 作为返回地址
const returnUrl = encodeURIComponent(window.location.origin + window.location.pathname + window.location.search);
try {
const result = await this.network.api(`/api/auth/init?site=${siteParam}&return_url=${returnUrl}`);
if (result.success && result.data?.auth_url) {
// 跳转到授权页面
window.location.href = result.data.auth_url;
// 返回一个永不 resolve 的 Promise(页面会跳转,不会执行后续代码)
return new Promise(() => {});
} else {
throw new Error(result.error?.message || '获取授权链接失败');
}
} catch (e) {
throw new Error(e.message || '登录请求失败');
}
}
logout() {
this.setToken(null);
this.setUserInfo(null);
this.setJoined(false);
}
/**
* 发起 API 请求,自动处理 Token 过期
* @param {string} endpoint - API 端点
* @param {Object} options - 请求选项
* @param {boolean} options.requireAuth - 是否需要登录(默认 true)
*/
async api(endpoint, options = {}) {
const { requireAuth = true, ...restOptions } = options;
// 需要登录的接口,先检查登录状态(包含 Token 过期检测)
if (requireAuth) {
const token = this.getToken();
// 无 Token 或 Token 已过期,直接返回错误
if (!token || this._isTokenExpired(token)) {
// 清理过期状态
if (token) this.logout();
return { success: false, error: { code: 'NOT_LOGGED_IN', message: 'Not logged in or token expired' } };
}
}
try {
const result = await this.network.api(endpoint, { ...restOptions, token: this.getToken() });
return result;
} catch (e) {
// 检查是否是 Token 过期错误
const errMsg = e.message || '';
const isAuthError = errMsg.includes('expired') || errMsg.includes('TOKEN_EXPIRED') ||
errMsg.includes('INVALID_TOKEN') || errMsg.includes('401') ||
errMsg.includes('Unauthorized') || (e instanceof NetworkError && e.isAuth);
if (isAuthError) {
this.logout();
// 通过事件总线通知(替代全局 window 事件)
EventBus.emit('auth:expired', { endpoint });
}
throw e;
}
}
}
// ==================== 排行榜管理器 ====================
class LeaderboardManager {
constructor(oauth, readingTracker, storage) {
this.oauth = oauth;
this.tracker = readingTracker;
this.storage = storage; // v3.2.7: 用于智能同步缓存
this.cache = new Map();
this._syncTimer = null;
this._lastSync = 0;
this._manualRefreshTime = new Map(); // 记录每种榜的手动刷新时间
}
// 手动刷新冷却时间 5 分钟
static MANUAL_REFRESH_COOLDOWN = 5 * 60 * 1000;
async getLeaderboard(type = 'daily') {
const key = `lb_${type}`;
const cached = this.cache.get(key);
const now = Date.now();
const ttlMap = {
daily: CONFIG.CACHE.LEADERBOARD_DAILY_TTL,
weekly: CONFIG.CACHE.LEADERBOARD_WEEKLY_TTL,
monthly: CONFIG.CACHE.LEADERBOARD_MONTHLY_TTL
};
const ttl = ttlMap[type] || CONFIG.CACHE.LEADERBOARD_DAILY_TTL;
if (cached && (now - cached.time) < ttl) return cached.data;
try {
// oauth.api() 内置登录检查,未登录时返回 { success: false }
const result = await this.oauth.api(`/api/leaderboard/${type}`);
if (result.success) {
const data = {
rankings: result.data.rankings || [],
period: result.data.period,
myRank: result.data.myRank
};
this.cache.set(key, { data, time: now });
return data;
}
throw new Error(result.error || '获取排行榜失败');
} catch (e) {
if (cached) return cached.data;
throw e;
}
}
// 手动刷新排行榜(有5分钟冷却时间)
async forceRefresh(type = 'daily') {
const key = `lb_${type}`;
const now = Date.now();
const lastRefresh = this._manualRefreshTime.get(type) || 0;
// 检查冷却时间
if (now - lastRefresh < LeaderboardManager.MANUAL_REFRESH_COOLDOWN) {
// 冷却中,返回缓存
const cached = this.cache.get(key);
if (cached) return { data: cached.data, fromCache: true };
throw new Error('刷新冷却中');
}
try {
const result = await this.oauth.api(`/api/leaderboard/${type}`);
if (result.success) {
const data = {
rankings: result.data.rankings || [],
period: result.data.period,
myRank: result.data.myRank
};
this.cache.set(key, { data, time: now });
this._manualRefreshTime.set(type, now);
return { data, fromCache: false };
}
throw new Error(result.error || '获取排行榜失败');
} catch (e) {
const cached = this.cache.get(key);
if (cached) return { data: cached.data, fromCache: true };
throw e;
}
}
// 获取手动刷新剩余冷却时间(秒)
getRefreshCooldown(type = 'daily') {
const lastRefresh = this._manualRefreshTime.get(type) || 0;
const elapsed = Date.now() - lastRefresh;
const remaining = LeaderboardManager.MANUAL_REFRESH_COOLDOWN - elapsed;
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
}
async join() {
const result = await this.oauth.api('/api/user/register', { method: 'POST' });
if (result.success) {
this.oauth.setJoined(true);
return true;
}
// 检测登录失效情况
const errCode = result.error?.code;
if (errCode === 'NOT_LOGGED_IN' || errCode === 'AUTH_EXPIRED' || errCode === 'INVALID_TOKEN') {
throw new Error('登录已失效,请重新登录');
}
throw new Error(result.error?.message || result.error || '加入失败');
}
async quit() {
const result = await this.oauth.api('/api/user/quit', { method: 'POST' });
if (result.success) {
this.oauth.setJoined(false);
return true;
}
// 检测登录失效情况
const errCode = result.error?.code;
if (errCode === 'NOT_LOGGED_IN' || errCode === 'AUTH_EXPIRED' || errCode === 'INVALID_TOKEN') {
throw new Error('登录已失效,请重新登录');
}
throw new Error(result.error?.message || result.error || '退出失败');
}
async syncReadingTime() {
if (!this.oauth.isLoggedIn() || !this.oauth.isJoined()) return;
// 只有领导者标签页才执行同步,避免多标签页重复请求
if (!TabLeader.isLeader()) return;
if (Date.now() - this._lastSync < 60000) return;
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const currentMinutes = this.tracker.getTodayTime();
// v3.2.7 优化(方案E):智能同步 - 只在数据变化时才发送请求
// 节省约 30% 的 D1 写入额度
const lastSyncedKey = `lastSynced_${today}`;
const lastSyncedMinutes = this.storage?.getGlobal(lastSyncedKey, -1) ?? -1;
if (currentMinutes === lastSyncedMinutes) {
// 数据没变化,跳过同步
return;
}
const result = await this.oauth.api('/api/reading/sync', {
method: 'POST',
body: {
date: today,
minutes: currentMinutes,
client_timestamp: Date.now()
}
});
// 登录失效或请求失败,不更新本地状态
if (!result?.success && !result?.server_minutes) {
return;
}
this._lastSync = Date.now();
// v3.4.2 修复:渐进同步 - 处理服务器截断响应
// 服务器防刷机制会限制单次增量,需要多次同步才能完成大幅增量
if (result && result.server_minutes !== undefined) {
// 以服务器实际接受的分钟数为准
const serverAccepted = result.server_minutes;
this.storage?.setGlobal(lastSyncedKey, serverAccepted);
if (result.truncated && serverAccepted < currentMinutes) {
// 服务器截断了数据,需要继续同步
Logger.log(`Leaderboard sync truncated: server=${serverAccepted}, client=${currentMinutes}, will retry`);
// 35秒后再次尝试同步剩余数据(服务器限制是30秒)
setTimeout(() => {
this._lastSync = 0; // 重置冷却时间
this.syncReadingTime();
}, 35000);
} else if (result.rateLimited) {
// 被服务器限速,稍后重试
Logger.log('Leaderboard rate limited, will retry later');
setTimeout(() => {
this._lastSync = 0;
this.syncReadingTime();
}, 35000);
}
} else {
// 兼容旧版响应格式
this.storage?.setGlobal(lastSyncedKey, currentMinutes);
}
} catch (e) {
console.warn('[Leaderboard] Sync failed:', e.message || e);
}
}
startSync() {
if (this._syncTimer) return;
// 延迟5秒后首次同步,避免与页面加载时的其他请求并发
setTimeout(() => this.syncReadingTime(), 5000);
this._syncTimer = setInterval(() => this.syncReadingTime(), CONFIG.INTERVALS.LEADERBOARD_SYNC);
}
stopSync() {
this._syncTimer && clearInterval(this._syncTimer);
this._syncTimer = null;
}
clearCache() { this.cache.clear(); }
destroy() {
this.stopSync();
this.clearCache();
}
}
// ==================== 云同步管理器 ====================
class CloudSyncManager {
constructor(storage, oauth, tracker) {
this.storage = storage;
this.oauth = oauth;
this.tracker = tracker;
this._timer = null;
this._syncing = false;
this._lastUpload = storage.getGlobal('lastCloudSync', 0);
this._lastDownload = storage.getGlobal('lastDownloadSync', 0);
this._lastHash = storage.getGlobal('lastUploadHash', '');
this._onSyncStateChange = null; // 同步状态变化回调
// 失败重试机制
this._failureCount = { reading: 0, requirements: 0 };
this._lastFailure = { reading: 0, requirements: 0 };
// trust_level 缓存(避免重复调用 requirements 接口)
this._trustLevelCache = storage.getGlobal('trustLevelCache', null);
this._trustLevelCacheTime = storage.getGlobal('trustLevelCacheTime', 0);
}
// 计算退避延迟(指数退避,最大 30 分钟)
_getBackoffDelay(type) {
const failures = this._failureCount[type] || 0;
if (failures === 0) return 0;
const baseDelay = CONFIG.INTERVALS.SYNC_RETRY_DELAY || 60000;
return Math.min(baseDelay * Math.pow(2, failures - 1), 30 * 60 * 1000);
}
// 检查是否可以重试
_canRetry(type) {
const lastFail = this._lastFailure[type] || 0;
const backoff = this._getBackoffDelay(type);
return Date.now() - lastFail >= backoff;
}
// 记录失败
_recordFailure(type) {
this._failureCount[type] = Math.min((this._failureCount[type] || 0) + 1, 6);
this._lastFailure[type] = Date.now();
}
// 记录成功(重置失败计数)
_recordSuccess(type) {
this._failureCount[type] = 0;
this._lastFailure[type] = 0;
}
// 检查用户 trust_level 是否足够
// 优先从 OAuth 用户信息获取,其次使用缓存
_hasSufficientTrustLevel() {
// 1. 优先从 OAuth 用户信息获取 trust_level(最准确)
// v3.4.7: 兼容 trust_level 和 trustLevel 两种命名格式
const userInfo = this.oauth.getUserInfo();
const trustLevel = userInfo?.trust_level ?? userInfo?.trustLevel;
if (userInfo && typeof trustLevel === 'number') {
const hasTrust = trustLevel >= 2;
// 更新缓存以便其他地方使用
if (this._trustLevelCache !== hasTrust) {
this._updateTrustLevelCache(hasTrust);
}
return hasTrust;
}
// 2. 使用缓存(24小时有效)
const now = Date.now();
const cacheAge = now - this._trustLevelCacheTime;
if (this._trustLevelCache !== null && cacheAge < 24 * 60 * 60 * 1000) {
return this._trustLevelCache;
}
// 3. 无法确定,返回 null(需要从 API 获取)
return null;
}
// 更新 trust_level 缓存(兼容性保留)
_updateTrustLevelCache(hasTrust) {
// v3.4.8: 移除等级限制,始终缓存为 true
this._trustLevelCache = true;
this._trustLevelCacheTime = Date.now();
this.storage.setGlobalNow('trustLevelCache', true);
this.storage.setGlobalNow('trustLevelCacheTime', this._trustLevelCacheTime);
}
// 设置同步状态变化回调
setSyncStateCallback(callback) {
this._onSyncStateChange = callback;
}
// 更新同步状态
_setSyncing(syncing) {
this._syncing = syncing;
this._onSyncStateChange?.(syncing);
}
// 获取同步状态
isSyncing() {
return this._syncing;
}
_getDataHash() {
const data = this.storage.get('readingTime', null);
if (!data?.dailyData) return '';
const days = Object.keys(data.dailyData).length;
const total = Object.values(data.dailyData).reduce((s, d) => s + (d.totalMinutes || 0), 0);
return `${days}:${Math.round(total)}`;
}
async download() {
// 检查退避延迟
if (!this._canRetry('reading')) {
return null;
}
try {
const result = await this.oauth.api('/api/reading/history?days=365');
if (!result.success) {
this._recordFailure('reading');
return null;
}
this._recordSuccess('reading');
const cloud = result.data.dailyData || {};
let local = this.storage.get('readingTime', null);
if (!local?.dailyData) {
local = { version: 3, dailyData: cloud, monthlyCache: {}, yearlyCache: {} };
this._rebuildCache(local);
this.storage.setNow('readingTime', local);
// 通知 UI 阅读数据已更新(新设备首次同步)
EventBus.emit('reading:synced', { merged: Object.keys(cloud).length, source: 'cloud' });
return { merged: Object.keys(cloud).length, source: 'cloud' };
}
let merged = 0;
Object.entries(cloud).forEach(([key, cloudDay]) => {
const localMinutes = local.dailyData[key]?.totalMinutes || 0;
const cloudMinutes = cloudDay.totalMinutes || 0;
if (cloudMinutes > localMinutes) {
local.dailyData[key] = {
totalMinutes: cloudMinutes,
lastActive: cloudDay.lastActive || Date.now(),
sessions: local.dailyData[key]?.sessions || []
};
merged++;
}
});
if (merged > 0) {
this._rebuildCache(local);
this.storage.setNow('readingTime', local);
// 通知 UI 阅读数据已更新
EventBus.emit('reading:synced', { merged, source: 'merge' });
}
return { merged, source: 'merge' };
} catch (e) {
console.error('[CloudSync] Download failed:', e);
this._recordFailure('reading');
return null;
}
}
async upload() {
// 前置检查:登录状态 + 同步状态 + 数据有效性
if (!this.oauth.isLoggedIn() || this._syncing) return null;
const local = this.storage.get('readingTime', null);
if (!local?.dailyData || Object.keys(local.dailyData).length === 0) {
return null;
}
// 检查退避延迟
if (!this._canRetry('reading')) {
return null;
}
try {
this._setSyncing(true);
// 优化:只上传最近 90 天的数据,减少请求大小
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
const cutoff = cutoffDate.toDateString();
const recentData = {};
let count = 0;
for (const [key, value] of Object.entries(local.dailyData)) {
// 只保留最近90天的数据
try {
const date = new Date(key);
if (date >= cutoffDate && count < 100) { // 最多100条
recentData[key] = value;
count++;
}
} catch (e) {}
}
if (Object.keys(recentData).length === 0) {
this._setSyncing(false);
return null;
}
const result = await this.oauth.api('/api/reading/sync-full', {
method: 'POST',
body: { dailyData: recentData, lastSyncTime: Date.now() }
});
if (result.success) {
this._lastUpload = Date.now();
this.storage.setGlobalNow('lastCloudSync', this._lastUpload);
this._recordSuccess('reading');
return result.data;
}
this._recordFailure('reading');
throw new Error(result.error || '上传失败');
} catch (e) {
console.error('[CloudSync] Upload failed:', e);
this._recordFailure('reading');
return null;
} finally {
this._setSyncing(false);
}
}
async onPageLoad() {
if (!this.oauth.isLoggedIn()) return;
const now = Date.now();
const local = this.storage.get('readingTime', null);
const hasLocal = local?.dailyData && Object.keys(local.dailyData).length > 0;
const isNew = !hasLocal || this._lastDownload === 0;
// 串行执行同步请求,避免并发压力
// 1. 下载检查(优先级最高)
if (isNew || (now - this._lastDownload) > CONFIG.INTERVALS.CLOUD_DOWNLOAD) {
const result = await this.download();
if (result) {
this._lastDownload = now;
this.storage.setGlobalNow('lastDownloadSync', now);
if (isNew && result.merged > 0) this.tracker._yearCache = null;
}
}
// 2. 上传检查(仅在数据变化时)
const hash = this._getDataHash();
if (hash && hash !== this._lastHash && (now - this._lastUpload) > 5 * 60 * 1000) {
// 至少间隔 5 分钟才上传
const result = await this.upload();
if (result) {
this._lastHash = hash;
this.storage.setGlobalNow('lastUploadHash', hash);
}
}
this._startPeriodicSync();
}
async fullSync() {
// 前置登录检查
if (!this.oauth.isLoggedIn() || this._syncing) return;
try {
this._setSyncing(true);
await this.download();
this._lastDownload = Date.now();
this.storage.setGlobalNow('lastDownloadSync', this._lastDownload);
// 上传本地数据(只上传最近 90 天,减少请求大小)
const local = this.storage.get('readingTime', null);
if (local?.dailyData && Object.keys(local.dailyData).length > 0) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
const recentData = {};
let count = 0;
for (const [key, value] of Object.entries(local.dailyData)) {
try {
const date = new Date(key);
if (date >= cutoffDate && count < 100) {
recentData[key] = value;
count++;
}
} catch (e) {}
}
if (Object.keys(recentData).length > 0) {
const result = await this.oauth.api('/api/reading/sync-full', {
method: 'POST',
body: { dailyData: recentData, lastSyncTime: Date.now() }
});
if (result?.success) {
this._lastUpload = Date.now();
this.storage.setGlobalNow('lastCloudSync', this._lastUpload);
}
}
}
this._lastHash = this._getDataHash();
this.storage.setGlobalNow('lastUploadHash', this._lastHash);
this._startPeriodicSync();
} finally {
this._setSyncing(false);
}
}
_startPeriodicSync() {
if (this._timer) return;
this._timer = setInterval(async () => {
if (!this.oauth.isLoggedIn()) return;
// 只有领导者标签页才执行定期同步,避免多标签页重复请求
if (!TabLeader.isLeader()) return;
if (this._syncing) return; // 避免并发
const now = Date.now();
const hash = this._getDataHash();
// 上传检查:数据变化 + 间隔足够 + 不在退避期
if (hash !== this._lastHash &&
(now - this._lastUpload) > CONFIG.INTERVALS.CLOUD_UPLOAD &&
this._canRetry('reading')) {
const result = await this.upload();
if (result) {
this._lastHash = hash;
this.storage.setGlobalNow('lastUploadHash', hash);
}
}
// 下载检查:间隔足够 + 不在退避期
if ((now - this._lastDownload) > CONFIG.INTERVALS.CLOUD_DOWNLOAD &&
this._canRetry('reading')) {
const result = await this.download();
if (result) {
this._lastDownload = now;
this.storage.setGlobalNow('lastDownloadSync', now);
}
}
}, CONFIG.INTERVALS.CLOUD_CHECK);
}
_rebuildCache(data) {
data.monthlyCache = {};
data.yearlyCache = {};
Object.entries(data.dailyData).forEach(([key, day]) => {
try {
const d = new Date(key);
if (isNaN(d.getTime())) return;
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const yearKey = `${d.getFullYear()}`;
const minutes = day.totalMinutes || 0;
data.monthlyCache[monthKey] = (data.monthlyCache[monthKey] || 0) + minutes;
data.yearlyCache[yearKey] = (data.yearlyCache[yearKey] || 0) + minutes;
} catch (e) {}
});
}
// ==================== 升级要求历史同步 (trust_level >= 2) ====================
/**
* 设置 HistoryManager 引用(用于升级要求同步)
*/
setHistoryManager(historyMgr) {
this._historyMgr = historyMgr;
// 兼容旧版本存储 key
this._reqLastDownload = this.storage.getGlobal('lastReqDownload', 0);
this._reqLastFullSync = this.storage.getGlobal('lastReqFullSync', 0) ||
this.storage.getGlobal('lastReqSync', 0); // 兼容旧 key
this._reqLastIncrementalSync = this.storage.getGlobal('lastReqIncrementalSync', 0);
}
/**
* 获取升级要求历史数据的 hash
*/
_getReqHash() {
if (!this._historyMgr) return '';
const history = this._historyMgr.getHistory();
if (!history.length) return '';
return `${history.length}:${history[history.length - 1].ts}`;
}
/**
* 下载升级要求历史数据
*/
async downloadRequirements() {
// 前置检查:登录状态 + 只有领导者标签页执行
if (!this.oauth.isLoggedIn() || !this._historyMgr) return null;
if (!TabLeader.isLeader()) return null;
// 检查退避延迟
if (!this._canRetry('requirements')) {
return null;
}
try {
// 减少请求数据量:从 100 天减少到 60 天
const result = await this.oauth.api('/api/requirements/history?days=60');
if (!result.success) {
// 权限不足(trust_level < 2)是正常情况,缓存结果避免重复请求
if (result.error?.code === 'INSUFFICIENT_TRUST_LEVEL') {
this._updateTrustLevelCache(false);
return null;
}
this._recordFailure('requirements');
return null;
}
// 请求成功,说明有足够权限
this._updateTrustLevelCache(true);
this._recordSuccess('requirements');
const cloudHistory = result.data.history || [];
if (!cloudHistory.length) return { merged: 0, source: 'empty' };
let localHistory = this._historyMgr.getHistory();
const localByDay = new Map();
localHistory.forEach(h => {
const day = new Date(h.ts).toDateString();
localByDay.set(day, h);
});
let merged = 0;
cloudHistory.forEach(cloudRecord => {
const day = new Date(cloudRecord.ts).toDateString();
const localRecord = localByDay.get(day);
if (!localRecord) {
// 本地没有,添加云端数据
localHistory.push(cloudRecord);
merged++;
} else {
// 本地有,合并数据(取每个字段的较大值)
let changed = false;
for (const [key, cloudValue] of Object.entries(cloudRecord.data)) {
if (typeof cloudValue === 'number') {
const localValue = localRecord.data[key] || 0;
if (cloudValue > localValue) {
localRecord.data[key] = cloudValue;
changed = true;
}
}
}
if (cloudRecord.readingTime > (localRecord.readingTime || 0)) {
localRecord.readingTime = cloudRecord.readingTime;
changed = true;
}
if (changed) merged++;
}
});
if (merged > 0) {
// 按时间排序
localHistory.sort((a, b) => a.ts - b.ts);
this.storage.set('history', localHistory);
this._historyMgr._history = localHistory;
this._historyMgr._historyTime = Date.now();
this._historyMgr.cache.clear();
}
return { merged, source: 'merge' };
} catch (e) {
console.error('[CloudSync] Requirements download failed:', e);
this._recordFailure('requirements');
return null;
}
}
/**
* 增量同步当天的升级要求数据
* @param {Object} todayRecord - 今天的历史记录 {ts, data, readingTime}
*/
async syncTodayRequirements(todayRecord) {
// 前置检查:登录状态 + 数据有效性
if (!this.oauth.isLoggedIn() || !this._historyMgr) return null;
if (!todayRecord?.data || Object.keys(todayRecord.data).length === 0) return null;
// 检查退避延迟
if (!this._canRetry('requirements')) {
return null;
}
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const result = await this.oauth.api('/api/requirements/sync', {
method: 'POST',
body: {
date: today,
requirements: todayRecord.data,
readingTime: todayRecord.readingTime || 0
}
});
if (result.success) {
this._reqLastIncrementalSync = Date.now();
this.storage.setGlobalNow('lastReqIncrementalSync', this._reqLastIncrementalSync);
this._updateTrustLevelCache(true);
this._recordSuccess('requirements');
return result.data;
}
// 权限不足是正常情况,缓存结果
if (result.error?.code === 'INSUFFICIENT_TRUST_LEVEL') {
this._updateTrustLevelCache(false);
return null;
}
this._recordFailure('requirements');
return null;
} catch (e) {
console.error('[CloudSync] Requirements incremental sync failed:', e);
this._recordFailure('requirements');
return null;
}
}
/**
* 全量上传升级要求历史数据(仅在需要时调用)
*/
async uploadRequirementsFull() {
// 前置检查:登录状态 + 数据有效性
if (!this.oauth.isLoggedIn() || !this._historyMgr || this._syncing) return null;
const history = this._historyMgr.getHistory();
if (!history.length) return null;
// 检查退避延迟
if (!this._canRetry('requirements')) {
return null;
}
try {
// 限制上传数据量,最多 60 天
const recentHistory = history.slice(-60);
const result = await this.oauth.api('/api/requirements/sync-full', {
method: 'POST',
body: { history: recentHistory, lastSyncTime: Date.now() }
});
if (result.success) {
this._reqLastFullSync = Date.now();
this.storage.setGlobalNow('lastReqFullSync', this._reqLastFullSync);
this._updateTrustLevelCache(true);
this._recordSuccess('requirements');
return result.data;
}
// 权限不足是正常情况,缓存结果
if (result.error?.code === 'INSUFFICIENT_TRUST_LEVEL') {
this._updateTrustLevelCache(false);
return null;
}
this._recordFailure('requirements');
throw new Error(result.error?.message || '上传失败');
} catch (e) {
console.error('[CloudSync] Requirements full upload failed:', e);
this._recordFailure('requirements');
return null;
}
}
/**
* 兼容旧调用 - 重定向到增量同步
* @deprecated 使用 syncTodayRequirements 或 uploadRequirementsFull
*/
async uploadRequirements() {
// 获取今天的记录并进行增量同步
const history = this._historyMgr?.getHistory() || [];
const today = new Date().toDateString();
const todayRecord = history.find(h => new Date(h.ts).toDateString() === today);
return this.syncTodayRequirements(todayRecord);
}
/**
* 页面加载时同步升级要求数据
* 仅 trust_level >= 2 的用户可用
*
* 优化策略(v3.3.1):
* 1. 增量同步:默认只同步当天数据(1小时间隔)
* 2. 全量同步:仅在以下情况触发(12小时间隔):
* - 首次登录(从未下载过云端数据)
* - 本地数据天数与云端不一致
*/
async syncRequirementsOnLoad() {
// 前置检查:登录状态 + 只有领导者标签页执行
if (!this.oauth.isLoggedIn() || !this._historyMgr) return;
if (!TabLeader.isLeader()) return;
const now = Date.now();
const localHistory = this._historyMgr.getHistory();
const INCREMENTAL_INTERVAL = CONFIG.INTERVALS.REQ_SYNC_INCREMENTAL || 3600000; // 1小时
const FULL_INTERVAL = CONFIG.INTERVALS.REQ_SYNC_FULL || 43200000; // 12小时
// ========== 判断是否需要全量同步 ==========
const isFirstTime = this._reqLastDownload === 0;
const needFullSync = isFirstTime || (now - (this._reqLastFullSync || 0)) > FULL_INTERVAL;
if (needFullSync) {
// 1. 先下载云端数据
const downloadResult = await this.downloadRequirements();
if (downloadResult) {
this._reqLastDownload = now;
this.storage.setGlobalNow('lastReqDownload', now);
// 2. 如果本地有数据且云端数据较少,上传本地数据
const cloudDays = downloadResult.merged || 0;
const localDays = localHistory.length;
if (localDays > 0 && (isFirstTime || localDays > cloudDays)) {
const uploadResult = await this.uploadRequirementsFull();
if (uploadResult) {
this._reqLastFullSync = now;
this.storage.setGlobalNow('lastReqFullSync', now);
}
} else {
this._reqLastFullSync = now;
this.storage.setGlobalNow('lastReqFullSync', now);
}
}
return;
}
// ========== 增量同步:只同步当天数据 ==========
const lastIncremental = this._reqLastIncrementalSync || 0;
if ((now - lastIncremental) < INCREMENTAL_INTERVAL) {
return;
}
// 获取今天的记录
const today = new Date().toDateString();
const todayRecord = localHistory.find(h => new Date(h.ts).toDateString() === today);
if (todayRecord) {
await this.syncTodayRequirements(todayRecord);
}
}
/**
* 获取系统公告(公开接口,不需要登录)
* @returns {Promise<{enabled: boolean, content: string, type: string}|null>}
*/
async getAnnouncement() {
try {
const response = await fetch(`${CONFIG.LEADERBOARD_API}/api/config/announcement`);
if (!response.ok) return null;
const result = await response.json();
if (result.success && result.data) {
return result.data;
}
return null;
} catch (e) {
console.error('[CloudSync] Get announcement failed:', e);
return null;
}
}
/**
* 获取官网URL(公开接口,不需要登录)
* 每天最多请求一次,使用本地缓存
* @returns {Promise} 官网URL
*/
async getWebsiteUrl() {
const DEFAULT_URL = 'https://ldspro.qzz.io/';
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
try {
// 检查今日是否已请求过
const cachedDate = GM_getValue('ldsp_website_url_date', null);
const cachedUrl = GM_getValue('ldsp_website_url', null);
if (cachedDate === today && cachedUrl) {
return cachedUrl;
}
// 今日未请求,发起请求
const response = await fetch(`${CONFIG.LEADERBOARD_API}/api/config/website-url`);
if (!response.ok) {
// 请求失败,返回缓存或默认值
return cachedUrl || DEFAULT_URL;
}
const result = await response.json();
if (result.success && result.data?.url) {
// 更新缓存
GM_setValue('ldsp_website_url', result.data.url);
GM_setValue('ldsp_website_url_date', today);
return result.data.url;
}
return cachedUrl || DEFAULT_URL;
} catch (e) {
console.error('[CloudSync] Get website URL failed:', e);
// 出错时返回缓存或默认值
const cachedUrl = GM_getValue('ldsp_website_url', null);
return cachedUrl || DEFAULT_URL;
}
}
destroy() {
this._timer && clearInterval(this._timer);
this._timer = null;
}
}
// ==================== 样式管理器 ====================
const Styles = {
_injected: false,
inject() {
if (this._injected) return;
const cfg = Screen.getConfig();
const style = document.createElement('style');
style.id = 'ldsp-styles';
style.textContent = this._css(cfg);
document.head.appendChild(style);
this._injected = true;
},
_css(c) {
return `
#ldsp-panel{--dur-fast:120ms;--dur:200ms;--dur-slow:350ms;--ease:cubic-bezier(.22,1,.36,1);--ease-circ:cubic-bezier(.85,0,.15,1);--ease-spring:cubic-bezier(.175,.885,.32,1.275);--ease-out:cubic-bezier(0,.55,.45,1);--bg:#12131a;--bg-card:rgba(24,26,36,.92);--bg-hover:rgba(38,42,56,.95);--bg-el:rgba(32,35,48,.88);--bg-glass:rgba(255,255,255,.02);--txt:#e4e6ed;--txt-sec:#9499ad;--txt-mut:#5d6275;--accent:#6b8cef;--accent-light:#8aa4f4;--accent2:#5bb5a6;--accent2-light:#7cc9bc;--accent3:#e07a8d;--grad:linear-gradient(135deg,#5a7de0 0%,#4a6bc9 100%);--grad-accent:linear-gradient(135deg,#4a6bc9,#3d5aaa);--grad-warm:linear-gradient(135deg,#e07a8d,#c9606e);--grad-gold:linear-gradient(135deg,#d4a853 0%,#c49339 100%);--ok:#5bb5a6;--ok-light:#7cc9bc;--ok-bg:rgba(91,181,166,.12);--err:#e07a8d;--err-light:#ea9aa8;--err-bg:rgba(224,122,141,.12);--warn:#d4a853;--warn-bg:rgba(212,168,83,.12);--border:rgba(255,255,255,.06);--border2:rgba(255,255,255,.1);--border-accent:rgba(107,140,239,.3);--border-panel:rgba(0,0,0,.25);--shadow:0 1.25rem 3rem rgba(0,0,0,.4);--shadow-lg:0 1.5rem 4rem rgba(0,0,0,.5),0 0 2rem rgba(107,140,239,.06);--shadow-glow:0 0 1.25rem rgba(107,140,239,.15);--glow-accent:0 0 1rem rgba(107,140,239,.2);--scrollbar:rgba(140,150,175,.5);--scrollbar-hover:rgba(140,150,175,.7);--r-xs:0.25em;--r-sm:0.5em;--r-md:0.75em;--r-lg:1em;--r-xl:1.25em;--w:${c.width}px;--h:${c.maxHeight}px;--fs:${c.fontSize}px;--pd:${c.padding}px;--av:${c.avatarSize}px;--ring:${c.ringSize}px;--min-w:220px;--max-w:420px;--min-h:300px;display:flex;flex-direction:column;position:fixed;left:0.5vw;top:${c.top}px;right:auto;width:var(--w);max-height:var(--h);min-width:var(--min-w);max-width:var(--max-w);min-height:var(--min-h);background:var(--bg);border-radius:var(--r-lg);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Noto Sans SC',sans-serif;font-size:var(--fs);color:var(--txt);box-shadow:var(--shadow);z-index:99999;overflow:hidden;border:none;backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px)}
#ldsp-panel,#ldsp-panel *{transition:opacity var(--dur) var(--ease),transform var(--dur) var(--ease);user-select:none;-webkit-font-smoothing:antialiased}
#ldsp-panel{transform:translateZ(0);backface-visibility:hidden}
#ldsp-panel input,#ldsp-panel textarea{cursor:text;user-select:text}
#ldsp-panel [data-clickable],#ldsp-panel [data-clickable] *,#ldsp-panel button,#ldsp-panel a,#ldsp-panel .ldsp-tab,#ldsp-panel .ldsp-subtab,#ldsp-panel .ldsp-ring-lvl,#ldsp-panel .ldsp-rd-day-bar,#ldsp-panel .ldsp-year-cell:not(.empty),#ldsp-panel .ldsp-rank-item,#ldsp-panel .ldsp-ticket-item,#ldsp-panel .ldsp-ticket-type,#ldsp-panel .ldsp-ticket-tab,#ldsp-panel .ldsp-ticket-close,#ldsp-panel .ldsp-ticket-back,#ldsp-panel .ldsp-lb-refresh,#ldsp-panel .ldsp-modal-btn,#ldsp-panel .ldsp-lb-btn,#ldsp-panel .ldsp-update-bubble-close{cursor:pointer}
#ldsp-panel.no-trans,#ldsp-panel.no-trans *{transition:none!important;animation-play-state:paused!important}
#ldsp-panel.anim{transition:width var(--dur-slow) var(--ease),height var(--dur-slow) var(--ease),left var(--dur-slow) var(--ease),top var(--dur-slow) var(--ease)}
#ldsp-panel.light{--bg:rgba(250,251,254,.97);--bg-card:rgba(245,247,252,.94);--bg-hover:rgba(238,242,250,.96);--bg-el:rgba(255,255,255,.94);--bg-glass:rgba(0,0,0,.012);--txt:#1e2030;--txt-sec:#4a5068;--txt-mut:#8590a6;--accent:#5070d0;--accent-light:#6b8cef;--accent2:#4a9e8f;--accent2-light:#5bb5a6;--ok:#4a9e8f;--ok-light:#5bb5a6;--ok-bg:rgba(74,158,143,.08);--err:#d45d6e;--err-light:#e07a8d;--err-bg:rgba(212,93,110,.08);--warn:#c49339;--warn-bg:rgba(196,147,57,.08);--border:rgba(0,0,0,.08);--border2:rgba(0,0,0,.1);--border-accent:rgba(80,112,208,.2);--border-panel:rgba(0,0,0,.1);--shadow:0 1.25rem 3rem rgba(0,0,0,.08);--shadow-lg:0 1.5rem 4rem rgba(0,0,0,.12);--glow-accent:0 0 1rem rgba(80,112,208,.1);--scrollbar:var(--accent);--scrollbar-hover:var(--accent-light)}
#ldsp-panel.collapsed{width:48px!important;height:48px!important;min-width:48px!important;min-height:48px!important;max-height:48px!important;border-radius:var(--r-md);cursor:pointer;touch-action:none;background:linear-gradient(135deg,#7a9bf5 0%,#5a7de0 50%,#5bb5a6 100%);border:none;box-shadow:var(--shadow),0 0 20px rgba(107,140,239,.35)}
#ldsp-panel.collapsed .ldsp-hdr{padding:0;justify-content:center;align-items:center;height:100%;background:0 0;min-height:0}
#ldsp-panel.collapsed .ldsp-hdr-info{opacity:0;visibility:hidden;pointer-events:none;position:absolute;transform:translateX(-10px)}
#ldsp-panel.collapsed .ldsp-body{display:none!important}
#ldsp-panel.collapsed .ldsp-hdr-btns>button:not(.ldsp-toggle){opacity:0;visibility:hidden;pointer-events:none;transform:scale(0.8);position:absolute}
#ldsp-panel.collapsed .ldsp-hdr-btns{justify-content:center;width:100%;height:100%;margin-left:0}
#ldsp-panel.collapsed,#ldsp-panel.collapsed *{cursor:pointer!important}
#ldsp-panel.collapsed .ldsp-toggle{width:100%;height:100%;font-size:18px;background:0 0;display:flex;align-items:center;justify-content:center;color:#fff;position:absolute;inset:0;margin:0;padding:0;box-sizing:border-box}
#ldsp-panel.collapsed .ldsp-toggle .ldsp-toggle-arrow{display:none}
#ldsp-panel.collapsed .ldsp-toggle .ldsp-toggle-logo{display:block;width:24px;height:24px;filter:brightness(1.05) drop-shadow(0 0 2px rgba(140,180,255,.2));transition:filter .2s var(--ease),transform .2s var(--ease)}
#ldsp-panel:not(.collapsed) .ldsp-toggle .ldsp-toggle-logo{display:none}
@media (hover:hover){#ldsp-panel.collapsed:hover{transform:scale(1.08);box-shadow:var(--shadow-lg),0 0 35px rgba(120,160,255,.6)}#ldsp-panel.collapsed:hover .ldsp-toggle-logo{filter:brightness(1.6) drop-shadow(0 0 12px rgba(160,200,255,1)) drop-shadow(0 0 20px rgba(140,180,255,.8));transform:scale(1.15) rotate(360deg);transition:filter .3s var(--ease),transform .6s var(--ease-spring)}}
#ldsp-panel.collapsed:active .ldsp-toggle-logo{filter:brightness(2) drop-shadow(0 0 16px rgba(200,230,255,1)) drop-shadow(0 0 30px rgba(160,200,255,1));transform:scale(0.92)}
#ldsp-panel.collapsed.no-hover-effect{transform:none!important}#ldsp-panel.collapsed.no-hover-effect .ldsp-toggle-logo{filter:brightness(1.05) drop-shadow(0 0 2px rgba(140,180,255,.2))!important;transform:none!important}
.ldsp-hdr{display:flex;align-items:center;padding:10px 12px;background:var(--grad);cursor:move;user-select:none;touch-action:none;position:relative;gap:8px;min-height:52px;box-sizing:border-box;flex-shrink:0}
.ldsp-hdr::before{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.1) 0%,transparent 100%);pointer-events:none}
.ldsp-hdr::after{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(circle,rgba(255,255,255,.1) 0%,transparent 60%);opacity:0;transition:opacity .5s;pointer-events:none}
.ldsp-hdr:hover::after{opacity:1}
.ldsp-hdr-info{display:flex;align-items:center;gap:8px;min-width:0;flex:1 1 auto;position:relative;z-index:1;transition:opacity .25s var(--ease),visibility .25s,transform .25s var(--ease);overflow:hidden}
.ldsp-site-wrap{display:flex;flex-direction:column;align-items:center;gap:3px;flex-shrink:0;position:relative}
.ldsp-site-icon{width:26px;height:26px;border-radius:7px;border:2px solid rgba(255,255,255,.25);flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.2)}
.ldsp-hdr-text{display:flex;flex-direction:column;align-items:flex-start;gap:1px;min-width:0;flex:1 1 0;overflow:hidden}
.ldsp-title{font-weight:800;font-size:14px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2;letter-spacing:-.02em;text-shadow:0 1px 2px rgba(0,0,0,.2);max-width:100%}
.ldsp-ver{font-size:10px;color:rgba(255,255,255,.6);line-height:1.2;display:flex;align-items:center;gap:4px;overflow:hidden;max-width:100%}
.ldsp-learn-trust{display:block;text-align:center;margin-top:8px;font-size:10px;color:var(--txt-dim);text-decoration:none;opacity:.6;transition:opacity .15s,color .15s}
.ldsp-learn-trust:hover{opacity:1;color:var(--txt-sec)}
.ldsp-app-name{font-size:10px;font-weight:700;white-space:nowrap;background:linear-gradient(90deg,#a8c0f8,#7a9eef,#7cc9bc,#7a9eef,#a8c0f8);background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:gradient-shift 6s ease infinite;will-change:background-position}
@keyframes gradient-shift{0%{background-position:0% center}50%{background-position:100% center}100%{background-position:0% center}}
.ldsp-ver-num{background:rgba(255,255,255,.2);padding:2px 8px;border-radius:10px;color:#fff;font-weight:600;font-size:9px;backdrop-filter:blur(4px)}
.ldsp-site-ver{font-size:9px;color:#fff;text-align:center;font-weight:700;background:rgba(0,0,0,.25);padding:1px 5px;border-radius:5px;letter-spacing:.02em}
.ldsp-hdr-btns{display:flex;gap:4px;flex-shrink:0;position:relative;z-index:1;margin-left:auto}
.ldsp-hdr-btns button{width:28px;height:28px;border:none;background:rgba(255,255,255,.12);color:#fff;border-radius:var(--r-sm);font-size:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0;outline:none;-webkit-tap-highlight-color:transparent;backdrop-filter:blur(4px);transition:transform .25s var(--ease),background .15s,box-shadow .2s,opacity .2s,visibility .2s}
.ldsp-hdr-btns button:hover{background:rgba(255,255,255,.25);transform:translateY(-2px) scale(1.05);box-shadow:0 4px 12px rgba(0,0,0,.2)}
.ldsp-hdr-btns button:active{transform:translateY(0) scale(.95)}
.ldsp-hdr-btns button:focus{outline:none}
.ldsp-hdr-btns button:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
.ldsp-hdr-btns button.has-update{background:linear-gradient(135deg,var(--ok),var(--ok-light));animation:pulse-update 3s ease-in-out infinite;position:relative;box-shadow:0 0 15px rgba(16,185,129,.4)}
.ldsp-hdr-btns button.has-update::after{content:'';position:absolute;top:-3px;right:-3px;width:10px;height:10px;background:var(--err);border-radius:50%;border:2px solid rgba(0,0,0,.2);animation:pulse-dot 2.5s ease infinite}
@keyframes pulse-update{0%,100%{transform:scale(1)}50%{transform:scale(1.05)}}
@keyframes pulse-dot{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.15);opacity:.8}}
.ldsp-update-bubble{position:absolute;top:52px;left:50%;transform:translateX(-50%) translateY(-10px);background:var(--bg-card);border:1px solid var(--border-accent);border-radius:var(--r-md);padding:16px 18px;text-align:center;z-index:100;box-shadow:var(--shadow-lg),var(--glow-accent);opacity:0;pointer-events:none;transition:transform .3s var(--ease-spring),opacity .3s var(--ease);max-width:calc(100% - 24px);width:220px;backdrop-filter:blur(16px);will-change:transform,opacity}
.ldsp-update-bubble::before{content:'';position:absolute;top:-7px;left:50%;transform:translateX(-50%) rotate(45deg);width:12px;height:12px;background:var(--bg-card);border-left:1px solid var(--border-accent);border-top:1px solid var(--border-accent)}
.ldsp-update-bubble.show{opacity:1;transform:translateX(-50%) translateY(0);pointer-events:auto}
.ldsp-update-bubble-close{position:absolute;top:8px;right:10px;font-size:16px;color:var(--txt-mut);transition:color .15s,background .15s;line-height:1;width:20px;height:20px;display:flex;align-items:center;justify-content:center;border-radius:50%}
.ldsp-update-bubble-close:hover{color:var(--txt);background:var(--bg-hover)}
.ldsp-update-bubble-icon{font-size:28px;margin-bottom:8px;animation:bounce-in .5s var(--ease-spring)}
@keyframes bounce-in{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
.ldsp-update-bubble-title{font-size:13px;font-weight:700;margin-bottom:6px;color:var(--txt);letter-spacing:-.01em}
.ldsp-update-bubble-ver{font-size:11px;margin-bottom:12px;color:var(--txt-sec)}
.ldsp-update-bubble-btn{background:var(--grad);color:#fff;border:none;padding:8px 20px;border-radius:20px;font-size:12px;font-weight:600;transition:transform .2s var(--ease),box-shadow .2s;box-shadow:0 4px 15px rgba(107,140,239,.3)}
.ldsp-update-bubble-btn:hover{transform:translateY(-2px) scale(1.02);box-shadow:0 6px 20px rgba(107,140,239,.4)}
.ldsp-update-bubble-btn:active{transform:translateY(0) scale(.98)}
.ldsp-update-bubble-btn:disabled{opacity:.6;cursor:not-allowed;transform:none!important}
.ldsp-body{background:var(--bg);position:relative;overflow:hidden;display:flex;flex-direction:column;flex:1;min-height:0}
.ldsp-announcement{overflow:hidden;background:linear-gradient(90deg,rgba(59,130,246,.1),rgba(107,140,239,.1));border-bottom:1px solid var(--border);padding:0;height:0;opacity:0;transition:height .3s var(--ease),opacity .3s,padding .3s;flex-shrink:0}
.ldsp-announcement.active{height:24px;min-height:24px;opacity:1;padding:0 10px}
.ldsp-announcement.warning{background:linear-gradient(90deg,rgba(245,158,11,.15),rgba(239,68,68,.08))}
.ldsp-announcement.success{background:linear-gradient(90deg,rgba(16,185,129,.12),rgba(34,197,94,.08))}
.ldsp-announcement-inner{display:flex;align-items:center;height:24px;white-space:nowrap;animation:marquee var(--marquee-duration,20s) linear forwards}
.ldsp-announcement-inner:hover{animation-play-state:paused}
.ldsp-announcement-text{font-size:11px;font-weight:500;color:var(--txt-sec);display:flex;align-items:center;gap:6px;padding-right:50px}
.ldsp-announcement-text::before{content:'📢';font-size:12px}
.ldsp-announcement.warning .ldsp-announcement-text::before{content:'⚠️'}
.ldsp-announcement.success .ldsp-announcement-text::before{content:'🎉'}
@keyframes marquee{0%{transform:translateX(100%)}100%{transform:translateX(-100%)}}
.ldsp-user{display:flex;align-items:stretch;gap:10px;padding:10px var(--pd) 24px;background:var(--bg-card);border-bottom:1px solid var(--border);position:relative;overflow:hidden;flex-shrink:0}
.ldsp-user::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.3}
.ldsp-user-left{display:flex;flex-direction:column;flex:1;min-width:0;gap:8px}
.ldsp-user-row{display:flex;align-items:center;gap:10px}
.ldsp-user-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
.ldsp-avatar{width:var(--av);height:var(--av);border-radius:12px;border:2px solid var(--accent);flex-shrink:0;background:var(--bg-el);position:relative;box-shadow:0 4px 12px rgba(107,140,239,.2);transition:transform .3s var(--ease),box-shadow .3s,border-color .2s}
.ldsp-avatar:hover{transform:scale(1.08) rotate(-3deg);border-color:var(--accent-light);box-shadow:0 6px 20px rgba(107,140,239,.35),var(--glow-accent)}
.ldsp-avatar-ph{width:var(--av);height:var(--av);border-radius:12px;background:var(--grad);display:flex;align-items:center;justify-content:center;font-size:18px;color:#fff;flex-shrink:0;transition:transform .3s var(--ease),box-shadow .3s;position:relative;box-shadow:0 4px 12px rgba(107,140,239,.25)}
.ldsp-avatar-ph:hover{transform:scale(1.08) rotate(-3deg);box-shadow:0 6px 20px rgba(107,140,239,.4)}
.ldsp-avatar-wrap{position:relative;flex-shrink:0}
.ldsp-avatar-wrap::after{content:'🔗 GitHub';position:absolute;bottom:-20px;left:50%;transform:translateX(-50%) translateY(4px);background:var(--bg-el);color:var(--txt-sec);padding:3px 8px;border-radius:6px;font-size:8px;white-space:nowrap;opacity:0;pointer-events:none;transition:transform .2s var(--ease),opacity .2s;border:1px solid var(--border2);box-shadow:0 4px 12px rgba(0,0,0,.2)}
.ldsp-avatar-wrap:hover::after{opacity:1;transform:translateX(-50%) translateY(0)}
.ldsp-user-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.ldsp-user-display-name{font-weight:700;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3;letter-spacing:-.01em;background:linear-gradient(135deg,var(--txt) 0%,var(--txt-sec) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.ldsp-user-handle{font-size:12px;color:var(--txt-mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
.ldsp-user.not-logged .ldsp-avatar,.ldsp-user.not-logged .ldsp-avatar-ph{border:2px dashed var(--warn);animation:pulse-border 3s ease infinite}
@keyframes pulse-border{0%,100%{border-color:var(--warn)}50%{border-color:rgba(245,158,11,.5)}}
@keyframes pulse-border-red{0%,100%{border-color:#ef4444}50%{border-color:rgba(239,68,68,.4)}}
.ldsp-user.not-logged .ldsp-user-display-name{color:var(--warn);-webkit-text-fill-color:var(--warn)}
.ldsp-login-hint{font-size:9px;color:var(--warn);margin-left:4px;animation:blink 2.5s ease-in-out infinite;background:var(--warn-bg);padding:2px 6px;border-radius:8px;font-weight:500}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.7}}
.ldsp-user-meta{display:flex;align-items:center;gap:8px;margin-top:3px}
.ldsp-reading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:8px 12px;border-radius:var(--r-md);min-width:70px;position:relative;overflow:visible;border:1px solid var(--border);transition:background .2s,border-color .2s,box-shadow .3s}
.ldsp-reading::before{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(255,255,255,.05) 0%,transparent 100%);pointer-events:none}
.ldsp-reading-icon{font-size:20px;margin-bottom:3px;animation:bounce 3s ease-in-out infinite;filter:drop-shadow(0 2px 4px rgba(0,0,0,.2));will-change:transform}
@keyframes bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
.ldsp-reading-time{font-size:13px;font-weight:800;letter-spacing:-.02em}
.ldsp-reading-label{font-size:9px;opacity:.85;margin-top:2px;font-weight:600;letter-spacing:.02em}
.ldsp-reading{--rc:#94a3b8}
.ldsp-reading::after{content:'未活动 已停止记录';position:absolute;bottom:-16px;left:50%;transform:translateX(-50%);font-size:8px;color:var(--err);white-space:nowrap;font-weight:600;letter-spacing:.02em;opacity:.8}
.ldsp-reading.tracking{animation:reading-glow 3.5s ease-in-out infinite;will-change:box-shadow}
.ldsp-reading.tracking::after{content:'阅读时间记录中...';color:var(--rc);opacity:1}
@keyframes reading-glow{0%,100%{box-shadow:0 0 8px color-mix(in srgb,var(--rc) 40%,transparent),0 0 16px color-mix(in srgb,var(--rc) 20%,transparent),0 0 24px color-mix(in srgb,var(--rc) 10%,transparent)}50%{box-shadow:0 0 16px color-mix(in srgb,var(--rc) 60%,transparent),0 0 32px color-mix(in srgb,var(--rc) 35%,transparent),0 0 48px color-mix(in srgb,var(--rc) 15%,transparent)}}
.ldsp-reading-ripple{position:absolute;inset:-2px;border-radius:inherit;pointer-events:none;z-index:-1;opacity:0}
.ldsp-reading.tracking .ldsp-reading-ripple{opacity:1}
.ldsp-reading.tracking .ldsp-reading-ripple::before,.ldsp-reading.tracking .ldsp-reading-ripple::after{content:'';position:absolute;inset:0;border-radius:inherit;border:2px solid var(--rc);opacity:.5;animation:ripple-expand 4s ease-out infinite;will-change:transform,opacity}
.ldsp-reading.tracking .ldsp-reading-ripple::after{animation-delay:2s}
@keyframes ripple-expand{0%{transform:scale(1);opacity:.5;border-width:2px}100%{transform:scale(1.4);opacity:0;border-width:1px}}
.ldsp-reading.hi{box-shadow:0 0 20px rgba(249,115,22,.2)}
.ldsp-reading.hi .ldsp-reading-icon{animation:fire 1.2s ease-in-out infinite;will-change:transform}
@keyframes fire{0%,100%{transform:scale(1)}50%{transform:scale(1.1)}}
.ldsp-reading.max{box-shadow:0 0 25px rgba(236,72,153,.25)}
.ldsp-reading.max .ldsp-reading-icon{animation:crown 2s ease-in-out infinite;will-change:transform}
@keyframes crown{0%,100%{transform:rotate(-5deg) scale(1)}50%{transform:rotate(5deg) scale(1.1)}}
.ldsp-tabs{display:flex;padding:8px 10px;gap:6px;background:var(--bg);border-bottom:1px solid var(--border);flex-shrink:0;container-type:inline-size}
.ldsp-tab{flex:1;padding:7px 8px;border:none;background:var(--bg-card);color:var(--txt-sec);border-radius:var(--r-sm);font-size:11px;font-weight:600;transition:background .15s,color .15s,border-color .15s,box-shadow .2s;border:1px solid transparent;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:4px;min-width:0;overflow:hidden}
.ldsp-tab .ldsp-tab-icon{flex-shrink:0;font-size:12px}
.ldsp-tab .ldsp-tab-text{overflow:hidden;text-overflow:ellipsis;min-width:1em}
.ldsp-tab:hover{background:var(--bg-hover);color:var(--txt);border-color:var(--border2);transform:translateY(-1px)}
.ldsp-tab.active{background:var(--grad);color:#fff;box-shadow:0 4px 15px rgba(107,140,239,.3);border-color:transparent}
@container (max-width:260px){.ldsp-tab{font-size:10px;padding:6px 5px;gap:2px}.ldsp-tab .ldsp-tab-icon{display:none}}
@container (max-width:200px){.ldsp-tab{font-size:9px;padding:5px 3px}}
@media (max-width:340px){.ldsp-tabs{padding:6px 8px;gap:4px}.ldsp-tab{font-size:10px;padding:6px 6px;gap:2px}.ldsp-tab .ldsp-tab-icon{display:none}}
@media (max-width:280px){.ldsp-tabs{padding:5px 6px;gap:3px}.ldsp-tab{font-size:9px;padding:5px 4px}}
.ldsp-content{flex:1 1 auto;min-height:0;max-height:calc(var(--h) - 180px);overflow-y:auto;scrollbar-width:thin;scrollbar-color:transparent transparent}
.ldsp-content.scrolling{scrollbar-color:var(--scrollbar) transparent}
.ldsp-content::-webkit-scrollbar{width:6px;background:transparent}
.ldsp-content::-webkit-scrollbar-track{background:transparent}
.ldsp-content::-webkit-scrollbar-thumb{background:transparent;border-radius:4px;transition:background .3s}
.ldsp-content.scrolling::-webkit-scrollbar-thumb{background:var(--scrollbar)}
.ldsp-content::-webkit-scrollbar-button{width:0;height:0;display:none}
.ldsp-section{display:none;padding:10px}
.ldsp-section.active{display:block;animation:enter var(--dur) var(--ease-out)}
@keyframes enter{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
.ldsp-ring{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;background:var(--bg-card);border-radius:var(--r-md);margin-bottom:10px;position:relative;overflow:hidden;border:1px solid var(--border);gap:12px}
.ldsp-ring::before{content:'';position:absolute;inset:0;background:radial-gradient(circle at 50% 0%,rgba(107,140,239,.08) 0%,transparent 70%);pointer-events:none}
.ldsp-ring-stat{display:flex;flex-direction:column;align-items:center;justify-content:center;min-width:50px;gap:4px;z-index:1}
.ldsp-ring-stat-val{font-size:18px;font-weight:800;letter-spacing:-.02em}
.ldsp-ring-stat-val.ok{color:var(--ok)}
.ldsp-ring-stat-val.fail{color:var(--err)}
.ldsp-ring-stat-lbl{font-size:9px;color:var(--txt-mut);font-weight:500;white-space:nowrap}
.ldsp-ring-center{display:flex;flex-direction:column;align-items:center;position:relative}
.ldsp-ring-wrap{position:relative;width:var(--ring);height:var(--ring)}
.ldsp-ring-wrap svg{transform:rotate(-90deg);width:100%;height:100%;overflow:visible}
.ldsp-ring-bg{fill:none;stroke:var(--bg-el);stroke-width:7}
.ldsp-ring-fill{fill:none;stroke:url(#ldsp-grad);stroke-width:7;stroke-linecap:round;transition:stroke-dashoffset 1s var(--ease)}
.ldsp-ring-fill.anim{animation:ring 1.5s var(--ease) forwards}
@keyframes ring{from{stroke-dashoffset:var(--circ)}to{stroke-dashoffset:var(--off)}}
.ldsp-ring-txt{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.ldsp-ring-val{font-size:clamp(12px,calc(var(--ring) * 0.2),18px);font-weight:800;background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;letter-spacing:-.02em}
.ldsp-ring-val.anim{animation:val 1s var(--ease-spring) .5s forwards;opacity:0}
@keyframes val{from{opacity:0;transform:scale(.6)}60%{transform:scale(1.1)}to{opacity:1;transform:scale(1)}}
.ldsp-ring-lbl{font-size:9px;color:var(--txt-mut);margin-top:2px;font-weight:500}
.ldsp-ring-lvl{font-size:12px;font-weight:700;margin-top:8px;padding:4px 14px;border-radius:12px;background-image:linear-gradient(90deg,#64748b 0%,#94a3b8 50%,#64748b 100%);background-size:200% 100%;background-position:0% 50%;color:#fff;box-shadow:0 2px 10px rgba(100,116,139,.35);letter-spacing:.03em;text-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform 2s ease;transform-style:preserve-3d;animation:lvl-shimmer 6s ease-in-out infinite;will-change:background-position}
.ldsp-ring-lvl:hover{transform:rotateY(360deg);animation-play-state:paused}
.ldsp-ring-lvl.lv1{background-image:linear-gradient(90deg,#64748b 0%,#94a3b8 50%,#64748b 100%);box-shadow:0 2px 10px rgba(100,116,139,.35);animation-duration:4s}
.ldsp-ring-lvl.lv2{background-image:linear-gradient(90deg,#3b82f6 0%,#60a5fa 50%,#3b82f6 100%);box-shadow:0 2px 10px rgba(59,130,246,.4);animation-duration:3.5s}
.ldsp-ring-lvl.lv3{background-image:linear-gradient(90deg,#5070d0 0%,#8aa4f4 30%,#5bb5a6 70%,#5070d0 100%);box-shadow:0 2px 12px rgba(107,140,239,.45);animation-duration:3s}
.ldsp-ring-lvl.lv4{background-image:linear-gradient(90deg,#f59e0b 0%,#fbbf24 25%,#f97316 50%,#ef4444 75%,#f59e0b 100%);box-shadow:0 2px 15px rgba(245,158,11,.5),0 0 20px rgba(249,115,22,.3);animation-duration:2.5s;animation-name:lvl-shimmer-gold}
@keyframes lvl-shimmer{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
@keyframes lvl-shimmer-gold{0%,100%{background-position:0% 50%;filter:brightness(1)}50%{background-position:100% 50%;filter:brightness(1.2)}}
.ldsp-confetti{position:absolute;width:100%;height:100%;top:0;left:0;pointer-events:none;overflow:visible;z-index:10}
.ldsp-confetti-piece{position:absolute;font-size:12px;opacity:0;top:42%;left:50%;transform-origin:center center;text-shadow:0 1px 3px rgba(0,0,0,.3)}
.ldsp-ring.complete.anim-done .ldsp-confetti-piece{animation:confetti-burst 2s cubic-bezier(.15,.8,.3,1) forwards}
@keyframes confetti-burst{0%{opacity:1;transform:translate(-50%,-50%) scale(0)}5%{opacity:1;transform:translate(-50%,-50%) scale(1.5)}25%{opacity:1;transform:translate(calc(var(--tx) * 1.2),calc(var(--ty) * 1.2)) rotate(calc(var(--rot) * 0.4)) scale(1.1)}100%{opacity:0;transform:translate(calc(var(--tx) + var(--drift)),calc(var(--ty) + 110px)) rotate(var(--rot)) scale(0.2)}}
.ldsp-ring-tip{font-size:11px;text-align:center;margin:12px 0 16px;padding:8px 14px;border-radius:20px;font-weight:600;letter-spacing:.02em}
.ldsp-ring-tip.ok{color:var(--ok);background:linear-gradient(135deg,var(--ok-bg),rgba(16,185,129,.05));border:1px solid rgba(16,185,129,.2)}
.ldsp-ring-tip.progress{color:var(--accent);background:linear-gradient(135deg,rgba(107,140,239,.1),rgba(6,182,212,.05));border:1px solid rgba(107,140,239,.2)}
.ldsp-ring-tip.max{color:var(--warn);background:linear-gradient(135deg,rgba(251,191,36,.1),rgba(249,115,22,.05));border:1px solid rgba(251,191,36,.25)}
.ldsp-item{display:flex;align-items:center;padding:8px 10px;margin-bottom:6px;background:var(--bg-card);border-radius:var(--r-sm);border-left:3px solid var(--border2);animation:item var(--dur) var(--ease-out) backwards;transition:background .15s,border-color .15s,transform .2s var(--ease);border:1px solid var(--border);border-left-width:3px}
.ldsp-item:nth-child(1){animation-delay:0ms}.ldsp-item:nth-child(2){animation-delay:25ms}.ldsp-item:nth-child(3){animation-delay:50ms}.ldsp-item:nth-child(4){animation-delay:75ms}.ldsp-item:nth-child(5){animation-delay:100ms}.ldsp-item:nth-child(6){animation-delay:125ms}.ldsp-item:nth-child(7){animation-delay:150ms}.ldsp-item:nth-child(8){animation-delay:175ms}.ldsp-item:nth-child(9){animation-delay:200ms}.ldsp-item:nth-child(10){animation-delay:225ms}.ldsp-item:nth-child(11){animation-delay:250ms}.ldsp-item:nth-child(12){animation-delay:275ms}
@keyframes item{from{opacity:0;transform:translateX(-15px)}to{opacity:1;transform:none}}
.ldsp-item:hover{background:var(--bg-hover);transform:translateX(4px);box-shadow:0 4px 12px rgba(0,0,0,.1)}
.ldsp-item.ok{border-left-color:var(--ok);background:linear-gradient(135deg,var(--ok-bg) 0%,transparent 100%)}
.ldsp-item.fail{border-left-color:var(--err);background:linear-gradient(135deg,var(--err-bg) 0%,transparent 100%)}
.ldsp-item-icon{font-size:12px;margin-right:8px;width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bg-el)}
.ldsp-item.ok .ldsp-item-icon{background:var(--ok-bg);color:var(--ok)}
.ldsp-item.fail .ldsp-item-icon{background:var(--err-bg);color:var(--err)}
.ldsp-item-name{flex:1;font-size:11px;color:var(--txt-sec);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
.ldsp-item.ok .ldsp-item-name{color:var(--ok)}
.ldsp-item-vals{display:flex;align-items:center;gap:3px;font-size:12px;font-weight:700;margin-left:8px}
.ldsp-item-cur{color:var(--txt);transition:color .2s}
.ldsp-item-cur.upd{animation:upd .7s var(--ease-spring)}
@keyframes upd{0%{transform:scale(1)}30%{transform:scale(1.3);background:var(--accent);color:#fff;border-radius:6px;padding:0 4px}100%{transform:scale(1)}}
.ldsp-item.ok .ldsp-item-cur{color:var(--ok)}
.ldsp-item.fail .ldsp-item-cur{color:var(--err)}
.ldsp-item-sep{color:var(--txt-mut);font-weight:400;opacity:.6}
.ldsp-item-req{color:var(--txt-mut);font-weight:500}
.ldsp-item-chg{font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700;margin-left:6px;animation:pop var(--dur) var(--ease-spring)}
@keyframes pop{from{transform:scale(0) rotate(-10deg);opacity:0}to{transform:scale(1) rotate(0);opacity:1}}
.ldsp-item-chg.up{background:var(--ok-bg);color:var(--ok);box-shadow:0 2px 8px rgba(16,185,129,.2)}
.ldsp-item-chg.down{background:var(--err-bg);color:var(--err);box-shadow:0 2px 8px rgba(244,63,94,.2)}
.ldsp-subtabs{display:flex;align-items:center;gap:6px;padding:6px 10px;overflow-x:auto;scrollbar-width:thin;scrollbar-color:var(--scrollbar) transparent;-webkit-overflow-scrolling:touch}
.ldsp-subtabs::-webkit-scrollbar{height:6px;background:var(--bg-el);border-radius:3px}
.ldsp-subtabs::-webkit-scrollbar-track{background:var(--bg-el);border-radius:3px}
.ldsp-subtabs::-webkit-scrollbar-thumb{background:var(--scrollbar);border-radius:3px;transition:background .3s}
.ldsp-subtabs::-webkit-scrollbar-thumb:hover{background:var(--accent)}
.ldsp-subtabs::-webkit-scrollbar-button{width:0;height:0;display:none}
.ldsp-subtab{padding:6px 12px;border:1px solid var(--border2);background:var(--bg-card);color:var(--txt-sec);border-radius:20px;font-size:10px;font-weight:600;white-space:nowrap;flex-shrink:0;transition:background .15s,color .15s,border-color .15s}
.ldsp-subtab:hover{border-color:var(--accent);color:var(--accent);background:rgba(107,140,239,.08);transform:translateY(-1px)}
.ldsp-subtab.active{background:var(--grad);border-color:transparent;color:#fff;box-shadow:0 4px 12px rgba(107,140,239,.25)}
.ldsp-chart{background:var(--bg-card);border-radius:var(--r-md);padding:12px;margin-bottom:10px;border:1px solid var(--border);position:relative;overflow:hidden}
.ldsp-chart::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.2}
.ldsp-chart:last-child{margin-bottom:0}
.ldsp-chart-title{font-size:12px;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:6px;color:var(--txt)}
.ldsp-chart-sub{font-size:10px;color:var(--txt-mut);font-weight:500;margin-left:auto}
.ldsp-spark-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.ldsp-spark-row:last-child{margin-bottom:0}
.ldsp-spark-lbl{width:55px;font-size:10px;color:var(--txt-sec);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
.ldsp-spark-bars{flex:1;display:flex;align-items:flex-end;gap:3px;height:24px}
.ldsp-spark-bar{flex:1;background:linear-gradient(180deg,var(--accent),var(--accent2));border-radius:3px 3px 0 0;min-height:3px;opacity:.35;position:relative;transition:opacity .2s,height .2s var(--ease)}
.ldsp-spark-bar:last-child{opacity:1}
.ldsp-spark-bar:hover{opacity:1;transform:scaleY(1.15);box-shadow:0 -4px 12px rgba(107,140,239,.3)}
.ldsp-spark-bar::after{content:attr(data-v);position:absolute;bottom:100%;left:50%;transform:translateX(-50%) translateY(5px);font-size:9px;background:var(--bg-el);padding:3px 6px;border-radius:4px;opacity:0;white-space:nowrap;pointer-events:none;border:1px solid var(--border2);box-shadow:0 4px 12px rgba(0,0,0,.2);transition:transform .15s var(--ease),opacity .15s}
.ldsp-spark-bar:hover::after{opacity:1;transform:translateX(-50%) translateY(-2px)}
.ldsp-spark-val{font-size:11px;font-weight:700;min-width:35px;text-align:right;color:var(--accent)}
.ldsp-date-labels{display:flex;justify-content:space-between;padding:8px 0 0 60px;margin-right:40px}
.ldsp-date-lbl{font-size:9px;color:var(--txt-mut);text-align:center;font-weight:500}
.ldsp-changes{margin-top:8px}
.ldsp-chg-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);transition:background .15s}
.ldsp-chg-row:hover{background:var(--bg-glass);margin:0 -6px;padding:8px 6px;border-radius:var(--r-xs)}
.ldsp-chg-row:last-child{border-bottom:none}
.ldsp-chg-name{font-size:11px;color:var(--txt-sec);flex:1;font-weight:500}
.ldsp-chg-cur{font-size:10px;color:var(--txt-mut);margin-right:8px}
.ldsp-chg-val{font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px}
.ldsp-chg-val.up{background:var(--ok-bg);color:var(--ok)}
.ldsp-chg-val.down{background:var(--err-bg);color:var(--err)}
.ldsp-chg-val.neu{background:var(--bg-el);color:var(--txt-mut)}
.ldsp-rd-stats{border-radius:var(--r-md);padding:14px;margin-bottom:10px;display:flex;align-items:center;gap:12px;border:1px solid var(--border)}
.ldsp-rd-stats-icon{font-size:32px;flex-shrink:0;filter:drop-shadow(0 2px 8px rgba(0,0,0,.2))}
.ldsp-rd-stats-info{flex:1}
.ldsp-rd-stats-val{font-size:18px;font-weight:800;background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-.02em}
.ldsp-rd-stats-lbl{font-size:10px;color:var(--txt-mut);margin-top:3px;font-weight:500}
.ldsp-rd-stats-badge{padding:5px 12px;border-radius:12px;font-size:10px;font-weight:700;transform:translateY(-1px);transition:transform .15s,box-shadow .15s}
.ldsp-track{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--bg-card);border-radius:var(--r-sm);margin-bottom:10px;font-size:10px;color:var(--txt-mut);border:1px solid var(--border);font-weight:500}
.ldsp-track-dot{width:8px;height:8px;border-radius:50%;background:var(--ok);animation:pulse 3s ease-in-out infinite;box-shadow:0 0 10px rgba(16,185,129,.4);will-change:opacity,transform}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1);box-shadow:0 0 10px rgba(16,185,129,.4)}50%{opacity:.7;transform:scale(.9);box-shadow:0 0 5px rgba(16,185,129,.2)}}
@keyframes gradient-shift{0%{background-position:0% center}50%{background-position:100% center}100%{background-position:0% center}}
.ldsp-rd-prog{background:var(--bg-card);border-radius:var(--r-md);padding:12px;margin-bottom:10px;border:1px solid var(--border)}
.ldsp-rd-prog-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.ldsp-rd-prog-title{font-size:11px;color:var(--txt-sec);font-weight:600}
.ldsp-rd-prog-val{font-size:12px;font-weight:700;color:var(--accent)}
.ldsp-rd-prog-bar{height:8px;background:var(--bg-el);border-radius:4px;overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,.1)}
.ldsp-rd-prog-fill{height:100%;border-radius:4px;transition:width .5s var(--ease);position:relative}
.ldsp-rd-prog-fill::after{content:'';position:absolute;top:0;left:0;right:0;height:50%;background:linear-gradient(180deg,rgba(255,255,255,.2) 0%,transparent 100%);border-radius:4px 4px 0 0}
.ldsp-rd-week{display:flex;justify-content:space-between;align-items:flex-end;height:55px;padding:0 4px;margin:12px 0 8px;gap:4px}
.ldsp-rd-day{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0}
.ldsp-rd-day-bar{width:100%;max-width:18px;background:linear-gradient(180deg,var(--accent) 0%,var(--accent2) 100%);border-radius:4px 4px 0 0;min-height:3px;position:relative;transition:opacity .2s,height .2s var(--ease)}
.ldsp-rd-day-bar:hover{transform:scaleX(1.2);box-shadow:0 -4px 15px rgba(91,181,166,.35)}
.ldsp-rd-day-bar::after{content:attr(data-t);position:absolute;bottom:100%;left:50%;transform:translateX(-50%) translateY(5px);background:var(--bg-el);padding:4px 8px;border-radius:6px;font-size:9px;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;margin-bottom:4px;border:1px solid var(--border2);box-shadow:0 4px 12px rgba(0,0,0,.2);transition:transform .15s var(--ease),opacity .15s}
.ldsp-rd-day-bar:hover::after{opacity:1;transform:translateX(-50%) translateY(0)}
.ldsp-rd-day-lbl{font-size:9px;color:var(--txt-mut);line-height:1;font-weight:500}
.ldsp-today-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:10px}
.ldsp-today-stat{background:var(--bg-card);border-radius:var(--r-md);padding:12px 10px;text-align:center;border:1px solid var(--border);position:relative;overflow:hidden;transition:background .15s,border-color .15s}
.ldsp-today-stat:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,.1)}
.ldsp-today-stat::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:var(--grad)}
.ldsp-today-stat-val{font-size:18px;font-weight:800;background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-.02em}
.ldsp-today-stat-lbl{font-size:10px;color:var(--txt-mut);margin-top:4px;font-weight:500}
.ldsp-time-info{font-size:10px;color:var(--txt-mut);text-align:center;padding:8px 10px;background:var(--bg-card);border-radius:var(--r-sm);margin-bottom:10px;border:1px solid var(--border);font-weight:500}
.ldsp-time-info span{color:var(--accent);font-weight:700}
.ldsp-year-heatmap{padding:10px 14px 10px 0;overflow-x:hidden;overflow-y:auto;max-height:320px;scrollbar-width:thin;scrollbar-color:transparent transparent}
.ldsp-year-heatmap.scrolling{scrollbar-color:var(--scrollbar) transparent}
.ldsp-year-heatmap::-webkit-scrollbar{width:6px;background:transparent}
.ldsp-year-heatmap::-webkit-scrollbar-track{background:transparent}
.ldsp-year-heatmap::-webkit-scrollbar-thumb{background:transparent;border-radius:4px;transition:background .3s}
.ldsp-year-heatmap.scrolling::-webkit-scrollbar-thumb{background:var(--scrollbar)}
.ldsp-year-heatmap::-webkit-scrollbar-button{width:0;height:0;display:none}
.ldsp-year-wrap{display:flex;flex-direction:column;gap:3px;width:100%;padding-right:6px}
.ldsp-year-row{display:flex;align-items:center;gap:4px;width:100%;position:relative}
.ldsp-year-month{width:28px;font-size:8px;font-weight:600;color:var(--txt-mut);text-align:right;flex-shrink:0;line-height:1;position:absolute;left:0;top:50%;transform:translateY(-50%)}
.ldsp-year-cells{display:grid;grid-template-columns:repeat(14,minmax(9px,1fr));gap:3px;width:100%;align-items:center;margin-left:32px}
.ldsp-year-cell{width:100%;aspect-ratio:1;border-radius:3px;background:var(--bg-card);border:1px solid var(--border);position:relative;transition:transform .15s var(--ease),box-shadow .15s}
.ldsp-year-cell:hover{transform:scale(1.6);box-shadow:0 4px 15px rgba(107,140,239,.4);border-color:var(--accent);z-index:10}
.ldsp-year-cell.l0{background:rgba(107,140,239,.1);border-color:rgba(107,140,239,.18)}
.ldsp-year-cell.l1{background:rgba(180,230,210,.35);border-color:rgba(180,230,210,.45)}
.ldsp-year-cell.l2{background:rgba(130,215,180,.5);border-color:rgba(130,215,180,.6)}
.ldsp-year-cell.l3{background:rgba(90,195,155,.65);border-color:rgba(90,195,155,.75)}
.ldsp-year-cell.l4{background:linear-gradient(135deg,#6dcfa5,#50c090);border-color:#6dcfa5;box-shadow:0 0 8px rgba(109,207,165,.4)}
.ldsp-year-cell.empty{background:0 0;border-color:transparent;cursor:default}
.ldsp-year-cell.empty:hover{transform:none;box-shadow:none}
.ldsp-year-tip{position:absolute;left:50%;transform:translateX(-50%);background:var(--bg-el);padding:5px 8px;border-radius:6px;font-size:9px;white-space:nowrap;opacity:0;pointer-events:none;border:1px solid var(--border2);z-index:1000;line-height:1.3;box-shadow:0 4px 15px rgba(0,0,0,.25);font-weight:500}
.ldsp-year-cell:hover .ldsp-year-tip{opacity:1}
.ldsp-year-cell .ldsp-year-tip{bottom:100%;margin-bottom:4px}
.ldsp-year-row:nth-child(-n+3) .ldsp-year-tip{bottom:auto;top:100%;margin-top:4px;margin-bottom:0}
.ldsp-year-cell:nth-child(13) .ldsp-year-tip,.ldsp-year-cell:nth-child(14) .ldsp-year-tip{left:auto;right:0;transform:translateX(0)}
.ldsp-heatmap-legend{display:flex;align-items:center;gap:6px;justify-content:center;font-size:9px;color:var(--txt-mut);padding:8px 0;font-weight:500}
.ldsp-heatmap-legend-cell{width:10px;height:10px;border-radius:2px;border:1px solid var(--border)}
.ldsp-empty,.ldsp-loading{text-align:center;padding:30px 16px;color:var(--txt-mut)}
.ldsp-empty-icon{font-size:36px;margin-bottom:12px;filter:drop-shadow(0 2px 8px rgba(0,0,0,.1))}
.ldsp-empty-txt{font-size:12px;line-height:1.7;font-weight:500}
.ldsp-spinner{width:28px;height:28px;border:3px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 10px;will-change:transform}
@keyframes spin{to{transform:rotate(360deg)}}
.ldsp-mini-loader{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:50px 20px;color:var(--txt-mut)}
.ldsp-mini-spin{width:32px;height:32px;border:3px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:14px;will-change:transform}
.ldsp-mini-txt{font-size:11px;font-weight:500}
.ldsp-toast{position:absolute;bottom:-55px;left:50%;transform:translateX(-50%) translateY(15px);background:var(--grad);color:#fff;padding:10px 18px;border-radius:20px;font-size:12px;font-weight:600;box-shadow:0 8px 30px rgba(107,140,239,.4);opacity:0;white-space:nowrap;display:flex;align-items:center;gap:8px;z-index:100000;transition:transform .3s var(--ease-spring),opacity .3s;will-change:transform,opacity}
.ldsp-toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.ldsp-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;z-index:100001;opacity:0;transition:opacity .3s var(--ease)}
.ldsp-modal-overlay.show{opacity:1}
.ldsp-modal{background:var(--bg-card);border-radius:var(--r-xl);padding:24px;max-width:340px;width:90%;box-shadow:var(--shadow-lg),var(--glow-accent);transform:scale(.9) translateY(30px);transition:transform .35s var(--ease-spring);border:1px solid var(--border);backdrop-filter:blur(20px)}
.ldsp-modal-overlay.show .ldsp-modal{transform:scale(1) translateY(0)}
.ldsp-modal-hdr{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.ldsp-modal-icon{font-size:28px;filter:drop-shadow(0 2px 8px rgba(0,0,0,.2))}
.ldsp-modal-title{font-size:17px;font-weight:700;letter-spacing:-.02em}
.ldsp-modal-body{font-size:13px;color:var(--txt-sec);line-height:1.7;margin-bottom:20px}
.ldsp-modal-body p{margin:0 0 10px}
.ldsp-modal-body ul{margin:10px 0;padding-left:0;list-style:none}
.ldsp-modal-body li{margin:6px 0;padding-left:24px;position:relative}
.ldsp-modal-body li::before{content:'';position:absolute;left:0;top:6px;width:6px;height:6px;background:var(--accent);border-radius:50%}
.ldsp-modal-body strong{color:var(--accent);font-weight:600}
.ldsp-modal-footer{display:flex;gap:12px}
.ldsp-modal-btn{flex:1;padding:12px 18px;border:none;border-radius:var(--r-md);font-size:13px;font-weight:600;transition:background .15s,transform .2s var(--ease)}
.ldsp-modal-btn.primary{background:var(--grad);color:#fff;box-shadow:0 4px 15px rgba(107,140,239,.3)}
.ldsp-modal-btn.primary:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(107,140,239,.4)}
.ldsp-modal-btn.primary:active{transform:translateY(0)}
.ldsp-modal-btn.secondary{background:var(--bg-el);color:var(--txt-sec);border:1px solid var(--border2)}
.ldsp-modal-btn.secondary:hover{background:var(--bg-hover);border-color:var(--border-accent)}
.ldsp-modal-btn.danger{background:var(--grad-warm);color:#fff;box-shadow:0 4px 15px rgba(224,122,141,.3)}
.ldsp-modal-btn.danger:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(224,122,141,.4)}
.ldsp-modal-btn.danger:active{transform:translateY(0)}
.ldsp-modal-note{margin-top:14px;font-size:11px;color:var(--txt-mut);text-align:center;font-weight:500}
.ldsp-confirm-overlay{position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;box-sizing:border-box;background:rgba(18,19,26,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:center;z-index:20;opacity:0;pointer-events:none;transition:opacity .3s var(--ease);border-radius:inherit;margin:0;padding:0 20px}
.ldsp-confirm-overlay.show{opacity:1;pointer-events:auto}
.ldsp-confirm-box{background:linear-gradient(145deg,var(--bg-card),var(--bg));border-radius:var(--r-lg);padding:24px 20px;width:100%;max-width:260px;box-shadow:var(--shadow-lg),0 0 40px rgba(224,122,141,.1);transform:scale(.92) translateY(20px);transition:transform .35s var(--ease-spring);border:1px solid var(--border2);position:relative;overflow:hidden;margin:0 auto}
.ldsp-confirm-box::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--grad-warm);opacity:.8}
.ldsp-confirm-overlay.show .ldsp-confirm-box{transform:scale(1) translateY(0)}
.ldsp-confirm-icon{text-align:center;font-size:40px;margin-bottom:14px;filter:drop-shadow(0 4px 8px rgba(224,122,141,.3));animation:confirm-icon-bounce .5s var(--ease-spring) .1s both}
@keyframes confirm-icon-bounce{0%{transform:scale(0) rotate(-20deg);opacity:0}60%{transform:scale(1.15) rotate(5deg)}100%{transform:scale(1) rotate(0);opacity:1}}
.ldsp-confirm-title{text-align:center;font-size:16px;font-weight:700;margin-bottom:10px;color:#ef4444;letter-spacing:-.02em}
.ldsp-confirm-msg{text-align:center;font-size:12px;color:var(--txt-sec);line-height:1.7;margin-bottom:20px;padding:0 4px}
.ldsp-confirm-btns{display:flex;gap:10px}
.ldsp-confirm-btn{flex:1;padding:11px 14px;border:none;border-radius:var(--r-sm);font-size:12px;font-weight:600;transition:background .15s,transform .15s,box-shadow .15s,border-color .15s;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
.ldsp-confirm-btn.cancel{background:var(--bg-el);color:var(--txt-sec);border:1px solid var(--border2)}
@media (hover:hover){.ldsp-confirm-btn.cancel:hover{background:var(--bg-hover);border-color:var(--border-accent)}}
.ldsp-confirm-btn.confirm{background:var(--grad-warm);color:#fff;box-shadow:0 4px 15px rgba(224,122,141,.3);border:none}
@media (hover:hover){.ldsp-confirm-btn.confirm:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(224,122,141,.4)}}
.ldsp-confirm-btn:active{transform:scale(.96)}
@media (max-width:480px){.ldsp-confirm-box{padding:20px 16px;max-width:240px}.ldsp-confirm-icon{font-size:36px;margin-bottom:12px}.ldsp-confirm-title{font-size:14px}.ldsp-confirm-msg{font-size:11px;margin-bottom:16px}.ldsp-confirm-btn{padding:10px 12px;font-size:11px}}
@media (max-width:320px){.ldsp-confirm-overlay{padding:0 12px}.ldsp-confirm-box{padding:16px 12px;max-width:220px}.ldsp-confirm-icon{font-size:32px;margin-bottom:10px}.ldsp-confirm-title{font-size:13px}.ldsp-confirm-msg{font-size:10px;margin-bottom:14px;line-height:1.6}.ldsp-confirm-btns{gap:8px}.ldsp-confirm-btn{padding:9px 10px;font-size:10px}}
.ldsp-no-chg{text-align:center;padding:18px;color:var(--txt-mut);font-size:11px;font-weight:500}
.ldsp-lb-hdr{display:flex;align-items:center;justify-content:space-between;padding:12px;background:var(--bg-card);border-radius:var(--r-md);margin-bottom:10px;border:1px solid var(--border)}
.ldsp-lb-status{display:flex;align-items:center;gap:10px}
.ldsp-lb-dot{width:10px;height:10px;border-radius:50%;background:var(--txt-mut);transition:background .2s}
.ldsp-lb-dot.joined{background:var(--ok);box-shadow:0 0 10px rgba(16,185,129,.4)}
.ldsp-lb-btn{padding:8px 14px;border:none;border-radius:20px;font-size:11px;font-weight:600;transition:background .15s,color .15s,transform .2s var(--ease)}
.ldsp-lb-btn.primary{background:var(--grad);color:#fff;box-shadow:0 4px 12px rgba(107,140,239,.25)}
.ldsp-lb-btn.primary:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(107,140,239,.4)}
.ldsp-lb-btn.primary:active{transform:translateY(0)}
.ldsp-lb-btn.secondary{background:var(--bg-el);color:var(--txt-sec);border:1px solid var(--border2)}
.ldsp-lb-btn.secondary:hover{background:var(--bg-hover);border-color:var(--border-accent)}
.ldsp-lb-btn.danger{background:var(--err-bg);color:var(--err);border:1px solid rgba(244,63,94,.3)}
.ldsp-lb-btn.danger:hover{background:var(--err);color:#fff;box-shadow:0 4px 12px rgba(244,63,94,.3)}
.ldsp-lb-btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
.ldsp-rank-list{display:flex;flex-direction:column;gap:6px}
.ldsp-rank-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:var(--r-md);animation:item var(--dur) var(--ease-out) backwards;border:1px solid var(--border);transition:background .15s,border-color .15s,transform .2s var(--ease)}
.ldsp-rank-item:hover{background:var(--bg-hover);transform:translateX(4px);box-shadow:0 4px 15px rgba(0,0,0,.1)}
.ldsp-rank-item.t1{background:linear-gradient(135deg,rgba(255,215,0,.12) 0%,rgba(255,185,0,.05) 100%);border:1px solid rgba(255,215,0,.35);box-shadow:0 4px 20px rgba(255,215,0,.15)}
.ldsp-rank-item.t2{background:linear-gradient(135deg,rgba(192,192,192,.12) 0%,rgba(160,160,160,.05) 100%);border:1px solid rgba(192,192,192,.35)}
.ldsp-rank-item.t3{background:linear-gradient(135deg,rgba(205,127,50,.12) 0%,rgba(181,101,29,.05) 100%);border:1px solid rgba(205,127,50,.35)}
.ldsp-rank-item.me{border-left:3px solid var(--accent);box-shadow:0 0 15px rgba(107,140,239,.1)}
.ldsp-rank-num{width:28px;height:28px;border-radius:10px;background:var(--bg-el);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:var(--txt-sec);flex-shrink:0}
.ldsp-rank-item.t1 .ldsp-rank-num{background:linear-gradient(135deg,#ffd700 0%,#ffb700 100%);color:#1a1a1a;font-size:14px;box-shadow:0 4px 12px rgba(255,215,0,.4)}
.ldsp-rank-item.t2 .ldsp-rank-num{background:linear-gradient(135deg,#e0e0e0 0%,#b0b0b0 100%);color:#1a1a1a;box-shadow:0 4px 12px rgba(192,192,192,.4)}
.ldsp-rank-item.t3 .ldsp-rank-num{background:linear-gradient(135deg,#cd7f32 0%,#b5651d 100%);color:#fff;box-shadow:0 4px 12px rgba(205,127,50,.4)}
.ldsp-rank-avatar{width:32px;height:32px;border-radius:10px;border:2px solid var(--border2);flex-shrink:0;background:var(--bg-el);transition:transform .2s var(--ease),border-color .15s}
.ldsp-rank-item:hover .ldsp-rank-avatar{transform:scale(1.05)}
.ldsp-rank-item.t1 .ldsp-rank-avatar{border-color:#ffd700;box-shadow:0 0 12px rgba(255,215,0,.3)}
.ldsp-rank-item.t2 .ldsp-rank-avatar{border-color:#c0c0c0}
.ldsp-rank-item.t3 .ldsp-rank-avatar{border-color:#cd7f32}
.ldsp-rank-info{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 5px}
.ldsp-rank-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ldsp-rank-display-name{font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:85px}
.ldsp-rank-username{font-size:10px;color:var(--txt-mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
.ldsp-rank-name-only{font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ldsp-rank-me-tag{font-size:10px;color:var(--accent);margin-left:3px;font-weight:600;background:rgba(107,140,239,.1);padding:1px 6px;border-radius:8px}
.ldsp-rank-time{font-size:13px;font-weight:800;color:var(--accent);white-space:nowrap;letter-spacing:-.02em}
.ldsp-rank-item.t1 .ldsp-rank-time{color:#ffc107;text-shadow:0 0 10px rgba(255,193,7,.3)}
.ldsp-rank-item.t2 .ldsp-rank-time{color:#b8b8b8}
.ldsp-rank-item.t3 .ldsp-rank-time{color:#cd7f32}
.ldsp-lb-empty{text-align:center;padding:40px 20px;color:var(--txt-mut)}
.ldsp-lb-empty-icon{font-size:48px;margin-bottom:14px;filter:drop-shadow(0 2px 10px rgba(0,0,0,.1))}
.ldsp-lb-empty-txt{font-size:12px;line-height:1.7;font-weight:500}
.ldsp-lb-login{text-align:center;padding:40px 20px}
.ldsp-lb-login-icon{font-size:56px;margin-bottom:16px;filter:drop-shadow(0 4px 15px rgba(0,0,0,.15))}
.ldsp-lb-login-title{font-size:15px;font-weight:700;margin-bottom:8px;letter-spacing:-.01em}
.ldsp-lb-login-desc{font-size:11px;color:var(--txt-mut);margin-bottom:20px;line-height:1.7;font-weight:500}
.ldsp-lb-period{font-size:10px;color:var(--txt-mut);text-align:center;padding:8px 10px;background:var(--bg-card);border-radius:var(--r-sm);margin-bottom:10px;display:flex;justify-content:center;align-items:center;gap:10px;flex-wrap:wrap;border:1px solid var(--border);font-weight:500}
.ldsp-lb-period span{color:var(--accent);font-weight:700}
.ldsp-lb-period .ldsp-update-rule{font-size:9px;opacity:.8}
.ldsp-lb-refresh{background:var(--bg-el);border:none;font-size:11px;padding:4px 8px;border-radius:6px;transition:background .15s,opacity .2s;opacity:.8}
.ldsp-lb-refresh:hover{opacity:1;background:var(--bg-hover);transform:scale(1.05)}
.ldsp-lb-refresh:active{transform:scale(.95)}
.ldsp-lb-refresh.spinning{animation:ldsp-spin 1s linear infinite}
.ldsp-lb-refresh:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
@keyframes ldsp-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
.ldsp-my-rank{display:flex;align-items:center;justify-content:space-between;padding:14px;background:var(--grad);border-radius:var(--r-md);margin-bottom:10px;color:#fff;position:relative;overflow:hidden;box-shadow:0 8px 25px rgba(107,140,239,.3)}
.ldsp-my-rank::before{content:'';position:absolute;top:-50%;right:-20%;width:100px;height:100px;background:radial-gradient(circle,rgba(255,255,255,.15) 0%,transparent 70%);pointer-events:none}
.ldsp-my-rank.not-in-top{background:linear-gradient(135deg,#52525b 0%,#3f3f46 100%);box-shadow:0 8px 25px rgba(0,0,0,.2)}
.ldsp-my-rank-lbl{font-size:11px;opacity:.9;font-weight:500}
.ldsp-my-rank-val{font-size:20px;font-weight:800;letter-spacing:-.02em;text-shadow:0 2px 8px rgba(0,0,0,.2)}
.ldsp-my-rank-time{font-size:12px;opacity:.95;font-weight:600}
.ldsp-not-in-top-hint{font-size:10px;opacity:.7;margin-left:5px}
.ldsp-join-prompt{background:var(--bg-card);border-radius:var(--r-md);padding:24px 20px;text-align:center;margin-bottom:10px;border:1px solid var(--border);position:relative;overflow:hidden}
.ldsp-join-prompt::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--grad)}
.ldsp-join-prompt-icon{font-size:44px;margin-bottom:12px;filter:drop-shadow(0 2px 10px rgba(0,0,0,.15))}
.ldsp-join-prompt-title{font-size:14px;font-weight:700;margin-bottom:6px;letter-spacing:-.01em}
.ldsp-join-prompt-desc{font-size:11px;color:var(--txt-mut);line-height:1.7;margin-bottom:16px;font-weight:500}
.ldsp-privacy-note{font-size:9px;color:var(--txt-mut);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:5px;font-weight:500}
/* 面板手动调整大小 - 仅桌面端 */
@media (hover:hover) and (pointer:fine){
#ldsp-panel:not(.collapsed){resize:none}
.ldsp-resize-handle{position:absolute;z-index:15;opacity:0;transition:opacity .2s}
#ldsp-panel:hover .ldsp-resize-handle{opacity:1}
.ldsp-resize-e{right:0;top:1em;bottom:1em;width:6px;cursor:e-resize}
.ldsp-resize-s{bottom:0;left:1em;right:1em;height:6px;cursor:s-resize}
.ldsp-resize-se{right:0;bottom:0;width:14px;height:14px;cursor:se-resize}
.ldsp-resize-se::before{content:'';position:absolute;right:3px;bottom:3px;width:8px;height:8px;border-right:2px solid var(--txt-mut);border-bottom:2px solid var(--txt-mut);opacity:.5;transition:opacity .2s}
#ldsp-panel:hover .ldsp-resize-se::before{opacity:.8}
.ldsp-resize-handle:hover::before{opacity:1}
#ldsp-panel.resizing .ldsp-resize-handle{opacity:1}
#ldsp-panel.resizing,#ldsp-panel.resizing *{transition:none!important;user-select:none!important}
}
@media (prefers-reduced-motion:reduce){#ldsp-panel,#ldsp-panel *{animation:none!important;transition:none!important}#ldsp-panel .ldsp-spinner,#ldsp-panel .ldsp-mini-spin,#ldsp-panel .ldsp-lb-refresh.spinning{animation:spin 1.5s linear infinite!important}}
/* 动态响应式布局 - 面板尺寸通过JS动态计算,CSS主要处理内部组件适配 */
/* 内容区使用CSS变量控制最大高度,确保不超出面板 */
.ldsp-content{max-height:calc(var(--h) - 160px)}
/* 小屏幕优化:当视口较小时隐藏部分非必要元素 */
@media (max-height:550px){.ldsp-user-actions{gap:4px;margin-top:0}.ldsp-action-btn{padding:4px 6px;font-size:9px}.ldsp-reading::after{font-size:7px;bottom:-10px}.ldsp-hdr{min-height:auto}}
@media (max-height:400px){.ldsp-user-actions{display:none}}
@media (max-height:450px){.ldsp-user{padding:5px var(--pd) 14px}.ldsp-tabs{padding:5px 8px}.ldsp-section{padding:5px}}
/* 窄屏幕优化 */
@media (max-width:360px){#ldsp-panel.collapsed{width:40px!important;height:40px!important;min-width:40px!important;min-height:40px!important;max-height:40px!important}.ldsp-hdr-info{gap:4px}.ldsp-site-icon{width:20px;height:20px}.ldsp-site-ver{font-size:7px}.ldsp-title{font-size:11px}.ldsp-hdr-btns button{width:24px;height:24px}.ldsp-user-actions{flex-direction:column}.ldsp-action-btn{flex:1 1 100%}}
.ldsp-action-btn{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;background:linear-gradient(135deg,rgba(107,140,239,.08),rgba(90,125,224,.12));border:1px solid rgba(107,140,239,.2);border-radius:8px;font-size:10px;color:var(--accent);transition:background .15s,border-color .15s,transform .15s,box-shadow .15s;font-weight:600;white-space:nowrap;flex:1 1 0;min-width:0;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
@media (hover:hover){.ldsp-action-btn:hover{background:linear-gradient(135deg,rgba(107,140,239,.15),rgba(90,125,224,.2));border-color:var(--accent);box-shadow:0 4px 12px rgba(107,140,239,.18)}}
.ldsp-action-btn:active{background:linear-gradient(135deg,rgba(107,140,239,.18),rgba(90,125,224,.24));transform:scale(.97)}
.ldsp-action-btn:only-child{flex:0 1 auto}
.ldsp-action-btn .ldsp-action-icon{flex-shrink:0}
.ldsp-action-btn .ldsp-action-text{overflow:hidden;text-overflow:ellipsis}
@media (max-width:320px){#ldsp-panel{--w:240px}#ldsp-panel.collapsed{width:34px!important;height:34px!important;min-width:34px!important;min-height:34px!important;max-height:34px!important;border-radius:8px}#ldsp-panel.collapsed .ldsp-toggle-logo{width:18px;height:18px}.ldsp-hdr{padding:5px 6px;gap:3px;min-height:36px}.ldsp-hdr-info{gap:3px}.ldsp-site-icon{width:16px;height:16px;border-radius:4px;border-width:1px}.ldsp-site-ver{display:none}.ldsp-title{font-size:9px}.ldsp-app-name{display:none}.ldsp-hdr-btns{gap:2px}.ldsp-hdr-btns button{width:20px;height:20px;font-size:9px;border-radius:4px}.ldsp-user-actions{flex-direction:column}.ldsp-action-btn{flex:1 1 100%;min-width:0}}
.ldsp-logout-btn,.ldsp-ticket-btn,.ldsp-melon-btn{flex:0 0 auto;min-width:auto;padding:5px 8px}
.ldsp-logout-btn{background:linear-gradient(135deg,rgba(239,68,68,.06),rgba(220,38,38,.08));border-color:rgba(239,68,68,.15);color:rgba(239,68,68,.7)}
.ldsp-logout-btn:hover{background:linear-gradient(135deg,rgba(239,68,68,.12),rgba(220,38,38,.16));border-color:rgba(239,68,68,.3);color:#ef4444}
.ldsp-login-btn{flex:1 1 100%;background:linear-gradient(135deg,rgba(212,168,83,.15),rgba(196,147,57,.2));border-color:rgba(212,168,83,.3);color:var(--warn);animation:login-pulse 2.5s ease-in-out infinite}
.ldsp-login-btn:hover{background:linear-gradient(135deg,rgba(212,168,83,.25),rgba(196,147,57,.3));border-color:var(--warn)}
@keyframes login-pulse{0%,100%{box-shadow:0 0 0 0 rgba(212,168,83,.3)}50%{box-shadow:0 0 12px 2px rgba(212,168,83,.2)}}
.ldsp-ticket-btn .ldsp-ticket-badge{background:var(--err);color:#fff;font-size:8px;padding:2px 5px;border-radius:8px;margin-left:2px;font-weight:700;animation:pulse 3s ease infinite}
.ldsp-ticket-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background:var(--bg);border-radius:0 0 var(--r-lg) var(--r-lg);z-index:10;display:none;flex-direction:column;overflow:hidden}
.ldsp-ticket-overlay.show{display:flex}
.ldsp-ticket-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-card);border-bottom:1px solid var(--border);flex-shrink:0}
.ldsp-ticket-title{font-size:13px;font-weight:700;display:flex;align-items:center;gap:6px;color:var(--txt)}
.ldsp-ticket-close{width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:var(--bg-el);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--txt-sec);transition:background .15s,color .15s}
.ldsp-ticket-close:hover{background:var(--err-bg);color:var(--err);border-color:var(--err)}
.ldsp-ticket-tabs{display:flex;border-bottom:1px solid var(--border);padding:0 10px;background:var(--bg-card);flex-shrink:0}
.ldsp-ticket-tab{padding:8px 12px;font-size:10px;font-weight:600;color:var(--txt-mut);border-bottom:2px solid transparent;transition:color .15s,border-color .15s}
.ldsp-ticket-tab.active{color:var(--accent);border-color:var(--accent)}
.ldsp-ticket-tab:hover:not(.active){color:var(--txt-sec)}
.ldsp-ticket-body{flex:1;overflow-y:auto;padding:12px;background:var(--bg);display:flex;flex-direction:column}
.ldsp-ticket-body.detail-mode{padding:0;overflow:hidden}
.ldsp-ticket-empty{text-align:center;padding:30px 16px;color:var(--txt-mut)}
.ldsp-ticket-empty-icon{font-size:36px;margin-bottom:10px}
.ldsp-ticket-list{display:flex;flex-direction:column;gap:8px}
.ldsp-ticket-item{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:10px;cursor:pointer;transition:background .15s,border-color .15s}
.ldsp-ticket-item:hover{background:var(--bg-hover);transform:translateX(3px)}
.ldsp-ticket-item.has-reply{border-left:3px solid #ef4444;animation:pulse-border-red 3s ease infinite;background:rgba(239,68,68,.05)}
.ldsp-ticket-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:5px}
.ldsp-ticket-item-type{font-size:10px;color:var(--txt-sec)}
.ldsp-ticket-item-status{font-size:9px;padding:2px 5px;border-radius:4px}
.ldsp-ticket-item-status.open{background:var(--ok-bg);color:var(--ok)}
.ldsp-ticket-item-status.closed{background:var(--bg-el);color:var(--txt-mut)}
.ldsp-ticket-item-title{font-size:11px;font-weight:600;color:var(--txt);margin-bottom:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ldsp-ticket-item-meta{font-size:9px;color:var(--txt-mut);display:flex;gap:6px}
.ldsp-ticket-form{display:flex;flex-direction:column;gap:10px}
.ldsp-ticket-form-group{display:flex;flex-direction:column;gap:5px}
.ldsp-ticket-label{font-size:10px;font-weight:600;color:var(--txt-sec)}
.ldsp-ticket-types{display:flex;gap:6px;flex-wrap:wrap}
.ldsp-ticket-type{flex:1;min-width:80px;padding:8px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-sm);text-align:center;cursor:pointer;transition:background .15s,border-color .15s}
.ldsp-ticket-type:hover{border-color:var(--accent)}
.ldsp-ticket-type.selected{border-color:var(--accent);background:rgba(107,140,239,.1)}
.ldsp-ticket-type-icon{font-size:16px;display:block;margin-bottom:3px}
.ldsp-ticket-type-label{font-size:10px;color:var(--txt)}
.ldsp-ticket-input{padding:8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:11px;color:var(--txt)}
.ldsp-ticket-input:focus{border-color:var(--accent);outline:none}
.ldsp-ticket-textarea{padding:8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:11px;color:var(--txt);min-height:80px;resize:vertical}
.ldsp-ticket-textarea:focus{border-color:var(--accent);outline:none}
.ldsp-ticket-submit{padding:10px;background:var(--grad);color:#fff;border:none;border-radius:var(--r-sm);font-size:11px;font-weight:600;cursor:pointer;transition:opacity .15s,transform .2s}
.ldsp-ticket-submit:hover{box-shadow:0 4px 12px rgba(107,140,239,.3)}
.ldsp-ticket-submit:disabled{opacity:.5;cursor:not-allowed}
.ldsp-ticket-detail{display:flex;flex-direction:column;flex:1;min-height:0;background:var(--bg)}
.ldsp-ticket-detail-top{padding:10px 12px;border-bottom:1px solid var(--border);background:var(--bg-card);flex-shrink:0}
.ldsp-ticket-back{display:inline-flex;align-items:center;gap:4px;padding:5px 8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:10px;color:var(--txt-sec);transition:background .15s,color .15s}
.ldsp-ticket-back:hover{background:var(--bg-hover);color:var(--txt)}
.ldsp-ticket-detail-header{margin-top:6px}
.ldsp-ticket-detail-title{font-size:12px;font-weight:600;color:var(--txt);line-height:1.4;word-break:break-word}
.ldsp-ticket-detail-meta{display:flex;flex-wrap:wrap;gap:5px;font-size:9px;color:var(--txt-mut);margin-top:5px}
.ldsp-ticket-detail-meta span{background:var(--bg-el);padding:2px 5px;border-radius:3px}
.ldsp-ticket-messages{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:8px;min-height:0}
.ldsp-ticket-reply{max-width:85%;padding:8px 10px;border-radius:var(--r-sm);font-size:11px;line-height:1.4;word-break:break-word}
.ldsp-ticket-reply.user{background:linear-gradient(135deg,rgba(107,140,239,.12),rgba(90,125,224,.08));border:1px solid rgba(107,140,239,.2);margin-left:auto;border-bottom-right-radius:3px}
.ldsp-ticket-reply.admin{background:var(--bg-card);border:1px solid var(--border);margin-right:auto;border-bottom-left-radius:3px}
.ldsp-ticket-reply-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:9px;color:var(--txt-mut)}
.ldsp-ticket-reply-author{font-weight:600}
.ldsp-ticket-reply.admin .ldsp-ticket-reply-author{color:var(--ok)}
.ldsp-ticket-reply-content{color:var(--txt);white-space:pre-wrap}
.ldsp-ticket-input-area{border-top:1px solid var(--border);padding:10px 12px;background:var(--bg-card);flex-shrink:0}
.ldsp-ticket-reply-form{display:flex;gap:6px;align-items:center}
.ldsp-ticket-reply-input{flex:1;padding:6px 8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:11px;resize:none;min-height:32px;max-height:50px;color:var(--txt)}
.ldsp-ticket-reply-input:focus{border-color:var(--accent);outline:none}
.ldsp-ticket-reply-btn{padding:6px 12px;background:var(--grad);color:#fff;border:none;border-radius:var(--r-sm);font-size:10px;font-weight:600;transition:opacity .15s,transform .2s;flex-shrink:0;height:32px}
.ldsp-ticket-reply-btn:hover{box-shadow:0 4px 12px rgba(107,140,239,.3)}
.ldsp-ticket-reply-btn:disabled{opacity:.5;cursor:not-allowed}
.ldsp-ticket-closed-hint{text-align:center;color:var(--txt-mut);font-size:10px;padding:10px}
/* 吃瓜助手样式 */
.ldsp-melon-btn{background:linear-gradient(135deg,rgba(74,222,128,.08),rgba(34,197,94,.12));border-color:rgba(74,222,128,.2);color:rgba(34,197,94,.85)}
.ldsp-melon-btn:hover{background:linear-gradient(135deg,rgba(74,222,128,.15),rgba(34,197,94,.2));border-color:rgba(74,222,128,.35);color:#22c55e}
.ldsp-melon-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background:var(--bg);border-radius:0 0 var(--r-lg) var(--r-lg);z-index:10;display:none;flex-direction:column;overflow:hidden}
.ldsp-melon-overlay.show{display:flex}
.ldsp-melon-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-card);border-bottom:1px solid var(--border);flex-shrink:0}
.ldsp-melon-title{font-size:13px;font-weight:700;display:flex;align-items:center;gap:6px;color:var(--txt)}
.ldsp-melon-close{width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:var(--bg-el);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--txt-sec);transition:background .15s,color .15s;cursor:pointer}
.ldsp-melon-close:hover{background:var(--err-bg);color:var(--err);border-color:var(--err)}
.ldsp-melon-tabs{display:flex;border-bottom:1px solid var(--border);padding:0 10px;background:var(--bg-card);flex-shrink:0}
.ldsp-melon-tab{padding:8px 12px;font-size:10px;font-weight:600;color:var(--txt-mut);border-bottom:2px solid transparent;transition:color .15s,border-color .15s;cursor:pointer}
.ldsp-melon-tab.active{color:var(--accent);border-color:var(--accent)}
.ldsp-melon-tab:hover:not(.active){color:var(--txt-sec)}
.ldsp-melon-body{flex:1;overflow-y:auto;padding:12px;background:var(--bg);display:flex;flex-direction:column;gap:10px}
.ldsp-melon-info{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:10px;font-size:11px}
.ldsp-melon-info-title{font-weight:600;color:var(--txt);margin-bottom:6px;display:flex;align-items:center;gap:5px}
.ldsp-melon-info-row{display:flex;align-items:center;gap:6px;color:var(--txt-sec);font-size:10px;margin-top:4px}
.ldsp-melon-info-label{color:var(--txt-mut);min-width:50px}
.ldsp-melon-info-value{color:var(--txt);font-weight:500}
.ldsp-melon-range{display:flex;align-items:center;gap:8px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:10px;flex-wrap:wrap}
.ldsp-melon-range-label{font-size:10px;color:var(--txt-sec);white-space:nowrap}
.ldsp-melon-range-input{width:70px;padding:5px 8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:11px;color:var(--txt);text-align:center}
.ldsp-melon-range-input:focus{border-color:var(--accent);outline:none}
.ldsp-melon-range-sep{color:var(--txt-mut);font-size:10px}
.ldsp-melon-range-hint{font-size:9px;color:var(--txt-mut);margin-left:auto}
/* 模式选择器 - 卡片式设计 */
.ldsp-melon-mode-selector{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:10px}
.ldsp-melon-mode-label{font-size:10px;color:var(--txt-sec);margin-bottom:8px;display:block}
.ldsp-melon-mode-cards{display:flex;gap:8px}
.ldsp-melon-mode-card{flex:1;display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-el);border:2px solid var(--border);border-radius:var(--r-md);cursor:pointer;transition:all .2s}
.ldsp-melon-mode-card:hover{border-color:var(--txt-mut);background:var(--bg)}
.ldsp-melon-mode-card.active{border-color:var(--accent);background:rgba(107,140,239,.08)}
.ldsp-melon-mode-card input{display:none}
.ldsp-melon-mode-card-icon{font-size:20px;flex-shrink:0}
.ldsp-melon-mode-card-content{flex:1;min-width:0}
.ldsp-melon-mode-card-title{font-size:11px;font-weight:600;color:var(--txt);margin-bottom:2px}
.ldsp-melon-mode-card-desc{font-size:9px;color:var(--txt-mut)}
.ldsp-melon-mode-card.active .ldsp-melon-mode-card-title{color:var(--accent)}
.ldsp-melon-actions{display:flex;gap:8px}
.ldsp-melon-btn-summarize{flex:1;padding:10px;background:linear-gradient(135deg,#22c55e,#16a34a);color:#fff;border:none;border-radius:var(--r-sm);font-size:12px;font-weight:600;cursor:pointer;transition:opacity .15s,transform .2s,box-shadow .2s;display:flex;align-items:center;justify-content:center;gap:6px}
.ldsp-melon-btn-summarize:hover{box-shadow:0 4px 16px rgba(34,197,94,.35);transform:translateY(-1px)}
.ldsp-melon-btn-summarize:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none}
.ldsp-melon-btn-summarize.loading{background:linear-gradient(135deg,#6b8cef,#5a7de0)}
/* 输出区域 */
.ldsp-melon-output-wrapper{display:flex;flex-direction:column;flex:1;min-height:0}
.ldsp-melon-output-header{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--bg-card);border:1px solid var(--border);border-bottom:none;border-radius:var(--r-md) var(--r-md) 0 0}
.ldsp-melon-output-title{font-size:10px;font-weight:600;color:var(--txt-sec)}
.ldsp-melon-output-actions{display:flex;gap:6px}
.ldsp-melon-resize-btn{display:flex;align-items:center;gap:4px;padding:4px 8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:10px;color:var(--txt-sec);cursor:pointer;transition:all .15s}
.ldsp-melon-resize-btn:hover{background:rgba(107,140,239,.1);border-color:var(--accent);color:var(--accent)}
.ldsp-melon-copy-btn{display:flex;align-items:center;gap:4px;padding:4px 8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:10px;color:var(--txt-sec);cursor:pointer;transition:all .15s}
.ldsp-melon-copy-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.ldsp-melon-output{flex:1;min-height:120px;max-height:200px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:12px;overflow-y:auto;font-size:11px;line-height:1.6;color:var(--txt);transition:max-height .3s ease}
.ldsp-melon-output.expanded{max-height:400px}
.ldsp-melon-output-header+.ldsp-melon-output{border-radius:0 0 var(--r-md) var(--r-md)}
.ldsp-melon-output:empty::before{content:'点击「立即吃瓜」获取帖子摘要...';color:var(--txt-mut);font-style:italic}
.ldsp-melon-output-content{min-height:20px}
.ldsp-melon-cursor{display:inline-block;color:var(--accent);animation:ldsp-blink 1s infinite}
@keyframes ldsp-blink{0%,50%{opacity:1}51%,100%{opacity:0}}
/* Markdown 渲染 */
.ldsp-melon-markdown{font-size:11px;line-height:1.7}
.ldsp-melon-markdown .ldsp-melon-p{margin:6px 0}
.ldsp-melon-markdown .ldsp-melon-h2,.ldsp-melon-markdown .ldsp-melon-h3{font-size:13px;font-weight:700;margin:12px 0 8px;color:var(--txt);border-bottom:1px solid var(--border);padding-bottom:4px}
.ldsp-melon-markdown .ldsp-melon-h4,.ldsp-melon-markdown .ldsp-melon-h5{font-size:12px;font-weight:600;margin:10px 0 6px;color:var(--txt)}
.ldsp-melon-markdown .ldsp-melon-ul,.ldsp-melon-markdown .ldsp-melon-ol{margin:6px 0;padding-left:18px}
.ldsp-melon-markdown .ldsp-melon-li,.ldsp-melon-markdown .ldsp-melon-oli{margin:3px 0}
.ldsp-melon-markdown .ldsp-melon-quote{margin:8px 0;padding:8px 12px;background:var(--bg-el);border-left:3px solid var(--accent);border-radius:0 var(--r-sm) var(--r-sm) 0;color:var(--txt-sec);font-style:italic}
.ldsp-melon-markdown strong{color:var(--accent);font-weight:600}
.ldsp-melon-markdown .ldsp-melon-inline-code{background:var(--bg-el);padding:2px 5px;border-radius:3px;font-family:monospace;font-size:10px}
.ldsp-melon-markdown .ldsp-melon-codeblock{background:var(--bg-el);padding:10px;border-radius:var(--r-sm);overflow-x:auto;margin:8px 0}
.ldsp-melon-markdown .ldsp-melon-codeblock code{background:none;padding:0;font-family:monospace;font-size:10px}
.ldsp-melon-markdown .ldsp-melon-hr{border:none;border-top:1px solid var(--border);margin:12px 0}
.ldsp-melon-markdown .ldsp-melon-link{color:var(--accent);text-decoration:none}
.ldsp-melon-markdown .ldsp-melon-link:hover{text-decoration:underline}
.ldsp-melon-output h1,.ldsp-melon-output h2,.ldsp-melon-output h3{font-size:13px;font-weight:700;margin:12px 0 8px;color:var(--txt);border-bottom:1px solid var(--border);padding-bottom:4px}
.ldsp-melon-output h1:first-child,.ldsp-melon-output h2:first-child,.ldsp-melon-output h3:first-child{margin-top:0}
.ldsp-melon-output h4,.ldsp-melon-output h5{font-size:12px;font-weight:600;margin:10px 0 6px;color:var(--txt)}
.ldsp-melon-output p{margin:6px 0}
.ldsp-melon-output ul,.ldsp-melon-output ol{margin:6px 0;padding-left:18px}
.ldsp-melon-output li{margin:3px 0}
.ldsp-melon-output blockquote{margin:8px 0;padding:8px 12px;background:var(--bg-el);border-left:3px solid var(--accent);border-radius:0 var(--r-sm) var(--r-sm) 0;color:var(--txt-sec);font-style:italic}
.ldsp-melon-output strong{color:var(--accent);font-weight:600}
.ldsp-melon-output code{background:var(--bg-el);padding:2px 5px;border-radius:3px;font-family:monospace;font-size:10px}
.ldsp-melon-output pre{background:var(--bg-el);padding:10px;border-radius:var(--r-sm);overflow-x:auto;margin:8px 0}
.ldsp-melon-output pre code{background:none;padding:0}
.ldsp-melon-output table{width:100%;border-collapse:collapse;margin:8px 0;font-size:10px}
.ldsp-melon-output th,.ldsp-melon-output td{border:1px solid var(--border);padding:6px 8px;text-align:left}
.ldsp-melon-output th{background:var(--bg-el);font-weight:600}
.ldsp-melon-output hr{border:none;border-top:1px solid var(--border);margin:12px 0}
.ldsp-melon-output a{color:var(--accent);text-decoration:none}
.ldsp-melon-output a:hover{text-decoration:underline}
.ldsp-melon-status{text-align:center;padding:20px;color:var(--txt-mut);font-size:11px}
.ldsp-melon-status-icon{font-size:24px;margin-bottom:8px}
.ldsp-melon-error{color:var(--err);background:var(--err-bg);padding:10px;border-radius:var(--r-sm);font-size:11px}
/* 历史记录 */
.ldsp-melon-history{display:flex;flex-direction:column;height:100%}
.ldsp-melon-history-header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);margin-bottom:10px}
.ldsp-melon-history-header-left{display:flex;align-items:center;gap:8px}
.ldsp-melon-history-count{font-size:10px;color:var(--txt-mut)}
.ldsp-melon-history-storage-badge{font-size:9px;color:var(--txt-mut);background:var(--bg-el);padding:2px 6px;border-radius:10px}
.ldsp-melon-history-storage-hint{font-size:10px;color:var(--txt-mut);margin-top:8px}
.ldsp-melon-history-clear-all{padding:4px 8px;background:var(--err-bg);border:1px solid rgba(239,68,68,.2);border-radius:var(--r-sm);font-size:9px;color:var(--err);cursor:pointer;transition:all .15s}
.ldsp-melon-history-clear-all:hover{background:var(--err);color:#fff}
.ldsp-melon-history-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px}
.ldsp-melon-history-item{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:10px;transition:border-color .15s}
.ldsp-melon-history-item:hover{border-color:var(--accent)}
.ldsp-melon-history-item-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:6px}
.ldsp-melon-history-item-title{font-size:11px;font-weight:600;color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.ldsp-melon-history-item-meta{display:flex;align-items:center;gap:6px;flex-shrink:0}
.ldsp-melon-history-mode{font-size:9px;padding:2px 6px;border-radius:10px;font-weight:500}
.ldsp-melon-history-mode.brief{background:rgba(59,130,246,.1);color:#3b82f6}
.ldsp-melon-history-mode.detailed{background:rgba(34,197,94,.1);color:#22c55e}
.ldsp-melon-history-date{font-size:9px;color:var(--txt-mut)}
.ldsp-melon-history-item-preview{font-size:10px;color:var(--txt-sec);line-height:1.5;margin-bottom:8px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.ldsp-melon-history-item-actions{display:flex;gap:6px}
.ldsp-melon-history-btn{padding:4px 8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:9px;color:var(--txt-sec);cursor:pointer;transition:all .15s}
.ldsp-melon-history-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.ldsp-melon-history-delete:hover{background:var(--err);border-color:var(--err)}
.ldsp-melon-history-empty{text-align:center;padding:40px 20px;color:var(--txt-mut)}
.ldsp-melon-history-empty-icon{font-size:36px;margin-bottom:10px}
.ldsp-melon-history-empty-text{font-size:12px;font-weight:500;margin-bottom:4px}
.ldsp-melon-history-empty-hint{font-size:10px;color:var(--txt-mut)}
.ldsp-melon-history-detail{display:flex;flex-direction:column;height:100%}
.ldsp-melon-history-detail-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.ldsp-melon-history-detail-actions{display:flex;gap:6px}
.ldsp-melon-history-back{padding:6px 12px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:10px;color:var(--txt-sec);cursor:pointer;transition:all .15s}
.ldsp-melon-history-back:hover{background:var(--bg-card);border-color:var(--accent);color:var(--accent)}
.ldsp-melon-history-expand-btn{padding:6px 12px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:10px;color:var(--accent);cursor:pointer;transition:all .15s}
.ldsp-melon-history-expand-btn:hover{background:rgba(107,140,239,.1);border-color:var(--accent)}
.ldsp-melon-history-copy-all{padding:6px 12px;background:var(--accent);border:none;border-radius:var(--r-sm);font-size:10px;color:#fff;cursor:pointer;transition:opacity .15s}
.ldsp-melon-history-copy-all:hover{opacity:.85}
.ldsp-melon-history-detail-info{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:10px;margin-bottom:10px}
.ldsp-melon-history-detail-title{font-size:12px;font-weight:600;color:var(--txt);margin-bottom:4px}
.ldsp-melon-history-detail-meta{font-size:10px;color:var(--txt-mut)}
.ldsp-melon-history-detail-content{flex:1;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:12px;overflow-y:auto;font-size:11px;line-height:1.6}
/* 设置页 */
.ldsp-melon-settings{display:flex;flex-direction:column;gap:12px}
.ldsp-melon-setting-group{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);padding:12px}
.ldsp-melon-setting-title{font-size:11px;font-weight:600;color:var(--txt);margin-bottom:10px;display:flex;align-items:center;gap:5px}
.ldsp-melon-setting-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.ldsp-melon-setting-row:last-child{margin-bottom:0}
.ldsp-melon-setting-label{font-size:10px;color:var(--txt-sec)}
.ldsp-melon-setting-input{padding:8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:11px;color:var(--txt)}
.ldsp-melon-setting-input:focus{border-color:var(--accent);outline:none}
.ldsp-melon-setting-input::placeholder{color:var(--txt-mut)}
.ldsp-melon-setting-input:disabled{background:var(--bg);color:var(--txt-mut);cursor:not-allowed}
.ldsp-melon-setting-actions{display:flex;gap:8px}
.ldsp-melon-setting-btn{flex:1;padding:10px 12px;background:var(--grad);color:#fff;border:none;border-radius:var(--r-sm);font-size:11px;font-weight:600;cursor:pointer;transition:all .15s}
.ldsp-melon-setting-btn:hover{box-shadow:0 4px 12px rgba(107,140,239,.3);transform:translateY(-1px)}
.ldsp-melon-btn-edit{background:linear-gradient(135deg,#6b8cef,#5a7de0)}
.ldsp-melon-btn-save{background:linear-gradient(135deg,#22c55e,#16a34a)}
.ldsp-melon-btn-save:hover{box-shadow:0 4px 12px rgba(34,197,94,.35)}
.ldsp-melon-btn-prompt{background:linear-gradient(135deg,#6b8cef,#5a7de0)}
.ldsp-melon-setting-textarea{width:100%;padding:8px;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:10px;color:var(--txt);resize:vertical;min-height:60px;font-family:monospace;line-height:1.5}
.ldsp-melon-setting-textarea:focus{border-color:var(--accent);outline:none}
.ldsp-melon-setting-textarea::placeholder{color:var(--txt-mut);font-size:9px;white-space:pre-wrap}
.ldsp-melon-prompt-reset{margin-left:6px;cursor:pointer;color:var(--err);font-size:10px;opacity:.6;transition:all .15s}
.ldsp-melon-prompt-reset:hover{opacity:1;color:var(--err)}
.ldsp-melon-setting-prompt-actions{display:flex;gap:8px;margin-top:8px}
.ldsp-melon-setting-security{display:flex;align-items:flex-start;gap:10px;margin-top:12px;padding:12px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:var(--r-md)}
.ldsp-melon-setting-security-icon{font-size:18px;flex-shrink:0}
.ldsp-melon-setting-security-text{font-size:10px;color:var(--txt-sec);line-height:1.5}
.ldsp-melon-setting-security-text strong{color:var(--ok);font-weight:600}
.ldsp-melon-setting-hint{font-size:9px;color:var(--txt-mut);margin-top:2px}
.ldsp-melon-setting-danger{background:var(--err-bg);border-color:rgba(239,68,68,.2);text-align:center}
.ldsp-melon-setting-danger .ldsp-melon-setting-title{color:var(--err)}
.ldsp-melon-setting-danger-desc{font-size:10px;color:var(--txt-sec);margin-bottom:10px}
.ldsp-melon-setting-danger .ldsp-melon-setting-btn{display:inline-block;flex:none;min-width:160px}
.ldsp-melon-btn-danger{background:linear-gradient(135deg,#ef4444,#dc2626) !important}
.ldsp-melon-btn-danger:hover{box-shadow:0 4px 12px rgba(239,68,68,.35) !important}
.ldsp-melon-warning{background:rgba(212,168,83,.1);border:1px solid rgba(212,168,83,.3);border-radius:var(--r-sm);padding:8px 10px;font-size:10px;color:var(--warn);margin-bottom:8px}
.ldsp-melon-not-topic{text-align:center;padding:40px 20px;color:var(--txt-mut)}
.ldsp-melon-not-topic-icon{font-size:36px;margin-bottom:10px}
.ldsp-melon-not-topic-text{font-size:12px;line-height:1.6}
.ldsp-melon-not-topic-hint{font-size:10px;color:var(--txt-mut);opacity:.7;margin-top:8px}
/* 全屏展开查看弹窗 - 独立的变量定义以支持body级挂载 */
.ldsp-melon-viewer-overlay{--bg:#12131a;--bg-card:rgba(24,26,36,.98);--bg-hover:rgba(38,42,56,.95);--bg-el:rgba(32,35,48,.88);--txt:#e4e6ed;--txt-sec:#9499ad;--txt-mut:#5d6275;--accent:#6b8cef;--border:rgba(255,255,255,.06);--r-sm:6px;--r-md:10px;--r-lg:14px;--err:#e07a8d;--err-bg:rgba(224,122,141,.12);position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:99999;display:flex;align-items:center;justify-content:center;animation:ldsp-viewer-fade-in .2s}
.ldsp-melon-viewer-overlay.light{--bg:rgba(250,251,254,.97);--bg-card:rgba(255,255,255,.98);--bg-hover:rgba(238,242,250,.96);--bg-el:rgba(245,247,252,.94);--txt:#1e2030;--txt-sec:#4a5068;--txt-mut:#8590a6;--accent:#5070d0;--border:rgba(0,0,0,.08);--err:#d45d6e;--err-bg:rgba(212,93,110,.08)}
@keyframes ldsp-viewer-fade-in{from{opacity:0}to{opacity:1}}
@keyframes ldsp-viewer-scale-in{from{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}
.ldsp-melon-viewer{position:absolute;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-lg);box-shadow:0 20px 60px rgba(0,0,0,.5);display:flex;flex-direction:column;min-width:320px;min-height:240px;max-width:95vw;max-height:90vh;overflow:hidden;animation:ldsp-viewer-scale-in .2s}
.ldsp-melon-viewer-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:var(--bg);border-bottom:1px solid var(--border);cursor:move;user-select:none;flex-shrink:0}
.ldsp-melon-viewer-title{font-size:13px;font-weight:700;color:var(--txt);display:flex;align-items:center;gap:8px}
.ldsp-melon-viewer-title-icon{font-size:16px}
.ldsp-melon-viewer-actions{display:flex;gap:6px}
.ldsp-melon-viewer-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:var(--bg-el);border:1px solid var(--border);border-radius:var(--r-sm);font-size:12px;color:var(--txt-sec);cursor:pointer;transition:all .15s}
.ldsp-melon-viewer-btn:hover{background:var(--bg-hover);border-color:var(--accent);color:var(--accent)}
.ldsp-melon-viewer-close:hover{background:var(--err-bg);border-color:var(--err);color:var(--err)}
.ldsp-melon-viewer-body{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg-card)}
.ldsp-melon-viewer-info{padding:12px 16px;background:var(--bg-el);border-bottom:1px solid var(--border);flex-shrink:0}
.ldsp-melon-viewer-topic-title{font-size:14px;font-weight:700;color:var(--txt);line-height:1.4;margin-bottom:6px}
.ldsp-melon-viewer-meta{display:flex;align-items:center;gap:8px;font-size:10px;color:var(--txt-mut)}
.ldsp-melon-viewer-mode{padding:2px 6px;border-radius:8px;font-weight:600;font-size:9px}
.ldsp-melon-viewer-mode.brief{background:rgba(34,197,94,.15);color:#22c55e}
.ldsp-melon-viewer-mode.detailed{background:rgba(107,140,239,.15);color:#6b8cef}
.ldsp-melon-viewer-content{flex:1;overflow-y:auto;padding:16px;background:var(--bg-card);color:var(--txt)}
.ldsp-melon-viewer-content .ldsp-melon-markdown{font-size:13px;line-height:1.7;color:var(--txt)}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-p{color:var(--txt)}
.ldsp-melon-viewer-content .ldsp-melon-markdown strong{color:var(--accent)}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-h2,.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-h3,.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-h4{color:var(--txt);border-color:var(--border)}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-quote{background:var(--bg-el);border-left-color:var(--accent);color:var(--txt-sec)}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-inline-code{background:var(--bg-el);color:var(--txt)}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-codeblock{background:var(--bg);border:1px solid var(--border);color:var(--txt);padding:10px;border-radius:6px;overflow-x:auto}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-ul,.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-ol{margin:8px 0;padding-left:20px}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-li,.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-oli{margin:4px 0;color:var(--txt)}
.ldsp-melon-viewer-content .ldsp-melon-markdown .ldsp-melon-hr{border:none;border-top:1px solid var(--border);margin:12px 0}
.ldsp-melon-viewer-content::-webkit-scrollbar{width:6px}
.ldsp-melon-viewer-content::-webkit-scrollbar-track{background:var(--bg)}
.ldsp-melon-viewer-content::-webkit-scrollbar-thumb{background:var(--txt-mut);border-radius:3px}
.ldsp-melon-viewer-content::-webkit-scrollbar-thumb:hover{background:var(--txt-sec)}
/* 调整大小手柄 */
.ldsp-melon-resize-handle{position:absolute;background:transparent}
.ldsp-melon-resize-handle-e{top:10px;right:0;width:6px;height:calc(100% - 20px);cursor:e-resize}
.ldsp-melon-resize-handle-s{bottom:0;left:10px;width:calc(100% - 20px);height:6px;cursor:s-resize}
.ldsp-melon-resize-handle-se{bottom:0;right:0;width:16px;height:16px;cursor:se-resize}
.ldsp-melon-resize-handle-se::before{content:'';position:absolute;right:3px;bottom:3px;width:8px;height:8px;border-right:2px solid var(--txt-mut);border-bottom:2px solid var(--txt-mut);opacity:.5}
.ldsp-melon-resize-handle-w{top:10px;left:0;width:6px;height:calc(100% - 20px);cursor:w-resize}
.ldsp-melon-resize-handle-n{top:0;left:10px;width:calc(100% - 20px);height:6px;cursor:n-resize}
.ldsp-melon-resize-handle-nw{top:0;left:0;width:16px;height:16px;cursor:nw-resize}
.ldsp-melon-resize-handle-ne{top:0;right:0;width:16px;height:16px;cursor:ne-resize}
.ldsp-melon-resize-handle-sw{bottom:0;left:0;width:16px;height:16px;cursor:sw-resize}
/* 自定义确认对话框 */
.ldsp-melon-confirm-dialog{position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:ldsp-fade-in .15s}
.ldsp-melon-confirm-content{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-lg);padding:20px;width:85%;max-width:280px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.2);animation:ldsp-scale-in .2s}
@keyframes ldsp-fade-in{from{opacity:0}to{opacity:1}}
@keyframes ldsp-scale-in{from{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}
.ldsp-melon-confirm-icon{font-size:32px;margin-bottom:12px}
.ldsp-melon-confirm-message{font-size:12px;color:var(--txt);line-height:1.6;margin-bottom:16px}
.ldsp-melon-confirm-actions{display:flex;gap:10px}
.ldsp-melon-confirm-btn{flex:1;padding:10px 16px;border:none;border-radius:var(--r-sm);font-size:11px;font-weight:600;cursor:pointer;transition:all .15s}
.ldsp-melon-confirm-cancel{background:var(--bg-el);color:var(--txt-sec);border:1px solid var(--border)}
.ldsp-melon-confirm-cancel:hover{background:var(--bg);border-color:var(--txt-mut)}
.ldsp-melon-confirm-ok{background:var(--err);color:#fff}
.ldsp-melon-confirm-ok:hover{background:#dc2626;box-shadow:0 4px 12px rgba(239,68,68,.3)}
.ldsp-user-meta{display:flex;align-items:center;flex-wrap:wrap;margin-top:2px}
.ldsp-follow-stats{display:flex;gap:6px;padding:2px 0}
.ldsp-follow-combined{display:inline-flex;align-items:center;gap:3px;padding:2px 0;font-size:10px;color:var(--txt-mut)}
.ldsp-follow-part{cursor:pointer;transition:color .15s;font-weight:500}
.ldsp-follow-part:hover{color:var(--accent)}
.ldsp-follow-sep{color:var(--txt-mut);margin:0 1px}
.ldsp-follow-num-following,.ldsp-follow-num-followers{font-weight:600;color:var(--txt-sec);transition:color .15s}
.ldsp-follow-part:hover .ldsp-follow-num-following,.ldsp-follow-part:hover .ldsp-follow-num-followers{color:var(--accent)}
.ldsp-join-days{display:inline-flex;align-items:center;font-size:10px;color:var(--txt-mut);margin-left:6px;cursor:pointer;transition:color .15s}
.ldsp-join-days:hover{color:var(--accent)}
.ldsp-join-days-num{font-weight:600;color:var(--txt-sec);margin:0 2px;transition:color .15s}
.ldsp-join-days:hover .ldsp-join-days-num{color:var(--accent)}
.ldsp-follow-stat{display:flex;align-items:center;gap:3px;padding:2px 6px;background:var(--bg-el);border:1px solid var(--border);border-radius:12px;font-size:9px;color:var(--txt-sec);cursor:pointer;transition:all .15s}
.ldsp-follow-stat:hover{background:var(--bg-hover);border-color:var(--accent);color:var(--accent)}
.ldsp-follow-stat:active{transform:scale(.98)}
.ldsp-follow-stat-icon{display:flex;align-items:center;justify-content:center;width:12px;height:12px;opacity:.7}
.ldsp-follow-stat-icon svg{width:11px;height:11px;stroke-width:2}
.ldsp-follow-stat:hover .ldsp-follow-stat-icon{opacity:1}
.ldsp-follow-stat:hover .ldsp-follow-stat-icon svg{stroke:var(--accent)}
.ldsp-follow-stat-num{font-weight:700;color:var(--txt);font-size:10px}
.ldsp-follow-stat-label{font-weight:500;font-size:9px}
.ldsp-follow-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background:var(--bg);border-radius:0 0 var(--r-lg) var(--r-lg);z-index:10;display:none;flex-direction:column;overflow:hidden}
.ldsp-follow-overlay.show{display:flex}
.ldsp-follow-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-card);border-bottom:1px solid var(--border);flex-shrink:0}
.ldsp-follow-title{font-size:13px;font-weight:700;display:flex;align-items:center;gap:6px;color:var(--txt)}
.ldsp-follow-title svg{width:16px;height:16px;stroke:var(--accent);stroke-width:2}
.ldsp-follow-close{width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:var(--bg-el);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--txt-sec);cursor:pointer;transition:background .15s,color .15s}
.ldsp-follow-close:hover{background:var(--err-bg);color:var(--err);border-color:var(--err)}
.ldsp-follow-tabs{display:flex;border-bottom:1px solid var(--border);background:var(--bg-card);flex-shrink:0}
.ldsp-follow-tab{flex:1;display:flex;align-items:center;justify-content:center;gap:4px;padding:9px 10px;font-size:10px;font-weight:600;color:var(--txt-mut);border-bottom:2px solid transparent;cursor:pointer;transition:color .15s,border-color .15s,background .15s}
.ldsp-follow-tab:hover:not(.active){background:var(--bg-hover)}
.ldsp-follow-tab.active{color:var(--accent);border-color:var(--accent)}
.ldsp-follow-tab-icon{display:flex;align-items:center;justify-content:center;width:14px;height:14px}
.ldsp-follow-tab-icon svg{width:13px;height:13px;stroke-width:2}
.ldsp-follow-tab.active .ldsp-follow-tab-icon svg{stroke:var(--accent)}
.ldsp-follow-tab-count{padding:1px 6px;background:var(--bg-el);border-radius:10px;font-size:10px;font-weight:700;color:var(--txt-sec)}
.ldsp-follow-tab.active .ldsp-follow-tab-count{background:var(--accent);color:#fff}
.ldsp-follow-body{flex:1;overflow-y:auto;padding:10px;background:var(--bg)}
.ldsp-follow-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:40px 20px;color:var(--txt-mut)}
.ldsp-follow-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:40px 20px;text-align:center;color:var(--txt-mut)}
.ldsp-follow-empty-icon{width:48px;height:48px;opacity:.4}
.ldsp-follow-empty-icon svg{width:100%;height:100%;stroke-width:1.5}
.ldsp-follow-list{display:flex;flex-direction:column;gap:5px}
.ldsp-follow-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--r-md);cursor:pointer;transition:all .15s;text-decoration:none;position:relative;overflow:hidden}
.ldsp-follow-item::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
.ldsp-follow-item:hover{background:var(--bg-hover);border-color:rgba(107,140,239,.4);box-shadow:0 2px 8px rgba(107,140,239,.08)}
.ldsp-follow-item:hover::before{opacity:1}
.ldsp-follow-avatar-wrap{flex-shrink:0;position:relative}
.ldsp-follow-avatar{width:36px;height:36px;border-radius:50%;object-fit:cover;background:var(--bg-el);border:2px solid var(--border);transition:border-color .15s,transform .15s}
.ldsp-follow-item:hover .ldsp-follow-avatar{border-color:var(--accent);transform:scale(1.05)}
.ldsp-follow-user-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.ldsp-follow-user-name{font-size:12px;font-weight:600;color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ldsp-follow-item:hover .ldsp-follow-user-name{color:var(--accent)}
.ldsp-follow-user-id{font-size:10px;color:var(--txt-mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ldsp-follow-arrow{flex-shrink:0;width:20px;height:20px;display:flex;align-items:center;justify-content:center;color:var(--txt-mut);opacity:.4;transition:opacity .15s,transform .15s}
.ldsp-follow-arrow svg{width:14px;height:14px}
.ldsp-follow-item:hover .ldsp-follow-arrow{opacity:1;transform:translateX(2px);color:var(--accent)}
.ldsp-activity-content{flex:1;overflow-y:auto;padding:8px}
.ldsp-topic-list{display:flex;flex-direction:column;gap:6px}
.ldsp-topic-list-enhanced .ldsp-topic-item{display:flex;flex-direction:row;align-items:center;gap:8px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:var(--r-md);cursor:pointer;transition:all .15s ease;text-decoration:none}
.ldsp-topic-item:hover{background:var(--bg-hover);border-color:rgba(107,140,239,.5);border-left-color:#5a7de0;transform:translateX(2px);box-shadow:0 2px 8px rgba(107,140,239,.1)}
.ldsp-topic-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
.ldsp-topic-header{display:flex;flex-direction:column;gap:4px}
.ldsp-topic-title-row{display:flex;align-items:center;gap:5px;min-width:0}
.ldsp-topic-badges{display:flex;gap:3px;flex-shrink:0}
.ldsp-topic-badge{padding:1px 4px;border-radius:6px;font-size:8px;font-weight:600;line-height:1.2}
.ldsp-badge-unread{background:var(--accent);color:#fff}
.ldsp-badge-new{background:#10b981;color:#fff}
.ldsp-topic-title{font-size:12px;font-weight:600;color:var(--txt);line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
.ldsp-topic-item:hover .ldsp-topic-title{color:var(--accent)}
.ldsp-topic-info{display:flex;align-items:center;gap:4px;flex-wrap:wrap;min-height:16px}
.ldsp-topic-tags{display:flex;gap:3px;flex-wrap:wrap}
.ldsp-topic-tag{padding:1px 6px;background:rgba(107,140,239,.08);border:1px solid rgba(107,140,239,.2);border-radius:8px;font-size:8px;color:#5a7de0;max-width:55px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ldsp-topic-tag-more{padding:1px 5px;background:rgba(107,140,239,.15);border:1px solid rgba(107,140,239,.3);border-radius:8px;font-size:8px;color:#4a6bc9;font-weight:600}
.ldsp-topic-footer{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.ldsp-topic-posters{display:flex;align-items:center;flex-shrink:0}
.ldsp-topic-avatar{width:16px;height:16px;border-radius:50%;border:1.5px solid var(--bg-card);margin-left:-5px;object-fit:cover;background:var(--bg-el)}
.ldsp-topic-avatar:first-child{margin-left:0}
.ldsp-poster-op{border-color:var(--accent)}
.ldsp-poster-latest{border-color:#10b981}
.ldsp-topic-posters-more{margin-left:2px;font-size:8px;color:var(--txt-mut)}
.ldsp-topic-stats{display:flex;align-items:center;gap:6px;flex:1;justify-content:flex-end;flex-wrap:nowrap;min-width:0}
.ldsp-topic-stat{display:flex;align-items:center;gap:2px;font-size:9px;color:var(--txt-mut);white-space:nowrap;flex-shrink:0}
.ldsp-topic-stat svg{width:10px;height:10px;stroke-width:2;opacity:.6;flex-shrink:0}
.ldsp-topic-stat em{font-style:normal}
.ldsp-stat-like{color:#ef4444}
.ldsp-stat-like svg{stroke:#ef4444;opacity:.8}
.ldsp-topic-time{font-size:9px;color:var(--txt-mut);opacity:.8;white-space:nowrap;flex-shrink:0}
.ldsp-topic-thumbnail{width:48px;height:48px;flex-shrink:0;border-radius:6px;overflow:hidden;background:var(--bg-el)}
.ldsp-topic-thumbnail img{width:100%;height:100%;object-fit:cover;transition:transform .15s}
.ldsp-topic-item:hover .ldsp-topic-thumbnail img{transform:scale(1.05)}
.ldsp-bookmark-list{display:flex;flex-direction:column;gap:8px}
.ldsp-bookmark-item{display:flex;flex-direction:column;gap:6px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-left:3px solid #f59e0b;border-radius:var(--r-md);cursor:pointer;transition:all .15s ease;text-decoration:none}
.ldsp-bookmark-item:hover{background:var(--bg-hover);border-color:rgba(245,158,11,.5);border-left-color:#eab308;transform:translateX(2px);box-shadow:0 2px 8px rgba(245,158,11,.1)}
.ldsp-bookmark-title{font-size:12px;font-weight:600;color:var(--txt);line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.ldsp-bookmark-item:hover .ldsp-bookmark-title{color:#b45309}
.ldsp-bookmark-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:9px;color:var(--txt-mut);align-items:center}
.ldsp-bookmark-time{display:flex;align-items:center;gap:3px;white-space:nowrap}
.ldsp-bookmark-time svg{width:10px;height:10px;stroke-width:2;flex-shrink:0;opacity:.6}
.ldsp-bookmark-tags{display:flex;flex-wrap:wrap;gap:3px}
.ldsp-bookmark-tag{padding:1px 6px;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);border-radius:8px;font-size:8px;color:#b45309;max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ldsp-bookmark-tag-more{padding:1px 5px;background:rgba(245,158,11,.15);border:1px solid rgba(245,158,11,.3);border-radius:8px;font-size:8px;color:#92400e;font-weight:600}
.ldsp-bookmark-excerpt{font-size:10px;color:var(--txt-sec);line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;padding:6px 0 0;word-break:break-word;border-top:1px dashed var(--border);margin-top:2px}
.ldsp-bookmark-excerpt *{all:revert;font-size:inherit;line-height:inherit;color:inherit;margin:0;padding:0;border:none;background:none;box-shadow:none}
.ldsp-bookmark-excerpt img{display:inline-block!important;max-width:18px!important;max-height:18px!important;vertical-align:middle!important;margin:0 2px!important;border-radius:2px!important}
.ldsp-bookmark-excerpt a{color:var(--accent)!important;text-decoration:none!important}
.ldsp-bookmark-excerpt a:hover{text-decoration:underline!important}
.ldsp-bookmark-excerpt .emoji{width:18px!important;height:18px!important;vertical-align:middle!important}
.ldsp-bookmark-excerpt .lightbox,.ldsp-bookmark-excerpt .lightbox img{display:inline!important;max-width:18px!important;max-height:18px!important}
.ldsp-bookmark-excerpt .anchor{display:none!important}
.ldsp-reply-list{display:flex;flex-direction:column;gap:8px}
.ldsp-reply-item{display:flex;flex-direction:column;gap:6px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-left:3px solid #10b981;border-radius:var(--r-md);cursor:pointer;transition:all .15s ease}
.ldsp-reply-item:hover{background:var(--bg-hover);border-color:rgba(16,185,129,.5);border-left-color:#059669;transform:translateX(2px);box-shadow:0 2px 8px rgba(16,185,129,.1)}
.ldsp-reply-title{font-size:12px;font-weight:600;color:var(--txt);line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.ldsp-reply-item:hover .ldsp-reply-title{color:#059669}
.ldsp-reply-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:9px;color:var(--txt-mut);align-items:center}
.ldsp-reply-time,.ldsp-reply-to{display:flex;align-items:center;gap:3px;white-space:nowrap}
.ldsp-reply-time svg,.ldsp-reply-to svg{width:10px;height:10px;stroke-width:2;flex-shrink:0;opacity:.6}
.ldsp-reply-to{color:#10b981;font-weight:500}
.ldsp-reply-excerpt{font-size:10px;color:var(--txt-sec);line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;padding:6px 0 0;word-break:break-word;border-top:1px dashed var(--border);margin-top:2px}
.ldsp-reply-excerpt *{all:revert;font-size:inherit;line-height:inherit;color:inherit;margin:0;padding:0;border:none;background:none;box-shadow:none}
.ldsp-reply-excerpt img{display:inline-block!important;max-width:16px!important;max-height:16px!important;vertical-align:middle!important;margin:0 2px!important;border-radius:2px!important}
.ldsp-reply-excerpt a{color:var(--accent)!important;text-decoration:none!important}
.ldsp-reply-excerpt a:hover{text-decoration:underline!important}
.ldsp-reply-excerpt .emoji{width:16px!important;height:16px!important;vertical-align:middle!important}
.ldsp-reply-excerpt .lightbox,.ldsp-reply-excerpt .lightbox img{display:inline!important;max-width:16px!important;max-height:16px!important}
.ldsp-reply-excerpt .anchor{display:none!important}
.ldsp-like-list{display:flex;flex-direction:column;gap:8px}
.ldsp-like-item{display:flex;flex-direction:column;gap:6px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-left:3px solid #ef4444;border-radius:var(--r-md);cursor:pointer;transition:all .15s ease}
.ldsp-like-item:hover{background:var(--bg-hover);border-color:rgba(239,68,68,.5);border-left-color:#dc2626;transform:translateX(2px);box-shadow:0 2px 8px rgba(239,68,68,.1)}
.ldsp-like-title{font-size:12px;font-weight:600;color:var(--txt);line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.ldsp-like-item:hover .ldsp-like-title{color:#dc2626}
.ldsp-like-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:9px;color:var(--txt-mut);align-items:center}
.ldsp-like-time,.ldsp-like-author{display:flex;align-items:center;gap:3px;white-space:nowrap}
.ldsp-like-time svg,.ldsp-like-author svg{width:10px;height:10px;stroke-width:2;flex-shrink:0;opacity:.6}
.ldsp-like-author{color:#ef4444;font-weight:500}
.ldsp-like-author svg{fill:#ef4444;stroke:none;opacity:.8}
.ldsp-like-excerpt{font-size:10px;color:var(--txt-sec);line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;padding:6px 0 0;word-break:break-word;border-top:1px dashed var(--border);margin-top:2px}
.ldsp-like-excerpt *{all:revert;font-size:inherit;line-height:inherit;color:inherit;margin:0;padding:0;border:none;background:none;box-shadow:none}
.ldsp-like-excerpt img{display:inline-block!important;max-width:16px!important;max-height:16px!important;vertical-align:middle!important;margin:0 2px!important;border-radius:2px!important}
.ldsp-like-excerpt a{color:var(--accent)!important;text-decoration:none!important}
.ldsp-like-excerpt a:hover{text-decoration:underline!important}
.ldsp-like-excerpt .emoji{width:16px!important;height:16px!important;vertical-align:middle!important}
.ldsp-like-excerpt .lightbox,.ldsp-like-excerpt .lightbox img{display:inline!important;max-width:16px!important;max-height:16px!important}
.ldsp-like-excerpt .anchor{display:none!important}
.ldsp-mytopic-list{display:flex;flex-direction:column;gap:8px}
.ldsp-mytopic-item{display:flex;flex-direction:column;gap:5px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-left:3px solid #8b5cf6;border-radius:var(--r-md);cursor:pointer;transition:all .15s ease;text-decoration:none}
.ldsp-mytopic-item:hover{background:var(--bg-hover);border-color:rgba(139,92,246,.5);border-left-color:#7c3aed;transform:translateX(2px);box-shadow:0 2px 8px rgba(139,92,246,.1)}
.ldsp-mytopic-item.closed{opacity:.7}
.ldsp-mytopic-item.closed .ldsp-mytopic-title{text-decoration:line-through;color:var(--txt-mut)}
.ldsp-mytopic-header{display:flex;align-items:flex-start;gap:6px}
.ldsp-mytopic-title{flex:1;font-size:12px;font-weight:600;color:var(--txt);line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.ldsp-mytopic-item:hover .ldsp-mytopic-title{color:#7c3aed}
.ldsp-mytopic-icons{display:flex;gap:3px;flex-shrink:0}
.ldsp-mytopic-status{display:flex;align-items:center;justify-content:center;width:14px;height:14px}
.ldsp-mytopic-status svg{width:11px;height:11px;fill:#8b5cf6}
.ldsp-mytopic-status.ldsp-mytopic-closed svg{fill:var(--txt-mut);stroke:var(--txt-mut);stroke-width:2}
.ldsp-mytopic-row{display:flex;align-items:center;flex-wrap:nowrap;gap:6px}
.ldsp-mytopic-tags{display:flex;flex-wrap:wrap;gap:3px;flex:1;min-width:0}
.ldsp-mytopic-tag{padding:1px 6px;background:rgba(139,92,246,.08);border:1px solid rgba(139,92,246,.2);border-radius:8px;font-size:8px;color:#7c3aed;max-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ldsp-mytopic-tag-more{padding:1px 5px;background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);border-radius:8px;font-size:8px;color:#6d28d9;font-weight:600}
.ldsp-mytopic-time{display:flex;align-items:center;gap:3px;font-size:8px;color:var(--txt-mut);flex-shrink:0;white-space:nowrap}
.ldsp-mytopic-time svg{width:9px;height:9px;stroke-width:2;flex-shrink:0;opacity:.7}
.ldsp-mytopic-meta{display:flex;align-items:center;gap:8px;font-size:9px;color:var(--txt-mut);padding-top:5px;border-top:1px solid var(--border)}
.ldsp-mytopic-stat{display:flex;align-items:center;gap:2px;white-space:nowrap}
.ldsp-mytopic-stat svg{width:11px;height:11px;stroke-width:2;flex-shrink:0}
.ldsp-mytopic-likes{color:#ef4444}
.ldsp-mytopic-likes svg{stroke:#ef4444}
.ldsp-mytopic-meta-right{display:flex;align-items:center;gap:6px;margin-left:auto;font-size:8px;white-space:nowrap}
@media (max-width:320px){.ldsp-activity-content{padding:6px}.ldsp-topic-list{gap:4px}.ldsp-topic-item{padding:8px;gap:6px}.ldsp-topic-title{font-size:11px}.ldsp-topic-footer{gap:4px}.ldsp-topic-stats{gap:4px}.ldsp-topic-stat{font-size:8px}.ldsp-topic-stat svg{width:9px;height:9px}.ldsp-topic-time{font-size:8px}.ldsp-topic-thumbnail{width:40px;height:40px}.ldsp-topic-avatar{width:14px;height:14px;margin-left:-4px}.ldsp-topic-tag{max-width:45px;font-size:7px}.ldsp-bookmark-list,.ldsp-reply-list,.ldsp-like-list,.ldsp-mytopic-list{gap:6px}.ldsp-bookmark-item,.ldsp-reply-item,.ldsp-like-item,.ldsp-mytopic-item{padding:10px}.ldsp-bookmark-title,.ldsp-reply-title,.ldsp-like-title,.ldsp-mytopic-title{font-size:11px}.ldsp-bookmark-meta,.ldsp-reply-meta,.ldsp-like-meta,.ldsp-mytopic-meta{gap:8px;font-size:8px}.ldsp-bookmark-excerpt,.ldsp-reply-excerpt,.ldsp-like-excerpt{font-size:9px;-webkit-line-clamp:2}.ldsp-bookmark-tag,.ldsp-reply-to,.ldsp-like-author,.ldsp-mytopic-tag{font-size:7px}.ldsp-bookmark-time svg,.ldsp-reply-time svg,.ldsp-like-time svg,.ldsp-mytopic-time svg{width:9px;height:9px}.ldsp-follow-stats{gap:4px}.ldsp-follow-stat{padding:2px 4px;font-size:8px}.ldsp-follow-stat-icon{width:10px;height:10px}.ldsp-follow-stat-icon svg{width:9px;height:9px}.ldsp-follow-stat-num{font-size:9px}.ldsp-follow-tab{padding:7px 8px;font-size:9px}.ldsp-follow-tab-icon{width:12px;height:12px}.ldsp-follow-tab-icon svg{width:11px;height:11px}.ldsp-follow-tab-count{font-size:8px;padding:1px 4px}.ldsp-follow-item{padding:8px;gap:8px}.ldsp-follow-avatar{width:32px;height:32px}.ldsp-follow-user-name{font-size:11px}.ldsp-follow-user-id{font-size:9px}}
@media (max-width:280px){.ldsp-topic-thumbnail{display:none}.ldsp-topic-posters{display:none}.ldsp-topic-stats{justify-content:flex-start}.ldsp-topic-tags{display:none}.ldsp-bookmark-tags,.ldsp-mytopic-tags{display:none}.ldsp-bookmark-excerpt,.ldsp-reply-excerpt,.ldsp-like-excerpt{-webkit-line-clamp:2}.ldsp-follow-stat-label{display:none}.ldsp-follow-arrow{display:none}.ldsp-follow-tab-text{display:none}}
.ldsp-load-more{display:flex;align-items:center;justify-content:center;padding:12px;color:var(--txt-sec);font-size:10px}
.ldsp-load-more.loading::after{content:'';width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:ldsp-spin .8s linear infinite;margin-left:6px}
.ldsp-activity-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px;text-align:center;color:var(--txt-mut)}
.ldsp-activity-placeholder-icon{font-size:32px;margin-bottom:10px;opacity:.5}
.ldsp-activity-placeholder-text{font-size:11px}
.ldsp-tooltip{position:fixed;z-index:2147483647;max-width:220px;padding:6px 10px;background:linear-gradient(135deg,rgba(30,32,48,.96),rgba(24,26,38,.98));color:#e8eaf0;font-size:11px;font-weight:500;line-height:1.4;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.3),0 0 0 1px rgba(255,255,255,.06);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity .15s ease,transform .15s ease;white-space:pre-line;word-break:break-word}
.ldsp-tooltip.show{opacity:1;transform:translateY(0)}
.ldsp-tooltip::before{content:'';position:absolute;width:8px;height:8px;background:inherit;transform:rotate(45deg);box-shadow:-1px -1px 0 rgba(255,255,255,.06)}
.ldsp-tooltip.top::before{bottom:-4px;left:50%;margin-left:-4px}
.ldsp-tooltip.bottom::before{top:-4px;left:50%;margin-left:-4px}
.ldsp-tooltip.left::before{bottom:-4px;right:12px}
.ldsp-tooltip.right::before{bottom:-4px;left:12px}
#ldsp-panel.light .ldsp-tooltip{background:linear-gradient(135deg,rgba(255,255,255,.98),rgba(248,250,254,.98));color:#2d3148;box-shadow:0 4px 16px rgba(0,0,0,.12),0 0 0 1px rgba(0,0,0,.06)}
#ldsp-panel.light .ldsp-tooltip::before{box-shadow:-1px -1px 0 rgba(0,0,0,.04)}`;
}
};
// ==================== Tooltip 管理器 ====================
const Tooltip = {
el: null,
timer: null,
currentTarget: null,
init() {
if (this.el) return;
this.el = document.createElement('div');
this.el.className = 'ldsp-tooltip';
document.body.appendChild(this.el);
},
show(target, text, delay = 400) {
if (!text || !target) return;
this.hide();
this.currentTarget = target;
this.timer = setTimeout(() => {
if (this.currentTarget !== target) return;
this.el.textContent = text;
this.el.classList.remove('show', 'top', 'bottom', 'left', 'right');
// 先显示以获取尺寸
this.el.style.visibility = 'hidden';
this.el.style.display = 'block';
const rect = target.getBoundingClientRect();
const tipRect = this.el.getBoundingClientRect();
const padding = 8;
// 计算位置(优先显示在上方)
let top, left;
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceAbove >= tipRect.height + padding || spaceAbove > spaceBelow) {
// 显示在上方
top = rect.top - tipRect.height - padding;
this.el.classList.add('top');
} else {
// 显示在下方
top = rect.bottom + padding;
this.el.classList.add('bottom');
}
// 水平居中,但不超出屏幕
left = rect.left + (rect.width - tipRect.width) / 2;
if (left < padding) {
left = padding;
this.el.classList.add('right');
} else if (left + tipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tipRect.width - padding;
this.el.classList.add('left');
}
// 确保不超出顶部
if (top < padding) top = padding;
this.el.style.top = `${top}px`;
this.el.style.left = `${left}px`;
this.el.style.visibility = '';
// 添加面板主题类
const panel = document.getElementById('ldsp-panel');
if (panel?.classList.contains('light')) {
this.el.closest('body').querySelector('.ldsp-tooltip')?.classList.add('light-theme');
}
requestAnimationFrame(() => this.el.classList.add('show'));
}, delay);
},
hide() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.currentTarget = null;
if (this.el) {
this.el.classList.remove('show');
}
},
// 绑定到面板
bindToPanel(panel) {
this.init();
// 使用事件委托
panel.addEventListener('mouseenter', (e) => {
const target = e.target.closest('[title], [data-tip]');
if (!target) return;
const text = target.dataset.tip || target.getAttribute('title');
if (text) {
// 临时移除 title 防止浏览器默认提示
if (target.hasAttribute('title')) {
target.dataset.tip = text;
target.removeAttribute('title');
}
this.show(target, text);
}
}, true);
panel.addEventListener('mouseleave', (e) => {
const target = e.target.closest('[data-tip]');
if (target) this.hide();
}, true);
// 滚动和点击时隐藏
panel.addEventListener('scroll', () => this.hide(), true);
panel.addEventListener('click', () => this.hide(), true);
}
};
// ==================== 工单管理器 ====================
class TicketManager {
// 跨标签页共享的缓存 key
static CACHE_KEY = 'ldsp_ticket_unread';
static CACHE_TTL = 30 * 1000; // 30 秒缓存有效期
constructor(oauth, panelBody) {
this.oauth = oauth;
this.panelBody = panelBody;
this.overlay = null;
this.ticketTypes = [];
this.tickets = [];
this.currentTicket = null;
this.currentView = 'list';
this.unreadCount = 0;
this._isOverlayOpen = false; // 工单面板是否打开
this._lastHiddenTime = null; // 页面隐藏时间,用于触发式检测
}
async init() {
this._createOverlay();
await this._loadTicketTypes();
this._bindVisibilityHandler();
// 延迟 5 秒后首次检查
setTimeout(() => this._checkUnread(), 5000);
}
_bindVisibilityHandler() {
// 页面可见性变化时触发式检测(切换标签页、最小化窗口等)
this._visibilityHandler = () => {
if (document.hidden) {
// 页面隐藏时,记录时间
this._lastHiddenTime = Date.now();
} else {
// 页面恢复可见时,检查是否超过10分钟阈值
const hiddenDuration = this._lastHiddenTime ? Date.now() - this._lastHiddenTime : 0;
const TEN_MINUTES = 10 * 60 * 1000; // 10分钟
if (hiddenDuration >= TEN_MINUTES) {
// 超过10分钟,触发检测
this._checkUnread();
}
this._lastHiddenTime = null;
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
}
_createOverlay() {
this.overlay = document.createElement('div');
this.overlay.className = 'ldsp-ticket-overlay';
this.overlay.innerHTML = `
`;
if (this.panelBody) {
this.panelBody.appendChild(this.overlay);
}
this._bindEvents();
}
_bindEvents() {
this.overlay.querySelector('.ldsp-ticket-close').addEventListener('click', () => this.hide());
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && this.overlay.classList.contains('show')) this.hide();
});
this.overlay.querySelectorAll('.ldsp-ticket-tab').forEach(tab => {
tab.addEventListener('click', () => {
this.overlay.querySelectorAll('.ldsp-ticket-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
if (tabName === 'list') {
// 先显示加载状态
this._showListLoading();
this._loadTickets().then(() => this._renderList());
} else if (tabName === 'create') {
this._renderCreate();
}
});
});
}
async _loadTicketTypes() {
const defaultTypes = [
{ id: 'feature_request', label: '功能建议', icon: '💡' },
{ id: 'bug_report', label: 'BUG反馈', icon: '📪' }
];
try {
const result = await this.oauth?.api('/api/tickets/types');
const data = result?.data?.data || result?.data;
this.ticketTypes = (result?.success && data?.types) ? data.types : defaultTypes;
} catch (e) {
this.ticketTypes = defaultTypes;
}
}
// 检查未读工单数(触发式调用,支持跨标签页缓存)
// 触发时机:1. 面板打开 2. 页面可见性恢复(>10分钟) 3. 数据同步完成
async _checkUnread(forceRefresh = false) {
// 1. 检查登录状态
if (!this.oauth?.isLoggedIn()) return;
const now = Date.now();
// 2. 检查跨标签页共享缓存(除非强制刷新)
if (!forceRefresh) {
try {
const cached = GM_getValue(TicketManager.CACHE_KEY, null);
if (cached && (now - cached.time) < TicketManager.CACHE_TTL) {
// 使用缓存数据更新徽章
this.unreadCount = cached.count || 0;
this._updateBadge();
return;
}
} catch (e) { /* 缓存读取失败,继续请求 */ }
}
// 3. 发起请求
try {
const result = await this.oauth.api('/api/tickets/unread/count');
const data = result.data?.data || result.data;
if (result.success) {
this.unreadCount = data?.count || 0;
this._updateBadge();
// 更新跨标签页缓存
try {
GM_setValue(TicketManager.CACHE_KEY, { count: this.unreadCount, time: now });
} catch (e) { /* 缓存写入失败,忽略 */ }
}
} catch (e) {
// 静默失败(未登录等情况)
}
}
_updateBadge() {
const btn = document.querySelector('.ldsp-ticket-btn');
if (!btn) return;
let badge = btn.querySelector('.ldsp-ticket-badge');
if (this.unreadCount > 0) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'ldsp-ticket-badge';
btn.appendChild(badge);
}
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
} else if (badge) {
badge.remove();
}
}
async show() {
this.currentView = 'list';
this._isOverlayOpen = true;
const activeTab = this.overlay.querySelector('.ldsp-ticket-tab.active');
if (activeTab?.dataset.tab === 'create') {
this._renderCreate();
} else {
// 先显示加载状态
this._showListLoading();
}
this.overlay.classList.add('show');
// 异步加载工单列表
await this._loadTickets();
this._updateTabBadge();
if (activeTab?.dataset.tab !== 'create') {
this._renderList();
}
// 工单面板打开时立即检查一次(触发式检测)
if (!document.hidden) {
this._checkUnread(true); // 强制刷新,忽略缓存
}
}
_updateTabBadge() {
const listTab = this.overlay.querySelector('.ldsp-ticket-tab[data-tab="list"]');
if (!listTab) return;
const hasUnread = this.tickets.some(t => t.has_new_reply);
listTab.classList.toggle('has-unread', hasUnread);
}
_showListLoading() {
const body = this.overlay.querySelector('.ldsp-ticket-body');
if (!body) return;
body.classList.remove('detail-mode');
body.innerHTML = '';
}
hide() {
this._isOverlayOpen = false;
this.overlay.classList.remove('show');
this.currentView = 'list';
this.currentTicket = null;
this.overlay.querySelectorAll('.ldsp-ticket-tab').forEach(t => t.classList.remove('active'));
this.overlay.querySelector('.ldsp-ticket-tab[data-tab="list"]')?.classList.add('active');
}
async _loadTickets() {
try {
const result = await this.oauth?.api('/api/tickets');
const data = result?.data?.data || result?.data;
this.tickets = result?.success ? (data?.tickets || []) : [];
} catch (e) {
this.tickets = [];
}
}
_renderList() {
this.currentView = 'list';
const body = this.overlay.querySelector('.ldsp-ticket-body');
body.classList.remove('detail-mode');
if (this.tickets.length === 0) {
body.innerHTML = `
`;
return;
}
body.innerHTML = `
${this.tickets.map(t => `
${Utils.sanitize(t.title, 50)}
#${t.id}
${this._formatTime(t.created_at)}
`).join('')}
`;
body.querySelectorAll('.ldsp-ticket-item').forEach(item => {
item.addEventListener('click', () => this._showDetail(item.dataset.id));
});
}
_renderCreate() {
this.currentView = 'create';
const body = this.overlay.querySelector('.ldsp-ticket-body');
body.classList.remove('detail-mode');
if (!this.ticketTypes || this.ticketTypes.length === 0) {
this.ticketTypes = [
{ id: 'feature_request', label: '功能建议', icon: '💡' },
{ id: 'bug_report', label: 'BUG反馈', icon: '📪' }
];
}
// 不同类型的 placeholder 提示
const placeholders = {
'feature_request': '请详细描述您的功能建议...',
'bug_report': '请详细描述您遇到的问题,建议包含以下信息:\n\n• 浏览器及版本(如 Chrome 120)\n• 操作系统(如 Windows 11)\n• 问题复现步骤\n• 预期行为与实际行为'
};
const defaultPlaceholder = placeholders[this.ticketTypes[0]?.id] || placeholders['feature_request'];
body.innerHTML = `
`;
const textarea = body.querySelector('.ldsp-ticket-textarea');
body.querySelectorAll('.ldsp-ticket-type').forEach(type => {
type.addEventListener('click', () => {
body.querySelectorAll('.ldsp-ticket-type').forEach(t => t.classList.remove('selected'));
type.classList.add('selected');
// 根据类型更新 placeholder
const selectedType = type.dataset.type;
textarea.placeholder = placeholders[selectedType] || placeholders['feature_request'];
});
});
body.querySelector('.ldsp-ticket-submit').addEventListener('click', () => this._submitTicket());
}
async _submitTicket() {
const body = this.overlay.querySelector('.ldsp-ticket-body');
const type = body.querySelector('.ldsp-ticket-type.selected')?.dataset.type;
const title = body.querySelector('.ldsp-ticket-input')?.value.trim();
const content = body.querySelector('.ldsp-ticket-textarea')?.value.trim();
const btn = body.querySelector('.ldsp-ticket-submit');
if (!title || title.length < 4) { alert('标题至少需要4个字符'); return; }
if (title.length > 50) { alert('标题最多50个字符'); return; }
if (!content || content.length < 8) { alert('描述至少需要8个字符'); return; }
if (content.length > 500) { alert('描述最多500个字符'); return; }
btn.disabled = true;
btn.textContent = '提交中...';
try {
const result = await this.oauth.api('/api/tickets', {
method: 'POST',
body: JSON.stringify({ type: type || 'feature_request', title, content })
});
const data = result.data?.data || result.data;
if (result.success || data?.success) {
await this._loadTickets();
this.overlay.querySelectorAll('.ldsp-ticket-tab').forEach(t => t.classList.remove('active'));
this.overlay.querySelector('.ldsp-ticket-tab[data-tab="list"]')?.classList.add('active');
this._renderList();
} else {
// 检测登录失效情况
const errCode = result.error?.code;
if (errCode === 'NOT_LOGGED_IN' || errCode === 'AUTH_EXPIRED' || errCode === 'INVALID_TOKEN') {
alert('登录已失效,请重新登录后再试');
} else {
alert(result.error?.message || result.error || data?.error || '提交失败');
}
}
} catch (e) {
alert('提交失败: ' + (e.message || '网络错误'));
} finally {
btn.disabled = false;
btn.textContent = '提交工单';
}
}
async _showDetail(ticketId) {
this.currentView = 'detail';
const body = this.overlay.querySelector('.ldsp-ticket-body');
body.classList.add('detail-mode');
body.innerHTML = '';
try {
const result = await this.oauth.api(`/api/tickets/${ticketId}`);
if (!result.success) {
const errCode = result.error?.code;
if (errCode === 'NOT_LOGGED_IN' || errCode === 'AUTH_EXPIRED' || errCode === 'INVALID_TOKEN') {
throw new Error('登录已失效,请重新登录');
}
throw new Error(result.error?.message || result.error || '加载失败');
}
const data = result.data?.data || result.data;
const ticket = data?.ticket || data;
const replies = ticket?.replies || [];
this.currentTicket = ticket;
body.innerHTML = `
${Utils.sanitize(ticket.content, 2000)}
${replies.map(r => `
${Utils.sanitize(r.content, 2000)}
`).join('')}
`;
body.querySelector('.ldsp-ticket-back').addEventListener('click', () => {
this._loadTickets().then(() => this._renderList());
});
const replyBtn = body.querySelector('.ldsp-ticket-reply-btn');
if (replyBtn) {
replyBtn.addEventListener('click', () => this._sendReply(ticketId));
}
requestAnimationFrame(() => {
const messagesEl = body.querySelector('.ldsp-ticket-messages');
if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
});
if (ticket.has_new_reply) {
// 获取工单详情时已自动标记已读,只需更新本地状态
this._checkUnread();
const t = this.tickets.find(x => x.id == ticketId);
if (t) t.has_new_reply = false;
this._updateTabBadge();
}
} catch (e) {
body.innerHTML = '';
}
}
async _sendReply(ticketId) {
const body = this.overlay.querySelector('.ldsp-ticket-body');
const input = body.querySelector('.ldsp-ticket-reply-input');
const btn = body.querySelector('.ldsp-ticket-reply-btn');
const text = input?.value.trim();
if (!text) return;
btn.disabled = true;
try {
const result = await this.oauth.api(`/api/tickets/${ticketId}/reply`, {
method: 'POST',
body: JSON.stringify({ content: text })
});
if (result.success) {
this._showDetail(ticketId);
} else {
// 检测登录失效情况
const errCode = result.error?.code;
if (errCode === 'NOT_LOGGED_IN' || errCode === 'AUTH_EXPIRED' || errCode === 'INVALID_TOKEN') {
alert('登录已失效,请重新登录后再试');
} else {
alert(result.error?.message || result.error || '发送失败');
}
}
} catch (e) {
alert('网络错误');
} finally {
btn.disabled = false;
}
}
_getTypeIcon(type) {
const t = this.ticketTypes.find(x => x.id === type);
return t?.icon || '💡';
}
_getTypeLabel(type) {
const t = this.ticketTypes.find(x => x.id === type);
return t?.label || type;
}
_formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}天前`;
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// 销毁方法 - 清理定时器和事件监听
destroy() {
this._stopUnreadPoll();
// 移除页面可见性监听
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
this._visibilityHandler = null;
}
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
}
}
// ==================== 吃瓜助手 ====================
class MelonHelper {
static STORAGE_KEY = 'ldsp_melon_config';
static HISTORY_KEY = 'ldsp_melon_history';
// 简略总结提示词
static PROMPT_BRIEF = `用简洁的方式总结以下论坛讨论:`;
// 详细总结提示词
static PROMPT_DETAILED = `详细分析和总结以下论坛讨论:`;
constructor(panelBody, renderer) {
this.panelBody = panelBody;
this.renderer = renderer;
this.overlay = null;
this.config = this._loadConfig();
this.history = this._loadHistory();
this._abortController = null;
this._isEditing = false;
this._topicCache = null;
this._currentOutput = ''; // 当前输出的原始文本
this._summaryMode = 'detailed'; // 默认详细模式
this._lastUrl = location.href; // 上次URL
this._urlCheckInterval = null; // URL检测定时器
}
_loadConfig() {
try {
const saved = GM_getValue(MelonHelper.STORAGE_KEY, null);
const defaultConfig = {
apiUrl: '',
apiKey: '',
model: 'gpt-4o-mini',
promptBrief: '',
promptDetailed: ''
};
return saved ? { ...defaultConfig, ...JSON.parse(saved) } : defaultConfig;
} catch {
return { apiUrl: '', apiKey: '', model: 'gpt-4o-mini', promptBrief: '', promptDetailed: '' };
}
}
_saveConfig() {
try {
GM_setValue(MelonHelper.STORAGE_KEY, JSON.stringify(this.config));
} catch (e) {
Logger.error('[MelonHelper] Save config failed:', e);
}
}
_loadHistory() {
try {
const saved = GM_getValue(MelonHelper.HISTORY_KEY, null);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
_saveHistory() {
try {
GM_setValue(MelonHelper.HISTORY_KEY, JSON.stringify(this.history));
} catch (e) {
Logger.error('[MelonHelper] Save history failed:', e);
}
}
_addToHistory(topicId, title, summary, mode) {
// 以 topicId + mode 为主键,同一话题的简略和详细版可以同时存在
const historyKey = `${topicId}_${mode}`;
const existingIndex = this.history.findIndex(h => `${h.topicId}_${h.mode}` === historyKey);
const record = {
topicId,
title,
summary,
mode,
timestamp: Date.now()
};
if (existingIndex >= 0) {
this.history[existingIndex] = record;
} else {
this.history.unshift(record);
}
// 最多保留 100 条
if (this.history.length > 100) {
this.history = this.history.slice(0, 100);
}
this._saveHistory();
}
_clearHistory() {
this.history = [];
this._saveHistory();
}
init() {
this._createOverlay();
}
_createOverlay() {
this.overlay = document.createElement('div');
this.overlay.className = 'ldsp-melon-overlay';
this.overlay.innerHTML = `
`;
if (this.panelBody) {
this.panelBody.appendChild(this.overlay);
}
this._bindEvents();
}
_bindEvents() {
this.overlay.querySelector('.ldsp-melon-close').addEventListener('click', () => this.hide());
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && this.overlay.classList.contains('show')) this.hide();
});
this.overlay.querySelectorAll('.ldsp-melon-tab').forEach(tab => {
tab.addEventListener('click', () => {
this.overlay.querySelectorAll('.ldsp-melon-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
if (tabName === 'home') {
this._renderHome();
} else if (tabName === 'history') {
this._renderHistory();
} else if (tabName === 'settings') {
this._renderSettings();
}
});
});
}
// URL 监听 - 仅在面板打开且非话题页时启动
_startUrlWatch() {
if (this._urlCheckInterval) return;
this._urlCheckInterval = setInterval(() => {
const currentUrl = location.href;
if (currentUrl !== this._lastUrl) {
this._lastUrl = currentUrl;
this._topicCache = null; // 清空话题缓存
// 如果面板打开且在首页,检测到话题 ID 有变化时刷新
if (this.overlay.classList.contains('show')) {
const activeTab = this.overlay.querySelector('.ldsp-melon-tab.active');
if (activeTab?.dataset.tab === 'home') {
const newTopicId = this._getTopicId();
// 只有当进入新话题时才刷新(从非话题页进入话题页)
if (newTopicId) {
this._renderHome();
// 成功进入话题后停止轮询,避免频繁刷新
this._stopUrlWatch();
}
}
}
}
}, 800); // 降低检测频率
}
_stopUrlWatch() {
if (this._urlCheckInterval) {
clearInterval(this._urlCheckInterval);
this._urlCheckInterval = null;
}
}
show() {
// 更新 URL 记录
this._lastUrl = location.href;
// 检查是否切换了话题,如果是则清空缓存
const currentTopicId = this._getTopicId();
if (this._topicCache && this._topicCache.id !== currentTopicId) {
this._topicCache = null;
Logger.log('[MelonHelper] Topic changed, clearing cache');
}
this.overlay.classList.add('show');
const activeTab = this.overlay.querySelector('.ldsp-melon-tab.active');
if (activeTab?.dataset.tab === 'settings') {
this._renderSettings();
} else {
this._renderHome();
}
}
hide() {
// 停止 URL 监听
this._stopUrlWatch();
// 中止正在进行的请求
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
this.overlay.classList.remove('show');
}
_getTopicId() {
return window.location.href.match(/\/t(?:opic)?\/[^\/]+\/(\d+)/)?.[1] ||
window.location.href.match(/\/t(?:opic)?\/(\d+)/)?.[1];
}
_getReplyCount() {
const el = document.querySelector('.timeline-replies');
if (!el) return 0;
const txt = el.textContent.trim();
return parseInt(txt.includes('/') ? txt.split('/')[1] : txt) || 0;
}
async _getTopicInfo(forceRefresh = false) {
const topicId = this._getTopicId();
if (!topicId) return null;
// 使用缓存(同一话题不重复请求)
if (!forceRefresh && this._topicCache && this._topicCache.id === topicId) {
Logger.log('[MelonHelper] Using cached topic info');
return this._topicCache;
}
try {
Logger.log('[MelonHelper] Fetching topic info for:', topicId);
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
const response = await fetch(`${location.origin}/t/${topicId}.json`, {
headers: {
'x-csrf-token': csrf,
'x-requested-with': 'XMLHttpRequest'
}
});
if (!response.ok) {
Logger.warn('[MelonHelper] Topic API response not ok:', response.status);
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
Logger.log('[MelonHelper] Topic data received:', { title: data.title, posts_count: data.posts_count });
this._topicCache = {
id: topicId,
title: data.title || '未知标题',
category: data.category_id ? (document.querySelector('.category-name')?.textContent || '') : '',
postsCount: data.posts_count || 1,
replyCount: Math.max(0, (data.posts_count || 1) - 1),
views: data.views || 0,
likeCount: data.like_count || 0,
createdAt: data.created_at,
lastPostedAt: data.last_posted_at
};
return this._topicCache;
} catch (e) {
Logger.error('[MelonHelper] Get topic info failed:', e);
// 降级:从页面 DOM 获取
const fallbackInfo = {
id: topicId,
title: document.querySelector('.fancy-title, .topic-title')?.textContent?.trim() || '当前话题',
replyCount: this._getReplyCount(),
postsCount: Math.max(1, this._getReplyCount() + 1),
views: 0
};
Logger.log('[MelonHelper] Using fallback info:', fallbackInfo);
return fallbackInfo;
}
}
async _fetchDialogues(topicId, start, end, progressCallback) {
Logger.log('[MelonHelper] Fetching dialogues:', { topicId, start, end });
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
const opts = {
headers: {
'x-csrf-token': csrf,
'x-requested-with': 'XMLHttpRequest'
}
};
// 获取帖子ID列表
progressCallback?.('正在获取帖子列表...');
const idRes = await fetch(`${location.origin}/t/${topicId}/post_ids.json?post_number=0&limit=99999`, opts);
if (!idRes.ok) throw new Error(`获取帖子列表失败 (${idRes.status})`);
const idData = await idRes.json();
Logger.log('[MelonHelper] Total post IDs:', idData.post_ids?.length);
let pIds = idData.post_ids.slice(Math.max(0, start - 1), end);
Logger.log('[MelonHelper] Selected post IDs count:', pIds.length);
// 如果包含第1楼,获取主帖信息确保第一楼ID正确
if (start <= 1 && pIds.length > 0) {
const mainRes = await fetch(`${location.origin}/t/${topicId}.json`, opts);
if (mainRes.ok) {
const mainData = await mainRes.json();
const firstId = mainData.post_stream?.posts?.[0]?.id;
if (firstId && !pIds.includes(firstId)) {
pIds.unshift(firstId);
Logger.log('[MelonHelper] Added first post ID:', firstId);
}
}
}
if (pIds.length === 0) {
throw new Error('没有找到帖子内容');
}
let text = '';
const totalBatches = Math.ceil(pIds.length / 200);
// 分批获取帖子详情(每批200条)
for (let i = 0; i < pIds.length; i += 200) {
const batchNum = Math.floor(i / 200) + 1;
progressCallback?.(`正在获取帖子内容 (${batchNum}/${totalBatches})...`);
const chunk = pIds.slice(i, i + 200);
const q = chunk.map(id => `post_ids[]=${id}`).join('&');
const res = await fetch(`${location.origin}/t/${topicId}/posts.json?${q}&include_suggested=false`, opts);
if (!res.ok) throw new Error(`获取帖子详情失败 (${res.status})`);
const data = await res.json();
Logger.log('[MelonHelper] Batch', batchNum, 'posts count:', data.post_stream?.posts?.length);
text += data.post_stream.posts.map(p => {
let content = p.cooked || '';
// 处理图片
content = content.replace(
/`;
return;
}
// 已在话题页,停止 URL 监听
this._stopUrlWatch();
// 先显示加载状态
body.innerHTML = ``;
this._getTopicInfo().then(info => {
if (!info) {
body.innerHTML = `❌ 获取话题信息失败,请刷新页面后重试
`;
return;
}
const totalPosts = info.postsCount || 1;
const defaultEnd = totalPosts;
const rangeHint = totalPosts > 100 ? `共${totalPosts}楼,内容较多可能需要较长时间` : `共${totalPosts}楼`;
// 检查是否已配置 API
const hasConfig = this.config.apiUrl && this.config.apiKey;
body.innerHTML = `
📋 ${Utils.escapeHtml(info.title)}
总楼层
${totalPosts} 楼
${info.views ? `
浏览量
${info.views.toLocaleString()}
` : ''}
${info.likeCount ? `
点赞数
${info.likeCount}
` : ''}
${!hasConfig ? `⚠️ 请先在「设置」中配置 API 信息
` : ''}
楼层范围
~
${rangeHint}
🍉
立即吃瓜
`;
// 绑定模式选择
body.querySelectorAll('input[name="melon-mode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
this._summaryMode = e.target.value;
// 更新卡片active状态
body.querySelectorAll('.ldsp-melon-mode-card').forEach(card => {
card.classList.toggle('active', card.querySelector('input').value === e.target.value);
});
});
});
// 绑定复制按钮
body.querySelector('#melon-copy').addEventListener('click', () => this._copyOutput());
// 绑定展开按钮 - 打开独立大窗口
body.querySelector('#melon-expand').addEventListener('click', () => {
if (this._currentOutput) {
this._showViewer({
title: info.title,
summary: this._currentOutput,
mode: this._summaryMode,
topicId: info.id
});
}
});
body.querySelector('#melon-summarize').addEventListener('click', () => this._doSummarize(info));
}).catch(e => {
Logger.error('[MelonHelper] Render home error:', e);
body.innerHTML = `❌ 获取话题信息失败: ${Utils.escapeHtml(e.message)}
`;
});
}
async _copyOutput() {
if (!this._currentOutput) {
return;
}
try {
await navigator.clipboard.writeText(this._currentOutput);
const copyBtn = this.overlay.querySelector('#melon-copy');
if (copyBtn) {
const originalText = copyBtn.innerHTML;
copyBtn.innerHTML = '✅ 已复制 ';
setTimeout(() => {
copyBtn.innerHTML = originalText;
}, 1500);
}
} catch (e) {
Logger.error('[MelonHelper] Copy failed:', e);
}
}
async _doSummarize(topicInfo) {
const body = this.overlay.querySelector('.ldsp-melon-body');
const btn = body.querySelector('#melon-summarize');
const output = body.querySelector('#melon-output');
const outputHeader = body.querySelector('.ldsp-melon-output-header');
const startInput = body.querySelector('#melon-start');
const endInput = body.querySelector('#melon-end');
const start = parseInt(startInput.value) || 1;
const end = parseInt(endInput.value) || topicInfo.postsCount;
Logger.log('[MelonHelper] Starting summarize:', { topicId: topicInfo.id, start, end, mode: this._summaryMode });
if (start > end) {
output.innerHTML = '❌ 起始楼层不能大于结束楼层
';
return;
}
if (start < 1) {
output.innerHTML = '❌ 起始楼层不能小于1
';
return;
}
if (!this.config.apiUrl || !this.config.apiKey) {
output.innerHTML = '❌ 请先在「设置」中配置 API 地址和密钥
';
return;
}
btn.disabled = true;
btn.classList.add('loading');
this._currentOutput = ''; // 清空当前输出
outputHeader.style.display = 'none'; // 隐藏复制按钮
const updateStatus = (msg) => {
btn.innerHTML = `⏳ ${msg} `;
};
updateStatus('获取帖子内容...');
output.innerHTML = '';
try {
// 1. 获取帖子内容
const dialogues = await this._fetchDialogues(topicInfo.id, start, end, updateStatus);
Logger.log('[MelonHelper] Dialogues length:', dialogues?.length);
if (!dialogues || dialogues.trim().length < 20) {
throw new Error('获取帖子内容失败或内容为空');
}
updateStatus('AI 分析中...');
output.innerHTML = '
▌
';
// 2. 根据模式选择提示词(优先使用自定义提示词)
const prompt = this._summaryMode === 'brief'
? (this.config.promptBrief || MelonHelper.PROMPT_BRIEF)
: (this.config.promptDetailed || MelonHelper.PROMPT_DETAILED);
const userContent = `话题标题: ${topicInfo.title}\n\n帖子内容 (第${start}楼 ~ 第${end}楼):\n${dialogues}`;
Logger.log('[MelonHelper] Calling AI, content length:', userContent.length, 'mode:', this._summaryMode);
this._abortController = new AbortController();
// 3. 流式调用 AI 接口
await this._callAIStream(prompt, userContent, this._abortController.signal, (chunk) => {
// 增量更新
this._currentOutput += chunk;
const contentDiv = output.querySelector('.ldsp-melon-output-content');
if (contentDiv) {
contentDiv.innerHTML = this._renderMarkdown(this._currentOutput);
// 自动滚动到底部
output.scrollTop = output.scrollHeight;
}
});
// 移除光标
const cursor = output.querySelector('.ldsp-melon-cursor');
if (cursor) cursor.remove();
Logger.log('[MelonHelper] AI response complete, length:', this._currentOutput?.length);
// 显示复制按钮
outputHeader.style.display = 'flex';
// 保存到历史
this._addToHistory(topicInfo.id, topicInfo.title, this._currentOutput, this._summaryMode);
this.renderer?.showToast('✅ 吃瓜完成!');
} catch (e) {
Logger.error('[MelonHelper] Summarize error:', e);
// 移除光标
const cursor = output.querySelector('.ldsp-melon-cursor');
if (cursor) cursor.remove();
if (e.name === 'AbortError') {
output.innerHTML = '';
} else {
output.innerHTML = `❌ ${Utils.escapeHtml(e.message || '请求失败')}
`;
}
} finally {
btn.disabled = false;
btn.classList.remove('loading');
btn.innerHTML = '🍉 立即吃瓜 ';
this._abortController = null;
}
}
async _callAIStream(systemPrompt, userContent, signal, onChunk) {
const { apiUrl, apiKey, model } = this.config;
// 构建流式请求体
const requestBody = {
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
],
max_tokens: 4096,
temperature: 0.7,
stream: true // 启用流式输出
};
Logger.log('[MelonHelper] Calling AI API (stream):', apiUrl);
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(requestBody),
signal: signal
});
Logger.log('[MelonHelper] API response status:', response.status);
if (!response.ok) {
let errMsg = `请求失败 (${response.status})`;
try {
const errData = await response.json();
errMsg = errData.error?.message || errMsg;
Logger.error('[MelonHelper] API error response:', errData);
} catch {}
throw new Error(errMsg);
}
// 处理 SSE 流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留未完成的行
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
if (!trimmed.startsWith('data: ')) continue;
try {
const json = JSON.parse(trimmed.slice(6));
const content = json.choices?.[0]?.delta?.content;
if (content) {
onChunk(content);
}
} catch (e) {
// 忽略解析错误
Logger.log('[MelonHelper] SSE parse skip:', trimmed);
}
}
}
Logger.log('[MelonHelper] Stream complete');
} catch (e) {
if (e.name === 'AbortError') {
throw e;
}
Logger.error('[MelonHelper] Stream fetch error:', e);
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
throw new Error('网络请求失败,请检查 API 地址是否正确,或该 API 是否支持跨域请求');
}
throw e;
}
}
_renderMarkdown(md) {
if (!md) return '';
let html = md;
// 1. 保护代码块,先提取出来
const codeBlocks = [];
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
const placeholder = `%%CODEBLOCK_${codeBlocks.length}%%`;
codeBlocks.push(`${Utils.escapeHtml(code.trim())} `);
return placeholder;
});
// 2. 行内代码
const inlineCodes = [];
html = html.replace(/`([^`\n]+)`/g, (match, code) => {
const placeholder = `%%INLINECODE_${inlineCodes.length}%%`;
inlineCodes.push(`${Utils.escapeHtml(code)}`);
return placeholder;
});
// 3. 标题 - 支持 emoji 开头的标题
html = html.replace(/^#### (.+)$/gm, '$1 ');
html = html.replace(/^### (.+)$/gm, '$1 ');
html = html.replace(/^## (.+)$/gm, '$1 ');
html = html.replace(/^# (.+)$/gm, '$1 ');
// 4. 粗体 - 修复跨行和 emoji 后的情况
html = html.replace(/\*\*([^*]+?)\*\*/g, '$1 ');
html = html.replace(/__([^_]+?)__/g, '$1 ');
// 5. 斜体
html = html.replace(/(?$1');
html = html.replace(/(?$1');
// 6. 引用块
html = html.replace(/^> (.+)$/gm, '$1 ');
html = html.replace(/<\/blockquote>\n/g, ' ');
// 7. 无序列表
html = html.replace(/^[-*] (.+)$/gm, '$1 ');
html = html.replace(/((?:[^<]*<\/li>\n?)+)/g, '');
// 8. 有序列表
html = html.replace(/^\d+\. (.+)$/gm, ' $1 ');
html = html.replace(/((?:[^<]*<\/li>\n?)+)/g, '$1 ');
// 9. 链接
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ');
// 10. 分隔线
html = html.replace(/^---+$/gm, ' ');
html = html.replace(/^\*\*\*+$/gm, ' ');
// 11. 段落处理
html = html.replace(/\n\n+/g, '
');
html = html.replace(/\n/g, ' ');
// 12. 清理
html = html.replace(/
<\/p>/g, '');
html = html.replace(/
()<\/p>/g, '$1');
html = html.replace(/(
)<\/p>/g, '$1');
html = html.replace(/(
)<\/p>/g, '$1');
html = html.replace(/(
)<\/p>/g, '$1');
html = html.replace(/(
)<\/p>/g, '$1');
html = html.replace(/ <(h[2-5]|ul|ol|blockquote|pre)/g, '<$1');
html = html.replace(/<\/(h[2-5]|ul|ol|blockquote|pre)> /g, '$1>');
// 13. 恢复代码块
codeBlocks.forEach((block, i) => {
html = html.replace(`%%CODEBLOCK_${i}%%`, block);
});
inlineCodes.forEach((code, i) => {
html = html.replace(`%%INLINECODE_${i}%%`, code);
});
return ``;
}
_renderSettings() {
const body = this.overlay.querySelector('.ldsp-melon-body');
const hasConfig = this.config.apiUrl && this.config.apiKey;
// 如果已有配置,默认不可编辑状态
const isEditing = !hasConfig || this._isEditing;
body.innerHTML = `
${isEditing ? `
💾 保存设置
` : `
✏️ 编辑设置
`}
📝 自定义提示词
自定义 AI 总结的提示词,留空则使用默认提示词
简略模式提示词
🗑️
${this.config.promptBrief ? '✅ 已自定义' : '💡 使用默认提示词'}
详细模式提示词
🗑️
${this.config.promptDetailed ? '✅ 已自定义' : '💡 使用默认提示词'}
💾 保存提示词
🔒
数据安全说明
您的 API Key 等配置信息仅保存在浏览器本地存储中,不会上传到任何服务器。请放心使用。
🗑️ 数据清理
清空所有本地存储的数据,包括 API 配置和历史记录
🗑️ 清空所有数据
`;
if (isEditing) {
body.querySelector('#melon-save-settings').addEventListener('click', () => {
const apiUrl = body.querySelector('#melon-api-url').value.trim();
const apiKey = body.querySelector('#melon-api-key').value.trim();
const model = body.querySelector('#melon-model').value.trim() || 'gpt-4o-mini';
// 验证必填项
if (!apiUrl) {
this.renderer?.showToast('⚠️ 请输入 API 地址');
return;
}
if (!apiKey) {
this.renderer?.showToast('⚠️ 请输入 API Key');
return;
}
// 简单验证 URL 格式
if (!apiUrl.startsWith('http://') && !apiUrl.startsWith('https://')) {
this.renderer?.showToast('⚠️ API 地址格式不正确');
return;
}
this.config.apiUrl = apiUrl;
this.config.apiKey = apiKey;
this.config.model = model;
this._saveConfig();
this._isEditing = false;
this.renderer?.showToast('✅ 设置已保存');
this._renderSettings(); // 重新渲染为不可编辑状态
});
} else {
body.querySelector('#melon-edit-settings').addEventListener('click', () => {
this._isEditing = true;
this._renderSettings(); // 重新渲染为可编辑状态
});
}
// 保存提示词按钮
body.querySelector('#melon-save-prompts')?.addEventListener('click', () => {
const promptBrief = body.querySelector('#melon-prompt-brief').value.trim();
const promptDetailed = body.querySelector('#melon-prompt-detailed').value.trim();
this.config.promptBrief = promptBrief;
this.config.promptDetailed = promptDetailed;
this._saveConfig();
this.renderer?.showToast('✅ 提示词已保存');
// 更新状态提示
const hintBrief = body.querySelector('#melon-prompt-brief')?.parentElement?.querySelector('.ldsp-melon-setting-hint');
const hintDetailed = body.querySelector('#melon-prompt-detailed')?.parentElement?.querySelector('.ldsp-melon-setting-hint');
if (hintBrief) hintBrief.textContent = promptBrief ? '✅ 已自定义' : '💡 使用默认提示词';
if (hintDetailed) hintDetailed.textContent = promptDetailed ? '✅ 已自定义' : '💡 使用默认提示词';
});
// 恢复默认提示词
body.querySelectorAll('.ldsp-melon-prompt-reset').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.prompt;
if (type === 'brief') {
body.querySelector('#melon-prompt-brief').value = '';
this.config.promptBrief = '';
} else {
body.querySelector('#melon-prompt-detailed').value = '';
this.config.promptDetailed = '';
}
this._saveConfig();
this.renderer?.showToast('✅ 已恢复默认提示词');
});
});
// 清空数据按钮
body.querySelector('#melon-clear-all-data')?.addEventListener('click', () => {
this._showConfirm('确定要清空所有数据吗? 包括 API 配置、提示词和历史记录,此操作不可撤销。', () => {
// 清空配置
this.config = { apiUrl: '', apiKey: '', model: 'gpt-4o-mini', promptBrief: '', promptDetailed: '' };
this._saveConfig();
// 清空历史
this._clearHistory();
this._isEditing = false;
this.renderer?.showToast('✅ 所有数据已清空');
this._renderSettings();
});
});
}
_renderHistory() {
const body = this.overlay.querySelector('.ldsp-melon-body');
if (this.history.length === 0) {
body.innerHTML = `
📭
暂无历史记录
使用吃瓜助手总结话题后会自动保存到这里
💾 数据仅存储在浏览器本地
`;
return;
}
const historyHtml = this.history.map((h, idx) => {
const date = new Date(h.timestamp);
const dateStr = `${date.getMonth() + 1}/${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
const modeLabel = h.mode === 'brief' ? '简略' : '详细';
const previewText = h.summary.slice(0, 100).replace(/\n/g, ' ') + (h.summary.length > 100 ? '...' : '');
return `
${Utils.escapeHtml(previewText)}
👁️ 查看
🔍 展开
📋 复制
🔗 话题
🗑️
`;
}).join('');
body.innerHTML = `
`;
// 绑定事件 - 使用自定义确认框
body.querySelector('#melon-clear-history').addEventListener('click', () => {
this._showConfirm('确定要清空所有历史记录吗? 此操作不可撤销。', () => {
this._clearHistory();
this.renderer?.showToast('✅ 历史记录已清空');
this._renderHistory();
});
});
body.querySelectorAll('.ldsp-melon-history-view').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
this._showHistoryDetail(idx);
});
});
body.querySelectorAll('.ldsp-melon-history-copy').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx);
const record = this.history[idx];
if (record) {
try {
await navigator.clipboard.writeText(record.summary);
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.innerHTML = '📋 复制'; }, 1500);
} catch (e) {
Logger.error('[MelonHelper] Copy history failed:', e);
}
}
});
});
body.querySelectorAll('.ldsp-melon-history-goto').forEach(btn => {
btn.addEventListener('click', () => {
const topicId = btn.dataset.topic;
window.open(`${location.origin}/t/${topicId}`, '_blank');
});
});
body.querySelectorAll('.ldsp-melon-history-delete').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
this.history.splice(idx, 1);
this._saveHistory();
this.renderer?.showToast('✅ 已删除');
this._renderHistory();
});
});
// 绑定展开按钮 - 打开独立大窗口
body.querySelectorAll('.ldsp-melon-history-expand').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
const record = this.history[idx];
if (record) {
this._showViewer({
title: record.title,
summary: record.summary,
mode: record.mode,
topicId: record.topicId,
timestamp: record.timestamp
});
}
});
});
}
_showHistoryDetail(idx) {
const record = this.history[idx];
if (!record) return;
const body = this.overlay.querySelector('.ldsp-melon-body');
const date = new Date(record.timestamp);
const dateStr = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
const modeLabel = record.mode === 'brief' ? '简略模式' : '详细模式';
body.innerHTML = `
${Utils.escapeHtml(record.title)}
${modeLabel} · ${dateStr}
${this._renderMarkdown(record.summary)}
`;
body.querySelector('#melon-history-back').addEventListener('click', () => {
this._renderHistory();
});
// 展开查看按钮
body.querySelector('#melon-history-expand').addEventListener('click', () => {
this._showViewer({
title: record.title,
summary: record.summary,
mode: record.mode,
topicId: record.topicId,
timestamp: record.timestamp
});
});
body.querySelector('#melon-history-copy-all').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(record.summary);
const btn = body.querySelector('#melon-history-copy-all');
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.innerHTML = '📋 复制'; }, 1500);
} catch (e) {
Logger.error('[MelonHelper] Copy detail failed:', e);
}
});
}
destroy() {
this._stopUrlWatch();
this._closeViewer();
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
}
// 显示全屏可调整大小的查看器
_showViewer(data) {
// 移除已存在的 viewer
this._closeViewer();
const dateStr = data.timestamp
? new Date(data.timestamp).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '刚刚';
const modeLabel = data.mode === 'brief' ? '简略' : '详细';
// 检测当前主题
const isLightTheme = document.querySelector('#ldsp-panel.light') !== null;
const overlay = document.createElement('div');
overlay.className = 'ldsp-melon-viewer-overlay' + (isLightTheme ? ' light' : '');
overlay.innerHTML = `
${Utils.escapeHtml(data.title)}
${modeLabel}模式
${dateStr}
${this._renderMarkdown(data.summary)}
`;
document.body.appendChild(overlay);
this._viewerOverlay = overlay;
const viewer = overlay.querySelector('.ldsp-melon-viewer');
const header = overlay.querySelector('.ldsp-melon-viewer-header');
// 关闭按钮
overlay.querySelector('#viewer-close').addEventListener('click', () => this._closeViewer());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this._closeViewer();
});
// ESC 关闭
this._viewerEscHandler = (e) => {
if (e.key === 'Escape') this._closeViewer();
};
document.addEventListener('keydown', this._viewerEscHandler);
// 复制按钮
overlay.querySelector('#viewer-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(data.summary);
const btn = overlay.querySelector('#viewer-copy');
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 1500);
} catch (e) {
Logger.error('[MelonHelper] Copy viewer content failed:', e);
}
});
// 跳转按钮
overlay.querySelector('#viewer-goto').addEventListener('click', () => {
if (data.topicId) {
window.open(`${location.origin}/t/${data.topicId}`, '_blank');
}
});
// 拖拽移动
let isDragging = false;
let startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.ldsp-melon-viewer-btn')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = viewer.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
viewer.style.position = 'fixed';
viewer.style.left = `${startLeft}px`;
viewer.style.top = `${startTop}px`;
viewer.style.transform = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', this._viewerMoveHandler = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
viewer.style.left = `${startLeft + dx}px`;
viewer.style.top = `${startTop + dy}px`;
});
document.addEventListener('mouseup', this._viewerUpHandler = () => {
isDragging = false;
});
// 调整大小
let isResizing = false;
let resizeDir = '';
let resizeStartX, resizeStartY, resizeStartW, resizeStartH, resizeStartL, resizeStartT;
overlay.querySelectorAll('.ldsp-melon-resize-handle').forEach(handle => {
handle.addEventListener('mousedown', (e) => {
isResizing = true;
resizeDir = handle.className.replace('ldsp-melon-resize-handle ldsp-melon-resize-handle-', '');
resizeStartX = e.clientX;
resizeStartY = e.clientY;
const rect = viewer.getBoundingClientRect();
resizeStartW = rect.width;
resizeStartH = rect.height;
resizeStartL = rect.left;
resizeStartT = rect.top;
viewer.style.position = 'fixed';
viewer.style.left = `${resizeStartL}px`;
viewer.style.top = `${resizeStartT}px`;
viewer.style.transform = 'none';
e.preventDefault();
e.stopPropagation();
});
});
document.addEventListener('mousemove', this._viewerResizeHandler = (e) => {
if (!isResizing) return;
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
let newW = resizeStartW, newH = resizeStartH;
let newL = resizeStartL, newT = resizeStartT;
if (resizeDir.includes('e')) newW = Math.max(320, resizeStartW + dx);
if (resizeDir.includes('w')) {
newW = Math.max(320, resizeStartW - dx);
newL = resizeStartL + (resizeStartW - newW);
}
if (resizeDir.includes('s')) newH = Math.max(240, resizeStartH + dy);
if (resizeDir.includes('n')) {
newH = Math.max(240, resizeStartH - dy);
newT = resizeStartT + (resizeStartH - newH);
}
viewer.style.width = `${newW}px`;
viewer.style.height = `${newH}px`;
viewer.style.left = `${newL}px`;
viewer.style.top = `${newT}px`;
});
document.addEventListener('mouseup', this._viewerResizeUpHandler = () => {
isResizing = false;
});
}
_closeViewer() {
if (this._viewerOverlay) {
this._viewerOverlay.remove();
this._viewerOverlay = null;
}
if (this._viewerEscHandler) {
document.removeEventListener('keydown', this._viewerEscHandler);
this._viewerEscHandler = null;
}
if (this._viewerMoveHandler) {
document.removeEventListener('mousemove', this._viewerMoveHandler);
this._viewerMoveHandler = null;
}
if (this._viewerUpHandler) {
document.removeEventListener('mouseup', this._viewerUpHandler);
this._viewerUpHandler = null;
}
if (this._viewerResizeHandler) {
document.removeEventListener('mousemove', this._viewerResizeHandler);
this._viewerResizeHandler = null;
}
if (this._viewerResizeUpHandler) {
document.removeEventListener('mouseup', this._viewerResizeUpHandler);
this._viewerResizeUpHandler = null;
}
}
// 显示自定义确认对话框
_showConfirm(message, onConfirm) {
const existingDialog = this.overlay.querySelector('.ldsp-melon-confirm-dialog');
if (existingDialog) existingDialog.remove();
const dialog = document.createElement('div');
dialog.className = 'ldsp-melon-confirm-dialog';
dialog.innerHTML = `
`;
dialog.querySelector('.ldsp-melon-confirm-cancel').addEventListener('click', () => dialog.remove());
dialog.querySelector('.ldsp-melon-confirm-ok').addEventListener('click', () => {
dialog.remove();
onConfirm();
});
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.remove();
});
this.overlay.appendChild(dialog);
}
}
// ==================== 关注/粉丝管理器 ====================
class FollowManager {
static CACHE_KEY = 'ldsp_follow_cache';
// SVG 图标定义
static ICONS = {
// 关注:人+右箭头(表示我关注的人/向外关注)
following: ' ',
// 粉丝:人+心形(表示喜欢我的人)
followers: ' ',
// 用户组图标(用于标题)
users: ' ',
// 单人图标(用于空状态)
user: ' '
};
constructor(network, storage, panelBody, renderer) {
this.network = network;
this.storage = storage;
this.panelBody = panelBody;
this.renderer = renderer;
this.overlay = null;
this.following = [];
this.followers = [];
this.followingCount = 0;
this.followersCount = 0;
this.cakedate = null;
this.animatedAvatar = null;
this._loaded = false;
this._loading = false;
this._profileLoaded = false;
}
async init() {
// 先从缓存加载数量显示
this._loadFromCache();
this._createOverlay();
}
// 从本地缓存加载
_loadFromCache() {
try {
const username = this.storage.getUser();
if (!username) return;
const cacheKey = `${FollowManager.CACHE_KEY}_${CURRENT_SITE.prefix}_${username}`;
const cached = GM_getValue(cacheKey, null);
if (cached && typeof cached === 'object') {
this.followingCount = cached.followingCount || 0;
this.followersCount = cached.followersCount || 0;
this.cakedate = cached.cakedate || null;
this.animatedAvatar = cached.animatedAvatar || null;
// 立即更新显示
this._updateStats();
this._updateDays();
}
} catch (e) {
// 缓存读取失败,忽略
}
}
// 保存到本地缓存
_saveToCache() {
try {
const username = this.storage.getUser();
if (!username) return;
const cacheKey = `${FollowManager.CACHE_KEY}_${CURRENT_SITE.prefix}_${username}`;
GM_setValue(cacheKey, {
followingCount: this.followingCount,
followersCount: this.followersCount,
cakedate: this.cakedate,
animatedAvatar: this.animatedAvatar,
time: Date.now()
});
} catch (e) {
// 缓存写入失败,忽略
}
}
_createOverlay() {
this.overlay = document.createElement('div');
this.overlay.className = 'ldsp-follow-overlay';
this.overlay.innerHTML = `
${FollowManager.ICONS.following}
关注
${this.followingCount}
${FollowManager.ICONS.followers}
粉丝
${this.followersCount}
`;
if (this.panelBody) {
this.panelBody.appendChild(this.overlay);
}
this._bindEvents();
}
_bindEvents() {
this.overlay.querySelector('.ldsp-follow-close').addEventListener('click', () => this.hide());
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && this.overlay.classList.contains('show')) this.hide();
});
this.overlay.querySelectorAll('.ldsp-follow-tab').forEach(tab => {
tab.addEventListener('click', () => {
this.overlay.querySelectorAll('.ldsp-follow-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
this._renderList(tabName);
});
});
}
async loadData(forceRefresh = false) {
if (this._loading) return;
if (this._loaded && !forceRefresh) return;
this._loading = true;
const username = this.storage.getUser();
if (!username) {
this._loading = false;
return;
}
// 保存旧的数量用于比较
const oldFollowingCount = this.followingCount;
const oldFollowersCount = this.followersCount;
const baseUrl = `https://${CURRENT_SITE.domain}`;
try {
const [followingRes, followersRes] = await Promise.all([
this.network.fetchJson(`${baseUrl}/u/${username}/follow/following`),
this.network.fetchJson(`${baseUrl}/u/${username}/follow/followers`)
]);
this.following = Array.isArray(followingRes) ? followingRes : [];
this.followers = Array.isArray(followersRes) ? followersRes : [];
this.followingCount = this.following.length;
this.followersCount = this.followers.length;
this._loaded = true;
// 检查是否有变化,有变化则更新缓存
if (this.followingCount !== oldFollowingCount || this.followersCount !== oldFollowersCount) {
this._saveToCache();
}
// 更新显示
this._updateStats();
this._updateTabCounts();
} catch (e) {
Logger.warn('Failed to load follow data:', e.message);
// 加载失败时保持缓存的数量,不清零
} finally {
this._loading = false;
}
}
// 加载用户 profile 数据(cakedate, animated_avatar)
async loadProfile() {
if (this._profileLoaded) return;
const username = this.storage.getUser();
if (!username) return;
const baseUrl = `https://${CURRENT_SITE.domain}`;
try {
const profileRes = await this.network.fetchJson(`${baseUrl}/u/${encodeURIComponent(username)}.json`);
if (profileRes && profileRes.user) {
const user = profileRes.user;
let hasChanges = false;
// 获取 cakedate
if (user.cakedate && user.cakedate !== this.cakedate) {
this.cakedate = user.cakedate;
this._updateDays();
hasChanges = true;
}
// 获取 animated_avatar
if (user.animated_avatar) {
const animatedUrl = user.animated_avatar.startsWith('http')
? user.animated_avatar
: `${baseUrl}${user.animated_avatar}`;
// 只有动态头像变化时才更新
if (animatedUrl !== this.animatedAvatar) {
this.animatedAvatar = animatedUrl;
// 通过 renderer 渲染头像
if (this.renderer) {
this.renderer.renderAvatar(animatedUrl);
}
hasChanges = true;
}
}
this._profileLoaded = true;
if (hasChanges) {
this._saveToCache();
}
}
} catch (e) {
Logger.warn('Failed to load user profile:', e.message);
}
}
// 更新注册天数显示
_updateDays() {
const daysNumEl = document.querySelector('.ldsp-join-days-num');
const siteEl = document.querySelector('.ldsp-join-days-site');
const joinDaysEl = document.querySelector('.ldsp-join-days');
if (!this.cakedate) {
if (joinDaysEl) joinDaysEl.style.display = 'none';
return;
}
try {
const joinDate = new Date(this.cakedate);
const now = new Date();
const diffTime = Math.abs(now - joinDate);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const siteShort = CURRENT_SITE.domain === 'linux.do' ? 'L站' : 'IF站';
const siteFull = CURRENT_SITE.domain === 'linux.do' ? 'Linux.do' : 'IDCFlare';
if (siteEl) siteEl.textContent = siteShort;
if (daysNumEl) daysNumEl.textContent = diffDays;
if (joinDaysEl) {
joinDaysEl.style.display = '';
// 设置悬浮提示:于xxxx年xx月xx日加入xx站
const year = joinDate.getFullYear();
const month = joinDate.getMonth() + 1;
const day = joinDate.getDate();
joinDaysEl.title = `于${year}年${month}月${day}日加入${siteFull}`;
}
} catch (e) {
if (joinDaysEl) joinDaysEl.style.display = 'none';
}
}
_updateStats() {
// 支持新的合并按钮样式
const combinedEl = document.querySelector('.ldsp-follow-combined');
if (combinedEl) {
const followingNum = combinedEl.querySelector('.ldsp-follow-num-following');
const followersNum = combinedEl.querySelector('.ldsp-follow-num-followers');
if (followingNum) followingNum.textContent = this.followingCount;
if (followersNum) followersNum.textContent = this.followersCount;
}
// 兼容旧样式
const statsEl = document.querySelector('.ldsp-follow-stats');
if (statsEl) {
const following = statsEl.querySelector('.ldsp-follow-stat-following .ldsp-follow-stat-num');
const followers = statsEl.querySelector('.ldsp-follow-stat-followers .ldsp-follow-stat-num');
if (following) following.textContent = this.followingCount;
if (followers) followers.textContent = this.followersCount;
}
}
_updateTabCounts() {
const followingTab = this.overlay.querySelector('.ldsp-follow-tab[data-tab="following"] .ldsp-follow-tab-count');
const followersTab = this.overlay.querySelector('.ldsp-follow-tab[data-tab="followers"] .ldsp-follow-tab-count');
if (followingTab) followingTab.textContent = this.followingCount;
if (followersTab) followersTab.textContent = this.followersCount;
}
async show() {
this.overlay.classList.add('show');
const body = this.overlay.querySelector('.ldsp-follow-body');
body.innerHTML = '';
// 加载数据(如果还没加载)
if (!this._loaded) {
await this.loadData();
}
// 渲染当前激活的tab
const activeTab = this.overlay.querySelector('.ldsp-follow-tab.active');
this._renderList(activeTab?.dataset.tab || 'following');
}
hide() {
this.overlay.classList.remove('show');
}
_renderList(type = 'following') {
const body = this.overlay.querySelector('.ldsp-follow-body');
const list = type === 'following' ? this.following : this.followers;
const emptyText = type === 'following' ? '还没有关注任何人' : '还没有粉丝';
const emptyIcon = type === 'following' ? FollowManager.ICONS.user : FollowManager.ICONS.followers;
if (list.length === 0) {
body.innerHTML = `
${emptyIcon}
${emptyText}
`;
return;
}
const baseUrl = `https://${CURRENT_SITE.domain}`;
body.innerHTML = `
`;
}
_getAvatarUrl(template, animatedAvatar, size = 64) {
if (!template) return '';
// 优先使用动画头像
if (animatedAvatar) {
return `https://${CURRENT_SITE.domain}${animatedAvatar}`;
}
// 替换size占位符
let url = template.replace('{size}', size);
// 如果是相对路径,添加域名
if (url.startsWith('/')) {
url = `https://${CURRENT_SITE.domain}${url}`;
}
return url;
}
getStats() {
return {
following: this.followingCount,
followers: this.followersCount
};
}
destroy() {
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
}
}
// ==================== 面板渲染器 ====================
class Renderer {
constructor(panel) {
this.panel = panel;
this.prevValues = new Map();
this.lastPct = -1;
}
// 渲染用户信息
renderUser(name, level, isOK, reqs, displayName = null) {
const done = reqs.filter(r => r.isSuccess).length;
const $ = this.panel.$;
// XSS 防护:使用 textContent 而不是 innerHTML,并清理输入
const safeName = Utils.sanitize(name, 30);
const safeDisplayName = Utils.sanitize(displayName, 100);
// 如果有 displayName 则显示 displayName + @username,否则只显示 username
if (safeDisplayName && safeDisplayName !== safeName) {
$.userDisplayName.textContent = safeDisplayName;
$.userHandle.textContent = `@${safeName}`;
$.userHandle.style.display = '';
} else {
$.userDisplayName.textContent = safeName;
$.userHandle.textContent = '';
$.userHandle.style.display = 'none';
}
}
// 渲染需求列表
renderReqs(reqs, level = null) {
const done = reqs.filter(r => r.isSuccess).length;
const remain = reqs.length - done;
const pct = Math.round(done / reqs.length * 100);
const cfg = Screen.getConfig();
const r = (cfg.ringSize / 2) - 8;
const circ = 2 * Math.PI * r;
const off = circ * (1 - pct / 100);
const anim = this.lastPct === -1 || this.lastPct !== pct || this.panel.animRing;
this.lastPct = pct;
this.panel.animRing = false;
// 使用缓存的level或传入的level
// v3.4.7: 确保 level 是数字类型,避免字符串拼接 bug (如 "2" + 1 = "21")
// 注意:不能用 || 2,因为 0 级用户会被错误地当作 2 级
// 优先级:传入的 level > panel.cachedLevel > OAuth userInfo > 默认值 2
const parsedLevel = level !== null ? parseInt(level, 10) : NaN;
let currentLevel;
if (!isNaN(parsedLevel)) {
currentLevel = parsedLevel;
} else if (this.panel.cachedLevel !== undefined) {
currentLevel = this.panel.cachedLevel;
} else {
// 尝试从 OAuth 用户信息获取(Tab 切换时的 fallback)
const oauthUser = this.panel.oauth?.getUserInfo?.();
const oauthLevel = oauthUser?.trust_level ?? oauthUser?.trustLevel;
currentLevel = typeof oauthLevel === 'number' ? oauthLevel : 2;
}
if (level !== null && !isNaN(parsedLevel)) this.panel.cachedLevel = currentLevel;
// 普通用户最高只能升级到LV3,LV4需要管理员手动授予
const maxTargetLevel = 3;
const canUpgrade = currentLevel < maxTargetLevel;
const targetLevel = canUpgrade ? currentLevel + 1 : currentLevel;
let tipText, tipClass;
if (!canUpgrade) {
tipText = currentLevel >= 4 ? '🏆 已达最高等级' : '🎖️ 已达普通用户最高等级';
tipClass = 'max';
} else if (remain > 0) {
tipText = `⏳ 距升级还需完成 ${remain} 项要求`;
tipClass = 'progress';
} else {
tipText = '🎉 已满足升级条件';
tipClass = 'ok';
}
const confettiColors = ['#5070d0', '#5bb5a6', '#f97316', '#22c55e', '#eab308', '#ec4899', '#f43f5e', '#6b8cef'];
const confettiPieces = pct === 100 ? Array.from({length: 28}, (_, i) => {
const color = confettiColors[i % confettiColors.length];
const angle = (i / 28) * 360 + (Math.random() - 0.5) * 25;
const rad = angle * Math.PI / 180;
const dist = 55 + Math.random() * 45;
const tx = Math.cos(rad) * dist;
const ty = Math.sin(rad) * dist * 0.7;
const drift = (Math.random() - 0.5) * 40;
const rot = (Math.random() - 0.5) * 900;
const delay = Math.random() * 0.06;
const shape = ['\u25cf', '\u25a0', '\u2605', '\u2764', '\u2728', '\u2740'][Math.floor(Math.random() * 6)];
return `${shape} `;
}).join('') : '';
// 使用数组构建HTML(避免多次字符串拼接)
const htmlParts = [];
htmlParts.push(``);
if (pct === 100) htmlParts.push(`
${confettiPieces}
`);
htmlParts.push(`
${canUpgrade ? `Lv${currentLevel} → Lv${targetLevel}` : `Lv${currentLevel} ★`}
${tipText}
`);
// 批量处理需求项(减少Map查询和字符串操作)
for (const r of reqs) {
const name = Utils.simplifyName(r.name);
const prev = this.prevValues.get(r.name);
const upd = prev !== undefined && prev !== r.currentValue;
const changeHtml = r.change
? `${r.change > 0 ? '+' : ''}${r.change} `
: '';
htmlParts.push(`
${r.isSuccess ? '✓' : '○'}
${name}
${r.currentValue}
/
${r.requiredValue}
${changeHtml}
`);
this.prevValues.set(r.name, r.currentValue);
}
// 添加底部了解信任等级的提示链接
htmlParts.push(`了解论坛信任等级 → `);
this.panel.$.reqs.innerHTML = htmlParts.join('');
// 100%时,等圆环动画完成后触发撒花
if (pct === 100 && anim) {
setTimeout(() => {
const ring = this.panel.$.reqs.querySelector('.ldsp-ring.complete');
if (ring) ring.classList.add('anim-done');
}, 950); // 等待圆环动画
} else if (pct === 100) {
setTimeout(() => {
const ring = this.panel.$.reqs.querySelector('.ldsp-ring.complete');
if (ring) ring.classList.add('anim-done');
}, 50);
}
}
// 渲染阅读卡片(带缓存,避免频繁更新导致动画闪烁)
renderReading(minutes, isTracking = true) {
const lv = Utils.getReadingLevel(minutes);
const timeStr = Utils.formatReadingTime(minutes);
const $ = this.panel.$;
// 缓存上次渲染的状态,避免不必要的 DOM 操作和样式更新
const cacheKey = `${lv.label}|${timeStr}|${isTracking}|${minutes >= 180}|${minutes >= 450}`;
if (this._readingCache === cacheKey) return;
this._readingCache = cacheKey;
// 只更新变化的内容
if ($.readingIcon.textContent !== lv.icon) $.readingIcon.textContent = lv.icon;
if ($.readingTime.textContent !== timeStr) $.readingTime.textContent = timeStr;
if ($.readingLabel.textContent !== lv.label) $.readingLabel.textContent = lv.label;
// 只在颜色变化时更新样式(避免重置动画)
if (this._readingColor !== lv.color) {
this._readingColor = lv.color;
$.reading.style.cssText = `background:${lv.bg};color:${lv.color};--rc:${lv.color}`;
$.readingTime.style.color = lv.color;
$.readingLabel.style.color = lv.color;
}
// tracking 类表示正在追踪,显示波浪效果和"阅读时间记录中..."
$.reading.classList.toggle('tracking', isTracking);
// hi 类表示阅读时间达到沉浸阅读(180-450分钟)
$.reading.classList.toggle('hi', minutes >= 180 && minutes < 450);
// max 类表示阅读时间达到极限(450分钟+)
$.reading.classList.toggle('max', minutes >= 450);
}
// 渲染头像
renderAvatar(url) {
const wrap = this.panel.$.user.querySelector('.ldsp-avatar-wrap');
if (!wrap) return;
const el = wrap.querySelector('.ldsp-avatar-ph, .ldsp-avatar');
if (!el) return;
const img = document.createElement('img');
img.className = 'ldsp-avatar';
img.src = url;
img.alt = 'Avatar';
img.onerror = () => {
const ph = document.createElement('div');
ph.className = 'ldsp-avatar-ph';
ph.textContent = '👤';
img.replaceWith(ph);
};
el.replaceWith(img);
}
// 渲染趋势标签页
renderTrends(currentTab) {
const tabs = [
{ id: 'today', icon: '☀️', label: '今日' },
{ id: 'week', icon: '📅', label: '本周' },
{ id: 'month', icon: '📊', label: '本月' },
{ id: 'year', icon: '📈', label: '本年' },
{ id: 'all', icon: '🌐', label: '全部' }
];
this.panel.$.trends.innerHTML = `
${tabs.map(t =>
`
${t.icon} ${t.label}
`
).join('')}
`;
}
// 获取趋势字段
getTrendFields(reqs) {
return CONFIG.TREND_FIELDS.map(f => {
const req = reqs.find(r => r.name.includes(f.search));
return req ? { ...f, req, name: req.name } : null;
}).filter(Boolean);
}
// 渲染今日趋势
renderTodayTrend(reqs, readingTime, todayData) {
if (!todayData) {
return ``;
}
const now = new Date();
const start = new Date(todayData.startTs);
const startStr = `${start.getHours()}:${String(start.getMinutes()).padStart(2, '0')}`;
const nowStr = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`;
const lv = Utils.getReadingLevel(readingTime);
const pct = Math.min(readingTime / 600 * 100, 100);
// 阅读时间基础信息(所有用户都可见)
let html = `
今日 00:00 ~ ${nowStr} (首次记录于 ${startStr})
${lv.icon}
${Utils.formatReadingTime(readingTime)}
今日累计阅读
${lv.label}
📖 阅读目标 (10小时) ${Math.round(pct)}%
`;
// 升级要求变化明细(仅当有reqs时显示)
if (reqs && reqs.length > 0) {
const changes = reqs.map(r => ({
name: Utils.simplifyName(r.name),
diff: r.currentValue - (todayData.startData[r.name] || 0)
})).filter(c => c.diff !== 0).sort((a, b) => b.diff - a.diff);
const pos = changes.filter(c => c.diff > 0).length;
const neg = changes.filter(c => c.diff < 0).length;
html += `
`;
if (changes.length > 0) {
html += `📊 今日变化明细
${
changes.map(c => `
${c.name} ${c.diff > 0 ? '+' : ''}${c.diff}
`).join('')
}
`;
} else {
html += `今日暂无数据变化
`;
}
}
return html;
}
// 渲染周趋势
renderWeekTrend(history, reqs, historyMgr, tracker) {
// 阅读时间图表始终显示
let html = this._renderWeekChart(tracker);
// 升级要求趋势(仅当有reqs时显示)
if (reqs && reqs.length > 0) {
const weekAgo = Date.now() - 7 * 86400000;
const recent = history.filter(h => h.ts > weekAgo);
if (recent.length >= 1) {
const daily = historyMgr.aggregateDaily(recent, reqs, 7);
const fields = this.getTrendFields(reqs);
const trends = [];
for (const f of fields) {
const data = this._calcDailyTrend(daily, f.name, 7);
if (data.values.some(v => v > 0)) {
trends.push({ label: f.label, ...data, current: f.req.currentValue });
}
}
if (trends.length > 0) {
html += `📈 本周每日增量每日累积量
`;
html += this._renderSparkRows(trends);
if (trends[0].dates.length > 0) {
html += `
${trends[0].dates.map(d => `${d} `).join('')}
`;
}
html += `
`;
}
}
}
return html;
}
// 渲染月趋势
renderMonthTrend(history, reqs, historyMgr, tracker) {
// 阅读时间图表始终显示
let html = this._renderMonthChart(tracker);
// 升级要求趋势(仅当有reqs时显示)
if (reqs && reqs.length > 0 && history.length >= 1) {
const weekly = historyMgr.aggregateWeekly(history, reqs);
const fields = this.getTrendFields(reqs);
const trends = [];
for (const f of fields) {
const data = this._calcWeeklyTrend(weekly, f.name);
if (data.values.length > 0) {
trends.push({ label: f.label, ...data, current: f.req.currentValue });
}
}
if (trends.length > 0) {
html += `📈 本月每周增量每周累积量
`;
html += this._renderSparkRows(trends, true);
if (trends[0].labels?.length > 0) {
html += `
${trends[0].labels.map(l => `${l} `).join('')}
`;
}
html += `
`;
}
}
return html;
}
// 渲染年趋势
renderYearTrend(history, reqs, historyMgr, tracker) {
// 阅读热力图始终显示
let html = this._renderYearChart(tracker);
// 升级要求趋势(仅当有reqs时显示)
if (reqs && reqs.length > 0) {
const yearAgo = Date.now() - 365 * 86400000;
const recent = history.filter(h => h.ts > yearAgo);
if (recent.length >= 1) {
const monthly = historyMgr.aggregateMonthly(recent, reqs);
const fields = this.getTrendFields(reqs);
const trends = [];
for (const f of fields) {
const data = this._calcMonthlyTrend(monthly, f.name);
if (data.values.some(v => v > 0)) {
trends.push({ label: f.label, ...data, current: f.req.currentValue });
}
}
if (trends.length > 0) {
html += `📊 本年每月增量每月累积量
`;
trends.forEach(t => {
const max = Math.max(...t.values, 1);
const bars = t.values.map((v, i) => `
`).join('');
html += `
${t.label} ${bars}
${t.current} `;
});
html += `
`;
}
}
}
return html;
}
// 渲染全部趋势
renderAllTrend(history, reqs, tracker) {
const total = tracker.getTotalTime();
const readingData = tracker.storage.get('readingTime', null);
const actualReadingDays = readingData?.dailyData ? Object.keys(readingData.dailyData).length : 1;
const avg = Math.round(total / Math.max(actualReadingDays, 1));
const lv = Utils.getReadingLevel(avg);
// 阅读时间统计(始终显示)
let html = `共记录 ${actualReadingDays} 天阅读数据
`;
if (total > 0) {
html += `
${lv.icon}
${Utils.formatReadingTime(total)}
累计阅读时间 · 日均 ${Utils.formatReadingTime(avg)}
${lv.label}
`;
}
// 升级要求统计(仅当有reqs和history时显示)
if (reqs && reqs.length > 0 && history.length >= 1) {
const oldest = history[0], newest = history.at(-1);
const recordDays = history.length;
const spanDays = Math.ceil((Date.now() - oldest.ts) / 86400000);
if (spanDays > actualReadingDays) {
html = html.replace(`共记录 ${actualReadingDays} 天阅读数据`,
`共记录 ${recordDays} 天数据${spanDays > recordDays ? ` · 跨度 ${spanDays} 天` : ''}`);
}
// 累计变化统计
const changes = reqs.map(r => ({
name: Utils.simplifyName(r.name),
diff: (newest.data[r.name] || 0) - (oldest.data[r.name] || 0),
current: r.currentValue,
required: r.requiredValue,
isSuccess: r.isSuccess
})).filter(c => c.diff !== 0 || c.current > 0);
if (changes.length > 0) {
html += `📊 累计变化 (${recordDays}天)
${
changes.map(c => {
const diffText = c.diff !== 0 ? `
${c.diff > 0 ? '+' : ''}${c.diff} ` : '';
return `
${c.name} ${c.current}/${c.required} ${diffText}
`;
}).join('')
}
`;
}
// 如果有足够的历史数据,显示更多统计
if (recordDays >= 2) {
const dailyAvgChanges = reqs.map(r => ({
name: Utils.simplifyName(r.name),
avg: Math.round(((newest.data[r.name] || 0) - (oldest.data[r.name] || 0)) / Math.max(recordDays - 1, 1) * 10) / 10
})).filter(c => c.avg > 0);
if (dailyAvgChanges.length > 0) {
html += `📈 日均增量
${
dailyAvgChanges.map(c => `
${c.name} +${c.avg}
`).join('')
}
`;
}
}
}
return html;
}
_renderSparkRows(trends, isWeekly = false) {
let html = '';
for (const t of trends) {
const max = Math.max(...t.values, 1);
const bars = t.values.map((v, i) => {
const h = Math.max(v / max * 20, 2);
const op = isWeekly && i === t.values.length - 1 ? 1 : (isWeekly ? 0.6 : '');
return `
`;
}).join('');
html += `${t.label} ${bars}
${t.current} `;
}
return html;
}
_renderWeekChart(tracker) {
const days = tracker.getWeekHistory();
const max = Math.max(...days.map(d => d.minutes), 60);
const total = days.reduce((s, d) => s + d.minutes, 0);
const avg = Math.round(total / 7);
const bars = days.map(d => {
const h = Math.max(d.minutes / max * 45, 3);
return ``;
}).join('');
return `⏱️ 7天阅读时间共 ${Utils.formatReadingTime(total)} · 日均 ${Utils.formatReadingTime(avg)}
${bars}
`;
}
_renderMonthChart(tracker) {
const today = new Date();
const [year, month, currentDay] = [today.getFullYear(), today.getMonth(), today.getDate()];
const daysInMonth = new Date(year, month + 1, 0).getDate();
let max = 1, total = 0;
const days = [];
for (let d = 1; d <= daysInMonth; d++) {
const key = new Date(year, month, d).toDateString();
const isToday = d === currentDay;
const isFuture = d > currentDay;
const mins = isFuture ? 0 : (isToday ? tracker.getTodayTime() : tracker.getTimeForDate(key));
if (!isFuture) { max = Math.max(max, mins); total += mins; }
days.push({ d, mins: Math.max(mins, 0), isToday, isFuture });
}
const avg = currentDay > 0 ? Math.round(total / currentDay) : 0;
// 日期标签字号根据天数动态调整
const lblFontSize = daysInMonth >= 31 ? '7px' : (daysInMonth >= 28 ? '8px' : '9px');
const bars = days.map(day => {
const h = max > 0 ? (day.mins > 0 ? Math.max(day.mins / max * 45, 2) : 1) : 1;
const op = day.isFuture ? 0.35 : (day.isToday ? 1 : 0.75);
const timeStr = day.isFuture ? '0分钟 (未到)' : Utils.formatReadingTime(day.mins);
return ``;
}).join('');
return `⏱️ 本月阅读时间共 ${Utils.formatReadingTime(total)} · 日均 ${Utils.formatReadingTime(avg)}
${bars}
`;
}
_renderYearChart(tracker) {
const today = new Date();
const year = today.getFullYear();
const data = tracker.getYearData();
const jan1 = new Date(year, 0, 1);
const blanks = jan1.getDay() === 0 ? 6 : jan1.getDay() - 1;
let total = 0;
data.forEach(m => total += m);
const days = Array(blanks).fill({ empty: true });
let d = new Date(jan1);
while (d <= today) {
days.push({
date: new Date(d),
mins: Math.max(data.get(d.toDateString()) || 0, 0),
month: d.getMonth(),
day: d.getDate()
});
d.setDate(d.getDate() + 1);
}
const COLS = 14;
while (days.length % COLS) days.push({ empty: true });
const rows = [];
for (let i = 0; i < days.length; i += COLS) {
rows.push(days.slice(i, i + COLS));
}
const monthRows = new Map();
rows.forEach((r, i) => {
r.forEach(day => {
if (!day.empty) {
const m = day.month;
if (!monthRows.has(m)) monthRows.set(m, { start: i, end: i });
else monthRows.get(m).end = i;
}
});
});
const labels = new Map();
monthRows.forEach((info, m) => {
const mid = Math.floor((info.start + info.end) / 2);
if (!labels.has(mid)) labels.set(mid, CONFIG.MONTHS[m]);
});
let html = `⏱️ 本年阅读时间共 ${Utils.formatReadingTime(total)}
`;
rows.forEach((row, i) => {
const lbl = labels.get(i) || '';
html += `
${lbl} `;
row.forEach(day => {
if (day.empty) {
html += `
`;
} else {
const lv = Utils.getHeatmapLevel(day.mins);
html += `
${day.month + 1}/${day.day} ${Utils.formatReadingTime(day.mins)}
`;
}
});
html += `
`;
});
html += `
<1分 `;
const legendColors = ['rgba(107,140,239,.1)', 'rgba(180,230,210,.35)', 'rgba(130,215,180,.5)', 'rgba(90,195,155,.65)', 'linear-gradient(135deg,#6dcfa5,#50c090)'];
for (let i = 0; i <= 4; i++) html += `
`;
html += `
>5小时 `;
return html;
}
_calcDailyTrend(daily, name, maxDays) {
const sorted = [...daily.keys()].sort((a, b) => new Date(a) - new Date(b)).slice(-maxDays);
return {
values: sorted.map(d => Math.max(daily.get(d)[name] || 0, 0)),
dates: sorted.map(d => Utils.formatDate(new Date(d).getTime(), 'short'))
};
}
_calcWeeklyTrend(weekly, name) {
const sorted = [...weekly.keys()].sort((a, b) => a - b);
return {
values: sorted.map(i => Math.max(weekly.get(i).data[name] || 0, 0)),
labels: sorted.map(i => weekly.get(i).label)
};
}
_calcMonthlyTrend(monthly, name) {
const sorted = [...monthly.keys()].sort((a, b) => new Date(a) - new Date(b));
return {
values: sorted.map(m => Math.max(monthly.get(m)[name] || 0, 0)),
dates: sorted.map(m => `${new Date(m).getMonth() + 1}月`)
};
}
// Toast 提示
showToast(msg) {
const toast = document.createElement('div');
toast.className = 'ldsp-toast';
toast.innerHTML = msg;
this.panel.el.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// 登录提示模态框
showLoginPrompt(isUpgrade = false) {
const overlay = document.createElement('div');
overlay.className = 'ldsp-modal-overlay';
overlay.innerHTML = `
${isUpgrade ? '🎉' : '👋'} ${isUpgrade ? '升级到 v3.0' : '欢迎使用 LDStatus Pro'}
${isUpgrade ? `
v3.0 版本新增了 云同步 功能!
登录后,你的阅读数据将自动同步到云端,支持跨浏览器、跨设备访问。
` : `
登录 Linux.do 账号后可以:
☁️ 阅读数据云端同步 🔄 跨浏览器/设备同步 🏆 查看/加入阅读排行榜 `}
登录仅用于云同步,不登录也可正常使用本地功能
`;
this.panel.el.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show'));
return overlay;
}
// 渲染排行榜
renderLeaderboard(tab, isLoggedIn, isJoined) {
const tabs = [
{ id: 'daily', label: '📅 日榜' },
{ id: 'weekly', label: '📊 周榜' },
{ id: 'monthly', label: '📈 月榜' }
];
this.panel.$.leaderboard.innerHTML = `
${tabs.map(t =>
`
${t.label}
`
).join('')}
`;
}
renderLeaderboardLogin() {
return `
🔐
需要登录
登录后可以: ☁️ 阅读数据云端同步 🏆 查看/加入排行榜
🚀 立即登录
🔒 仅获取基本信息,用于数据同步
`;
}
renderLeaderboardJoin() {
return `
🏆
加入阅读排行榜
加入后可以查看排行榜,你的阅读时间将与其他用户一起展示 这是完全可选的,随时可以退出
✨ 加入排行榜
🔒 仅展示用户名和阅读时间
`;
}
renderLeaderboardData(data, userId, isJoined, type = 'daily') {
// 从 CONFIG.CACHE 动态读取更新频率并格式化
const formatInterval = (ms) => {
const mins = Math.round(ms / 60000);
if (mins < 60) return `每 ${mins} 分钟更新`;
const hours = Math.round(mins / 60);
return `每 ${hours} 小时更新`;
};
const rules = {
daily: formatInterval(CONFIG.CACHE.LEADERBOARD_DAILY_TTL),
weekly: formatInterval(CONFIG.CACHE.LEADERBOARD_WEEKLY_TTL),
monthly: formatInterval(CONFIG.CACHE.LEADERBOARD_MONTHLY_TTL)
};
if (!data?.rankings?.length) {
return ``;
}
let html = `🔄 ${data.period ? `📅 统计周期: ${data.period} ` : ''}🔄 ${rules[type]}
`;
if (data.myRank && isJoined) {
// 显示用户排名(无论是否在榜内都显示真实排名)
const rankDisplay = data.myRank.rank ? `#${data.myRank.rank}` : (data.myRank.rank_display || '--');
const inTopClass = data.myRank.in_top ? '' : ' not-in-top';
const topLabel = data.myRank.in_top ? '' : '(未入榜) ';
html += `我的排名${topLabel}
${rankDisplay}
${Utils.formatReadingTime(data.myRank.minutes)}
`;
}
html += '';
const siteBaseUrl = `https://${CURRENT_SITE.domain}`;
data.rankings.forEach((user, i) => {
const rank = i + 1;
const isMe = userId && user.user_id === userId;
const cls = [rank <= 3 ? `t${rank}` : '', isMe ? 'me' : ''].filter(Boolean).join(' ');
const icon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank;
const avatar = user.avatar_url ? (user.avatar_url.startsWith('http') ? user.avatar_url : `${siteBaseUrl}${user.avatar_url}`) : '';
// XSS 防护:转义用户名和显示名称
const safeUsername = Utils.escapeHtml(Utils.sanitize(user.username, 30));
const safeName = Utils.escapeHtml(Utils.sanitize(user.name, 100));
const hasName = safeName && safeName.trim();
const nameHtml = hasName
? `
${safeName} @${safeUsername} `
: `
${safeUsername} `;
html += `
${rank <= 3 ? icon : rank}
${avatar ? `
` : '
👤
'}
${nameHtml}${isMe ? '(我) ' : ''}
${Utils.formatReadingTime(user.minutes)}
`;
});
html += '
';
if (isJoined) {
html += `退出排行榜
`;
}
return html;
}
renderLeaderboardLoading() {
return ``;
}
renderLeaderboardError(msg) {
return ``;
}
// ========== 我的活动渲染 ==========
renderActivity(activeSubTab) {
const tabs = [
{ id: 'read', label: '已读', icon: '📖' },
{ id: 'bookmarks', label: '收藏', icon: '⭐' },
{ id: 'replies', label: '回复', icon: '💬' },
{ id: 'likes', label: '赞过', icon: '❤️' },
{ id: 'topics', label: '我的话题', icon: '📝' }
];
this.panel.$.activity.innerHTML = `
${tabs.map(t =>
`
${t.icon} ${t.label}
`
).join('')}
`;
}
renderActivityLoading() {
return ``;
}
renderActivityEmpty(icon, msg) {
return ``;
}
renderActivityError(msg) {
return ``;
}
renderTopicList(topics, hasMore) {
if (!topics || topics.length === 0) {
return this.renderActivityEmpty('📭', '暂无已读话题');
}
// SVG 图标定义(更小更简洁)
const icons = {
reply: ' ',
view: ' ',
like: ' '
};
// 获取头像URL
const getAvatarUrl = (user, size = 36) => {
if (!user || !user.avatar_template) return '';
let template = user.avatar_template;
if (template.startsWith('/')) {
template = `https://${CURRENT_SITE.domain}${template}`;
}
return template.replace('{size}', size);
};
// 获取缩略图URL
const getThumbnailUrl = (topic) => {
if (!topic.thumbnails || topic.thumbnails.length === 0) return null;
const small = topic.thumbnails.find(t => t.max_width && t.max_width <= 200);
return small?.url || topic.thumbnails[topic.thumbnails.length - 1]?.url || topic.image_url;
};
let html = '';
topics.forEach((topic, i) => {
const title = Utils.escapeHtml(topic.title || '无标题');
const postsCount = topic.posts_count || 0;
const views = topic.views || 0;
const likeCount = topic.like_count || 0;
const unread = topic.unread_posts || topic.unread || 0;
const newPosts = topic.new_posts || 0;
const tags = topic.tags || [];
const relativeTime = Utils.formatRelativeTime(topic.bumped_at);
const topicUrl = `https://${CURRENT_SITE.domain}/t/topic/${topic.id}`;
const thumbnailUrl = getThumbnailUrl(topic);
const posters = topic.postersInfo || [];
// 未读/新回复标记(简化,只显示一个)
let badgeHtml = '';
if (unread > 0) {
badgeHtml = `
${unread} `;
} else if (newPosts > 0) {
badgeHtml = `
${newPosts} `;
}
// 标签(最多2个)
let tagsHtml = '';
if (tags.length > 0) {
tagsHtml = `
${tags.slice(0, 2).map(tag =>
`${Utils.escapeHtml(tag)} `
).join('')}${tags.length > 2 ? `+${tags.length - 2} ` : ''}
`;
}
// 发帖人头像(最多3个,更小)
let postersHtml = '';
if (posters.length > 0) {
postersHtml = '
';
posters.slice(0, 3).forEach((poster, idx) => {
const isOP = poster.description?.includes('原始发帖人');
const isLatest = poster.extras?.includes('latest');
const cls = isOP ? 'ldsp-poster-op' : (isLatest ? 'ldsp-poster-latest' : '');
postersHtml += `
`;
});
if (posters.length > 3) postersHtml += `
+${posters.length - 3} `;
postersHtml += '
';
}
// 缩略图
const thumbHtml = thumbnailUrl ? `
` : '';
html += `
${thumbHtml}
`;
});
html += '
';
if (hasMore) {
html += '';
}
return html;
}
renderBookmarkList(bookmarks, hasMore) {
if (!bookmarks || bookmarks.length === 0) {
return this.renderActivityEmpty('⭐', '暂无收藏');
}
// SVG 图标定义
const icons = {
clock: ' ',
calendar: ' ',
bookmark: ' '
};
let html = '';
bookmarks.forEach((bookmark, i) => {
const title = Utils.escapeHtml(bookmark.title || bookmark.fancy_title || '无标题');
const tags = bookmark.tags || [];
const bumpedAt = bookmark.bumped_at;
const createdAt = bookmark.created_at;
const relativeTime = Utils.formatRelativeTime(bumpedAt);
const createdTime = Utils.formatDateTime(createdAt);
const bookmarkUrl = bookmark.bookmarkable_url || '#';
const excerpt = bookmark.excerpt || '';
// 构建标签HTML
let tagsHtml = '';
if (tags.length > 0) {
tagsHtml = `
${tags.slice(0, 4).map(tag =>
`${Utils.escapeHtml(tag)} `
).join('')}${tags.length > 4 ? `+${tags.length - 4} ` : ''}
`;
}
html += `
${title}
${icons.calendar}${createdTime || '--'}
${icons.clock}${relativeTime || '--'}
${tagsHtml}
${excerpt ? `
${excerpt}
` : ''}
`;
});
html += '
';
if (hasMore) {
html += '';
}
return html;
}
renderReplyList(replies, hasMore) {
if (!replies || replies.length === 0) {
return this.renderActivityEmpty('💬', '暂无回复');
}
// SVG 图标定义
const icons = {
clock: ' ',
reply: ' '
};
let html = '';
replies.forEach((reply, i) => {
const title = Utils.escapeHtml(reply.title || '无标题');
const excerpt = reply.excerpt || '';
const createdAt = reply.created_at;
const relativeTime = Utils.formatRelativeTime(createdAt);
const topicId = reply.topic_id;
const postNumber = reply.post_number;
const replyUrl = `https://${CURRENT_SITE.domain}/t/topic/${topicId}/${postNumber}`;
const replyToPostNumber = reply.reply_to_post_number;
html += `
${title}
${icons.clock}${relativeTime || '--'}
${replyToPostNumber ? `${icons.reply}#${replyToPostNumber} ` : ''}
${excerpt ? `
${excerpt}
` : ''}
`;
});
html += '
';
if (hasMore) {
html += '';
}
return html;
}
renderLikeList(likes, hasMore) {
if (!likes || likes.length === 0) {
return this.renderActivityEmpty('❤️', '暂无赞过内容');
}
// SVG 图标定义
const icons = {
clock: ' ',
heart: ' ',
user: ' '
};
let html = '';
likes.forEach((like, i) => {
const title = Utils.escapeHtml(like.title || '无标题');
const excerpt = like.excerpt || '';
const createdAt = like.created_at;
const relativeTime = Utils.formatRelativeTime(createdAt);
const topicId = like.topic_id;
const postNumber = like.post_number;
const likeUrl = `https://${CURRENT_SITE.domain}/t/topic/${topicId}/${postNumber}`;
const authorName = Utils.escapeHtml(like.name || like.username || '匿名');
const authorUsername = like.username;
html += `
${title}
${icons.clock}${relativeTime || '--'}
${icons.user}${authorName}
${excerpt ? `
${excerpt}
` : ''}
`;
});
html += '
';
if (hasMore) {
html += '';
}
return html;
}
renderMyTopicList(topics, hasMore) {
if (!topics || topics.length === 0) {
return this.renderActivityEmpty('📝', '暂无发布的话题');
}
// SVG 图标定义
const icons = {
reply: ' ',
view: ' ',
heart: ' ',
clock: ' ',
calendar: ' ',
pin: ' ',
lock: ' '
};
let html = '';
topics.forEach((topic, i) => {
const title = Utils.escapeHtml(topic.title || topic.fancy_title || '无标题');
const postsCount = topic.posts_count || 0;
const views = topic.views || 0;
const likeCount = topic.like_count || 0;
const tags = topic.tags || [];
const bumpedAt = topic.bumped_at;
const createdAt = topic.created_at;
const relativeTime = Utils.formatRelativeTime(bumpedAt);
const createdTime = Utils.formatDateTime(createdAt);
const topicUrl = `https://${CURRENT_SITE.domain}/t/topic/${topic.id}`;
const isPinned = topic.pinned;
const isClosed = topic.closed;
// 构建标签HTML
let tagsHtml = '';
if (tags.length > 0) {
tagsHtml = `
${tags.slice(0, 3).map(tag =>
`${Utils.escapeHtml(tag)} `
).join('')}${tags.length > 3 ? `+${tags.length - 3} ` : ''}
`;
}
// 状态图标
let statusIcons = '';
if (isPinned) statusIcons += `
${icons.pin} `;
if (isClosed) statusIcons += `
${icons.lock} `;
html += `
${tagsHtml}
${icons.calendar}${createdTime || '--'}
`;
});
html += '
';
if (hasMore) {
html += '';
}
return html;
}
}
// ==================== 我的活动管理器 ====================
class ActivityManager {
constructor(network) {
this.network = network;
this._cache = new Map();
this._loading = new Map();
this._pageState = new Map(); // 记录每个子tab的分页状态
}
// 获取已读话题列表
async getReadTopics(page = 0) {
const cacheKey = `read_${page}`;
// 检查缓存(1分钟有效)
const cached = this._cache.get(cacheKey);
if (cached && Date.now() - cached.time < 60000) {
return cached.data;
}
// 防止重复请求
if (this._loading.get(cacheKey)) {
return new Promise((resolve) => {
const check = () => {
if (!this._loading.get(cacheKey)) {
resolve(this._cache.get(cacheKey)?.data);
} else {
setTimeout(check, 100);
}
};
check();
});
}
this._loading.set(cacheKey, true);
try {
const url = page > 0
? `https://${CURRENT_SITE.domain}/read.json?page=${page}`
: `https://${CURRENT_SITE.domain}/read.json`;
const response = await this.network.fetchJson(url, {
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Discourse-Present': 'true',
'Discourse-Logged-In': 'true'
},
credentials: 'include'
});
if (!response || !response.topic_list) {
throw new Error('无效的响应数据');
}
// 构建用户ID到用户信息的映射
const usersMap = {};
if (response.users && Array.isArray(response.users)) {
response.users.forEach(user => {
usersMap[user.id] = user;
});
}
// 为每个话题附加发帖人信息
const topics = (response.topic_list.topics || []).map(topic => {
if (topic.posters && topic.posters.length > 0) {
topic.postersInfo = topic.posters.map(poster => {
const user = usersMap[poster.user_id];
return user ? { ...user, description: poster.description, extras: poster.extras } : null;
}).filter(Boolean);
}
return topic;
});
const result = {
topics: topics,
hasMore: !!response.topic_list.more_topics_url,
page: page
};
this._cache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (e) {
throw new Error(e.message || '获取已读话题失败');
} finally {
this._loading.set(cacheKey, false);
}
}
// 获取收藏列表
async getBookmarks(page = 0, username) {
if (!username) throw new Error('未登录');
const cacheKey = `bookmarks_${page}`;
// 检查缓存(1分钟有效)
const cached = this._cache.get(cacheKey);
if (cached && Date.now() - cached.time < 60000) {
return cached.data;
}
// 防止重复请求
if (this._loading.get(cacheKey)) {
return new Promise((resolve) => {
const check = () => {
if (!this._loading.get(cacheKey)) {
resolve(this._cache.get(cacheKey)?.data);
} else {
setTimeout(check, 100);
}
};
check();
});
}
this._loading.set(cacheKey, true);
try {
const url = `https://${CURRENT_SITE.domain}/u/${encodeURIComponent(username)}/bookmarks.json?page=${page}`;
const response = await this.network.fetchJson(url, {
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Discourse-Present': 'true',
'Discourse-Logged-In': 'true'
},
credentials: 'include'
});
if (!response || !response.user_bookmark_list) {
throw new Error('无效的响应数据');
}
const result = {
bookmarks: response.user_bookmark_list.bookmarks || [],
hasMore: !!response.user_bookmark_list.more_bookmarks_url,
page: page
};
this._cache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (e) {
throw new Error(e.message || '获取收藏列表失败');
} finally {
this._loading.set(cacheKey, false);
}
}
// 获取回复列表
async getReplies(offset = 0, username) {
if (!username) throw new Error('未登录');
const cacheKey = `replies_${offset}`;
// 检查缓存(1分钟有效)
const cached = this._cache.get(cacheKey);
if (cached && Date.now() - cached.time < 60000) {
return cached.data;
}
// 防止重复请求
if (this._loading.get(cacheKey)) {
return new Promise((resolve) => {
const check = () => {
if (!this._loading.get(cacheKey)) {
resolve(this._cache.get(cacheKey)?.data);
} else {
setTimeout(check, 100);
}
};
check();
});
}
this._loading.set(cacheKey, true);
try {
const url = `https://${CURRENT_SITE.domain}/user_actions.json?offset=${offset}&username=${encodeURIComponent(username)}&filter=5`;
const response = await this.network.fetchJson(url, {
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Discourse-Present': 'true',
'Discourse-Logged-In': 'true'
},
credentials: 'include'
});
if (!response || !response.user_actions) {
throw new Error('无效的响应数据');
}
const replies = response.user_actions || [];
const result = {
replies: replies,
hasMore: replies.length >= 30, // 每页30条,如果返回30条说明可能还有更多
offset: offset
};
this._cache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (e) {
throw new Error(e.message || '获取回复列表失败');
} finally {
this._loading.set(cacheKey, false);
}
}
// 获取赞过列表
async getLikes(offset = 0, username) {
if (!username) throw new Error('未登录');
const cacheKey = `likes_${offset}`;
// 检查缓存(1分钟有效)
const cached = this._cache.get(cacheKey);
if (cached && Date.now() - cached.time < 60000) {
return cached.data;
}
// 防止重复请求
if (this._loading.get(cacheKey)) {
return new Promise((resolve) => {
const check = () => {
if (!this._loading.get(cacheKey)) {
resolve(this._cache.get(cacheKey)?.data);
} else {
setTimeout(check, 100);
}
};
check();
});
}
this._loading.set(cacheKey, true);
try {
const url = `https://${CURRENT_SITE.domain}/user_actions.json?offset=${offset}&username=${encodeURIComponent(username)}&filter=1`;
const response = await this.network.fetchJson(url, {
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Discourse-Present': 'true',
'Discourse-Logged-In': 'true'
},
credentials: 'include'
});
if (!response || !response.user_actions) {
throw new Error('无效的响应数据');
}
const likes = response.user_actions || [];
const result = {
likes: likes,
hasMore: likes.length >= 30, // 每页30条,如果返回30条说明可能还有更多
offset: offset
};
this._cache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (e) {
throw new Error(e.message || '获取赞过列表失败');
} finally {
this._loading.set(cacheKey, false);
}
}
// 获取我的话题列表
async getMyTopics(page = 0, username) {
if (!username) throw new Error('未登录');
const cacheKey = `topics_${page}`;
// 检查缓存(1分钟有效)
const cached = this._cache.get(cacheKey);
if (cached && Date.now() - cached.time < 60000) {
return cached.data;
}
// 防止重复请求
if (this._loading.get(cacheKey)) {
return new Promise((resolve) => {
const check = () => {
if (!this._loading.get(cacheKey)) {
resolve(this._cache.get(cacheKey)?.data);
} else {
setTimeout(check, 100);
}
};
check();
});
}
this._loading.set(cacheKey, true);
try {
const url = page > 0
? `https://${CURRENT_SITE.domain}/topics/created-by/${encodeURIComponent(username)}.json?page=${page}`
: `https://${CURRENT_SITE.domain}/topics/created-by/${encodeURIComponent(username)}.json`;
const response = await this.network.fetchJson(url, {
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Discourse-Present': 'true',
'Discourse-Logged-In': 'true'
},
credentials: 'include'
});
if (!response || !response.topic_list) {
throw new Error('无效的响应数据');
}
const topics = response.topic_list.topics || [];
const result = {
topics: topics,
hasMore: topics.length >= 30, // 每页30条
page: page
};
this._cache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (e) {
throw new Error(e.message || '获取我的话题失败');
} finally {
this._loading.set(cacheKey, false);
}
}
// 清除缓存和分页状态
clearCache(type) {
if (type) {
// 清除特定类型的缓存
for (const key of this._cache.keys()) {
if (key.startsWith(type)) {
this._cache.delete(key);
}
}
// 同时清除分页状态
this._pageState.delete(type);
} else {
this._cache.clear();
this._pageState.clear();
}
}
// 获取分页状态
getPageState(type) {
return this._pageState.get(type) || { page: 0, allTopics: [], hasMore: true };
}
// 设置分页状态
setPageState(type, state) {
this._pageState.set(type, state);
}
}
// ==================== 主面板类 ====================
class Panel {
constructor() {
this._initManagers();
this._initState();
this._initUI();
this._initCloudServices();
this._initEventListeners();
this._initTimers();
}
// 初始化管理器实例
_initManagers() {
this.storage = new Storage();
this.network = new Network();
this.historyMgr = new HistoryManager(this.storage);
this.tracker = new ReadingTracker(this.storage);
this.notifier = new Notifier(this.storage);
this.activityMgr = new ActivityManager(this.network);
// 排行榜相关(仅支持的站点)
this.hasLeaderboard = CURRENT_SITE.supportsLeaderboard;
if (this.hasLeaderboard) {
this.oauth = new OAuthManager(this.storage, this.network);
this.leaderboard = new LeaderboardManager(this.oauth, this.tracker, this.storage);
this.cloudSync = new CloudSyncManager(this.storage, this.oauth, this.tracker);
this.cloudSync.setHistoryManager(this.historyMgr);
this.lbTab = this.storage.getGlobal('leaderboardTab', 'daily');
}
}
// 初始化状态变量
_initState() {
this.prevReqs = [];
this.trendTab = this.storage.getGlobal('trendTab', 'today');
// 兼容性:修复旧版本的无效 tab 值
if (!['today', 'week', 'month', 'year', 'all'].includes(this.trendTab)) {
this.trendTab = 'today';
this.storage.setGlobal('trendTab', 'today');
}
this.avatar = this.storage.get('userAvatar', null);
this.readingTime = 0;
this.username = null;
this.animRing = true;
this.cachedHistory = [];
this.cachedReqs = [];
this.loading = false;
this._readingTimer = null;
this._destroyed = false; // 销毁标记
// 我的活动相关状态
this.activitySubTab = 'read'; // 默认子tab
this._activityScrollHandler = null; // 滚动事件处理器
}
// 初始化 UI
_initUI() {
Styles.inject();
this._createPanel();
this.renderer = new Renderer(this);
this._bindEvents();
this._restore();
this.fetch();
// 工单管理器初始化
if (this.hasLeaderboard && this.oauth) {
this.ticketManager = new TicketManager(this.oauth, this.$.panelBody);
this.ticketManager.init().catch(e => Logger.warn('TicketManager init error:', e));
}
// 吃瓜助手初始化
this.melonHelper = new MelonHelper(this.$.panelBody, this.renderer);
this.melonHelper.init();
// 关注/粉丝管理器初始化(包含头像缓存)
this.followManager = new FollowManager(this.network, this.storage, this.$.panelBody, this.renderer);
this.followManager.init();
// 从缓存加载头像(优先动态头像)
this._loadAvatarFromCache();
// 延迟懒加载关注/粉丝数据和用户profile
setTimeout(() => {
this.followManager.loadData().catch(e => Logger.warn('FollowManager load error:', e));
this.followManager.loadProfile().catch(e => Logger.warn('FollowManager profile error:', e));
}, 2000);
}
// 初始化云服务
_initCloudServices() {
// 检查待处理的 OAuth 登录结果(统一同窗口模式)
let justLoggedIn = false;
if (this.hasLeaderboard && this.oauth) {
justLoggedIn = this._checkPendingOAuthLogin();
}
if (!this.hasLeaderboard) return;
// 注册同步状态回调
this.cloudSync.setSyncStateCallback(syncing => {
if (this._destroyed) return;
if (this.$.btnCloudSync) {
this.$.btnCloudSync.disabled = syncing;
this.$.btnCloudSync.textContent = syncing ? '⏳' : '☁️';
this.$.btnCloudSync.title = syncing ? '同步中...' : '云同步';
}
// 云同步完成时检查未读工单
if (!syncing) this.ticketManager?._checkUnread();
});
if (this.oauth.isLoggedIn() && !justLoggedIn) {
this._initLoggedInUser();
} else if (justLoggedIn) {
if (this.oauth.isJoined()) this.leaderboard.startSync();
} else {
this._checkLoginPrompt();
}
}
// 初始化已登录用户
_initLoggedInUser() {
const oauthUser = this.oauth.getUserInfo();
if (oauthUser?.username) {
const currentUser = this.storage.getUser();
if (currentUser !== oauthUser.username) {
this.storage.setUser(oauthUser.username);
this.storage.invalidateCache();
this.storage.migrate(oauthUser.username);
}
this._updateUserInfoFromOAuth(oauthUser);
}
// 串行化同步请求
this.cloudSync.onPageLoad()
.then(() => this.cloudSync.syncRequirementsOnLoad())
.catch(e => Logger.warn('CloudSync error:', e));
this._syncPrefs();
if (this.oauth.isJoined()) this.leaderboard.startSync();
this._updateLoginUI();
}
// 初始化事件监听器
_initEventListeners() {
// 窗口大小变化
this._resizeHandler = Utils.debounce(() => this._onResize(), 250);
window.addEventListener('resize', this._resizeHandler);
// 订阅 Token 过期事件
EventBus.on('auth:expired', () => {
this.renderer?.showToast('⚠️ 登录已过期,请重新登录');
this._updateLoginUI();
});
// 订阅阅读数据同步完成事件
EventBus.on('reading:synced', ({ merged, source }) => {
Logger.log(`阅读数据已同步: ${merged} 天, 来源: ${source}`);
// 更新本地阅读时间变量
this.readingTime = this.tracker.getTodayTime();
// 清除年度缓存
this.tracker._yearCache = null;
// 检查当前是否在趋势页面(通过 DOM 查询)
const trendsActive = this.el?.querySelector('.ldsp-tab[data-tab="trends"].active');
if (trendsActive) {
// 使用缓存的历史和要求数据重新渲染趋势页面
this._renderTrends(this.cachedHistory || [], this.cachedReqs || []);
}
// 更新顶部阅读时间显示
this.renderer.renderReading(this.readingTime, this.tracker.isActive);
// 数据同步完成后触发工单未读检测
this.ticketManager?._checkUnread();
});
}
// 初始化定时任务
_initTimers() {
// 定期刷新数据(只有领导者标签页执行)
this._refreshTimer = setInterval(() => {
if (!this._destroyed && TabLeader.isLeader()) {
this.fetch();
}
}, CONFIG.INTERVALS.REFRESH);
// 延迟检查版本更新
setTimeout(() => !this._destroyed && this._checkUpdate(true), 2000);
// 延迟加载系统公告
setTimeout(() => !this._destroyed && this._loadAnnouncement(), 1500);
}
_createPanel() {
this.el = document.createElement('div');
this.el.id = 'ldsp-panel';
this.el.setAttribute('role', 'complementary');
this.el.setAttribute('aria-label', `${CURRENT_SITE.name} 信任级别面板`);
this.el.innerHTML = `
v${GM_info.script.version}
${CURRENT_SITE.name}
LDStatus Pro
📋 要求
📈 趋势
${this.hasLeaderboard ? '🏆 排行 ' : ''}
👤 我的
${this.hasLeaderboard ? '
' : ''}
⏻
确认注销登录吗?
退出后排行榜和云同步功能将不可用
取消
确认注销
`;
document.body.appendChild(this.el);
// 初始化resize功能(仅桌面端)
this._initResize();
// 绑定自定义 Tooltip
Tooltip.bindToPanel(this.el);
this.$ = {
header: this.el.querySelector('.ldsp-hdr'),
announcement: this.el.querySelector('.ldsp-announcement'),
announcementText: this.el.querySelector('.ldsp-announcement-text'),
user: this.el.querySelector('.ldsp-user'),
userDisplayName: this.el.querySelector('.ldsp-user-display-name'),
userHandle: this.el.querySelector('.ldsp-user-handle'),
ticketBtn: this.el.querySelector('.ldsp-ticket-btn'),
melonBtn: this.el.querySelector('.ldsp-melon-btn'),
logoutBtn: this.el.querySelector('.ldsp-logout-btn'),
loginBtn: this.el.querySelector('.ldsp-login-btn'),
confirmOverlay: this.el.querySelector('.ldsp-confirm-overlay'),
confirmCancel: this.el.querySelector('.ldsp-confirm-btn.cancel'),
confirmOk: this.el.querySelector('.ldsp-confirm-btn.confirm'),
panelBody: this.el.querySelector('.ldsp-body'),
reading: this.el.querySelector('.ldsp-reading'),
readingIcon: this.el.querySelector('.ldsp-reading-icon'),
readingTime: this.el.querySelector('.ldsp-reading-time'),
readingLabel: this.el.querySelector('.ldsp-reading-label'),
tabs: this.el.querySelectorAll('.ldsp-tab'),
sections: this.el.querySelectorAll('.ldsp-section'),
reqs: this.el.querySelector('#ldsp-reqs'),
trends: this.el.querySelector('#ldsp-trends'),
leaderboard: this.el.querySelector('#ldsp-leaderboard'),
activity: this.el.querySelector('#ldsp-activity'),
btnToggle: this.el.querySelector('.ldsp-toggle'),
btnRefresh: this.el.querySelector('.ldsp-refresh'),
btnTheme: this.el.querySelector('.ldsp-theme'),
btnUpdate: this.el.querySelector('.ldsp-update'),
btnCloudSync: this.el.querySelector('.ldsp-cloud-sync'),
updateBubble: this.el.querySelector('.ldsp-update-bubble'),
updateBubbleVer: this.el.querySelector('.ldsp-update-bubble-ver'),
updateBubbleBtn: this.el.querySelector('.ldsp-update-bubble-btn'),
updateBubbleClose: this.el.querySelector('.ldsp-update-bubble-close')
};
}
_bindEvents() {
// 拖拽(支持鼠标和触摸)
let dragging = false, ox, oy, moved = false, sx, sy;
const THRESHOLD = 5;
const getPos = e => e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : { x: e.clientX, y: e.clientY };
const startDrag = e => {
if (!this.el.classList.contains('collapsed') && e.target.closest('button')) return;
const p = getPos(e);
dragging = true;
moved = false;
// 使用 left/top 进行拖拽计算
this.el.style.right = 'auto';
this.el.style.left = this.el.offsetLeft + 'px';
ox = p.x - this.el.offsetLeft;
oy = p.y - this.el.offsetTop;
sx = p.x;
sy = p.y;
this.el.classList.add('no-trans');
e.preventDefault();
};
const updateDrag = e => {
if (!dragging) return;
const p = getPos(e);
if (Math.abs(p.x - sx) > THRESHOLD || Math.abs(p.y - sy) > THRESHOLD) moved = true;
this.el.style.left = Math.max(0, Math.min(p.x - ox, innerWidth - this.el.offsetWidth)) + 'px';
this.el.style.top = Math.max(0, Math.min(p.y - oy, innerHeight - this.el.offsetHeight)) + 'px';
};
const endDrag = () => {
if (!dragging) return;
dragging = false;
this.el.classList.remove('no-trans');
this.storage.setGlobalNow('position', { left: this.el.style.left, top: this.el.style.top });
this._updateExpandDir();
};
// 鼠标事件
this.$.header.addEventListener('mousedown', e => !this.el.classList.contains('collapsed') && startDrag(e));
this.el.addEventListener('mousedown', e => this.el.classList.contains('collapsed') && startDrag(e));
document.addEventListener('mousemove', updateDrag);
document.addEventListener('mouseup', endDrag);
// 触摸事件(移动端拖拽)
this.$.header.addEventListener('touchstart', e => !this.el.classList.contains('collapsed') && startDrag(e), { passive: false });
this.el.addEventListener('touchstart', e => this.el.classList.contains('collapsed') && startDrag(e), { passive: false });
document.addEventListener('touchmove', updateDrag, { passive: false });
document.addEventListener('touchend', e => {
const wasDragging = dragging;
const isCollapsed = this.el.classList.contains('collapsed');
endDrag();
// 触摸未移动且是折叠状态,视为点击展开
if (wasDragging && !moved && isCollapsed) {
this._toggle();
}
// 移动端:清除折叠 logo 的 hover 残留效果
if (isCollapsed && wasDragging) {
this.el.classList.add('no-hover-effect');
setTimeout(() => this.el.classList.remove('no-hover-effect'), 50);
}
});
// 按钮事件
this.$.btnToggle.addEventListener('click', e => {
e.stopPropagation();
if (moved) { moved = false; return; }
this._toggle();
});
this.$.btnRefresh.addEventListener('click', () => {
if (this.loading) return;
this.animRing = true;
this.fetch();
// 刷新数据时同步检查未读工单
this.ticketManager?._checkUnread();
});
this.$.btnTheme.addEventListener('click', () => this._switchTheme());
this.$.btnUpdate.addEventListener('click', () => this._checkUpdate());
// 彩蛋:点击头像打开GitHub仓库
this.$.user.addEventListener('click', e => {
if (e.target.closest('.ldsp-avatar-wrap')) {
window.open('https://github.com/caigg188/LDStatusPro', '_blank');
}
});
// 注销登录按钮与确认弹窗
const showLogoutConfirm = () => {
if (!this.hasLeaderboard || !this.oauth?.isLoggedIn()) {
this.renderer.showToast('ℹ️ 当前未登录');
return;
}
this.$.confirmOverlay?.classList.add('show');
};
const hideLogoutConfirm = () => {
this.$.confirmOverlay?.classList.remove('show');
};
const doLogout = () => {
hideLogoutConfirm();
this.oauth.logout();
this.leaderboard?.stopSync();
this.renderer.showToast('✅ 已退出登录');
this._updateLoginUI();
this._renderLeaderboard();
};
// 注销按钮点击
this.$.logoutBtn?.addEventListener('click', (e) => {
e.stopPropagation();
showLogoutConfirm();
});
// 确认弹窗按钮
this.$.confirmCancel?.addEventListener('click', hideLogoutConfirm);
this.$.confirmOk?.addEventListener('click', doLogout);
// 点击遮罩关闭
this.$.confirmOverlay?.addEventListener('click', (e) => {
if (e.target === this.$.confirmOverlay) hideLogoutConfirm();
});
// 云同步按钮(状态由 CloudSyncManager 的回调自动管理)
this.$.btnCloudSync?.addEventListener('click', async () => {
if (!this.hasLeaderboard || !this.oauth?.isLoggedIn()) return;
if (this.cloudSync.isSyncing()) return; // 正在同步中,忽略点击
try {
await this.cloudSync.fullSync();
this.renderer.showToast('✅ 数据同步完成');
this.renderer.renderReading(this.tracker.getTodayTime(), this.tracker.isActive);
// 显示成功状态
if (this.$.btnCloudSync) {
this.$.btnCloudSync.textContent = '✅';
setTimeout(() => {
if (this.$.btnCloudSync) this.$.btnCloudSync.textContent = '☁️';
}, 1000);
}
} catch (e) {
this.renderer.showToast(`❌ 同步失败: ${e.message || e}`);
// 显示失败状态
if (this.$.btnCloudSync) {
this.$.btnCloudSync.textContent = '❌';
setTimeout(() => {
if (this.$.btnCloudSync) this.$.btnCloudSync.textContent = '☁️';
}, 10000);
}
}
});
// 工单按钮
this.$.ticketBtn?.addEventListener('click', e => {
e.stopPropagation();
if (!this.hasLeaderboard || !this.oauth?.isLoggedIn()) {
this.renderer.showToast('⚠️ 请先登录后使用工单功能');
return;
}
if (this.ticketManager) {
this.ticketManager.show();
}
});
// 吃瓜助手按钮
this.$.melonBtn?.addEventListener('click', e => {
e.stopPropagation();
if (this.melonHelper) {
this.melonHelper.show();
}
});
// 关注/粉丝分别点击
this.el.querySelectorAll('.ldsp-follow-part').forEach(part => {
part.addEventListener('click', e => {
e.stopPropagation();
const username = this.storage.getUser();
if (!username) {
this.renderer.showToast('⚠️ 请先登录论坛');
return;
}
if (this.followManager) {
// 根据点击的区域打开对应列表
const tabName = part.dataset.tab || 'following';
const tabs = this.followManager.overlay.querySelectorAll('.ldsp-follow-tab');
tabs.forEach(t => t.classList.remove('active'));
const targetTab = this.followManager.overlay.querySelector(`.ldsp-follow-tab[data-tab="${tabName}"]`);
targetTab?.classList.add('active');
this.followManager.show();
}
});
});
// 兼容旧样式的关注/粉丝统计点击
this.el.querySelectorAll('.ldsp-follow-stat').forEach(stat => {
stat.addEventListener('click', e => {
e.stopPropagation();
const username = this.storage.getUser();
if (!username) {
this.renderer.showToast('⚠️ 请先登录论坛');
return;
}
if (this.followManager) {
// 设置激活的tab
const isFollowing = stat.classList.contains('ldsp-follow-stat-following');
const tabs = this.followManager.overlay.querySelectorAll('.ldsp-follow-tab');
tabs.forEach(t => t.classList.remove('active'));
const targetTab = this.followManager.overlay.querySelector(`.ldsp-follow-tab[data-tab="${isFollowing ? 'following' : 'followers'}"]`);
targetTab?.classList.add('active');
this.followManager.show();
}
});
});
// 阅读卡片点击彩蛋 - 跳转到官网(动态URL)
this.$.reading?.addEventListener('click', async e => {
e.stopPropagation();
const url = await this.cloudSync?.getWebsiteUrl() || 'https://ldspro.qzz.io/';
window.open(url, '_blank');
});
// 标签页切换
this.$.tabs.forEach((tab, i) => {
tab.addEventListener('click', () => {
this.$.tabs.forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); });
this.$.sections.forEach(s => s.classList.remove('active'));
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
this.el.querySelector(`#ldsp-${tab.dataset.tab}`).classList.add('active');
if (tab.dataset.tab === 'reqs') {
this.animRing = true;
this.cachedReqs.length && this.renderer.renderReqs(this.cachedReqs);
} else if (tab.dataset.tab === 'leaderboard') {
this._renderLeaderboard();
} else if (tab.dataset.tab === 'activity') {
this._renderActivity();
}
});
tab.addEventListener('keydown', e => {
if (['ArrowRight', 'ArrowLeft'].includes(e.key)) {
e.preventDefault();
const next = e.key === 'ArrowRight' ? (i + 1) % this.$.tabs.length : (i - 1 + this.$.tabs.length) % this.$.tabs.length;
this.$.tabs[next].click();
this.$.tabs[next].focus();
}
});
});
// 监听 Token 过期事件,刷新 UI
window.addEventListener('ldsp_token_expired', () => {
this.renderer.showToast('⚠️ 登录已过期,请重新登录');
this._renderLeaderboard();
});
// 滚动条自动隐藏:滚动时显示,停止后隐藏
this._initScrollbarAutoHide();
}
_initScrollbarAutoHide() {
// 使用闭包存储状态,避免污染实例属性
const timers = new WeakMap();
const showScrollbar = (el) => {
if (this._programmaticScroll) return; // 忽略程序性滚动
el.classList.add('scrolling');
clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => el.classList.remove('scrolling'), 800));
};
// 事件委托:捕获所有可滚动元素的滚动事件
this.el.addEventListener('scroll', (e) => {
const t = e.target;
if (t.classList.contains('ldsp-content') ||
t.classList.contains('ldsp-subtabs') ||
t.classList.contains('ldsp-year-heatmap')) {
showScrollbar(t);
}
}, { capture: true, passive: true });
}
// 程序性滚动(不显示滚动条)
_scrollTo(el, top) {
this._programmaticScroll = true;
el.scrollTop = top;
requestAnimationFrame(() => { this._programmaticScroll = false; });
}
_restore() {
const pos = this.storage.getGlobal('position');
if (pos) {
this.el.style.right = 'auto'; // 拖拽后使用 left
this.el.style.left = pos.left;
this.el.style.top = pos.top;
}
if (this.storage.getGlobal('collapsed', false)) {
this.el.classList.add('collapsed');
const arrow = this.$.btnToggle.querySelector('.ldsp-toggle-arrow');
if (arrow) arrow.textContent = '▶';
}
const theme = this.storage.getGlobal('theme', 'light');
if (theme === 'light') this.el.classList.add('light');
this.$.btnTheme.textContent = theme === 'dark' ? '🌓' : '☀️';
requestAnimationFrame(() => this._updateExpandDir());
}
_updateExpandDir() {
const rect = this.el.getBoundingClientRect();
const center = rect.left + rect.width / 2;
this.el.classList.toggle('expand-left', center > innerWidth / 2);
this.el.classList.toggle('expand-right', center <= innerWidth / 2);
}
_onResize() {
if (this.el.classList.contains('collapsed')) return; // 折叠状态不处理
const cfg = Screen.getConfig();
const el = this.el;
// 更新CSS变量
el.style.setProperty('--w', `${cfg.width}px`);
el.style.setProperty('--h', `${cfg.maxHeight}px`);
el.style.setProperty('--fs', `${cfg.fontSize}px`);
el.style.setProperty('--pd', `${cfg.padding}px`);
el.style.setProperty('--av', `${cfg.avatarSize}px`);
el.style.setProperty('--ring', `${cfg.ringSize}px`);
// 确保面板宽度和最大高度不超出视口
el.style.width = `${cfg.width}px`;
el.style.maxHeight = `${cfg.maxHeight}px`;
// 检查并修正面板位置,确保完全在视口内
this._clampPosition();
this._updateExpandDir();
}
// 确保面板位置在视口内
_clampPosition() {
const el = this.el;
const rect = el.getBoundingClientRect();
const { innerWidth: vw, innerHeight: vh } = window;
const margin = 8; // 最小边距
let needUpdate = false;
let newLeft = parseFloat(el.style.left) || rect.left;
let newTop = parseFloat(el.style.top) || rect.top;
// 面板实际尺寸(考虑折叠状态)
const isCollapsed = el.classList.contains('collapsed');
const panelWidth = isCollapsed ? 48 : rect.width;
const panelHeight = isCollapsed ? 48 : rect.height;
// 检查并修正水平位置
if (newLeft + panelWidth > vw - margin) {
newLeft = Math.max(margin, vw - panelWidth - margin);
needUpdate = true;
}
if (newLeft < margin) {
newLeft = margin;
needUpdate = true;
}
// 检查并修正垂直位置
if (newTop + panelHeight > vh - margin) {
newTop = Math.max(margin, vh - panelHeight - margin);
needUpdate = true;
}
if (newTop < margin) {
newTop = margin;
needUpdate = true;
}
// 应用修正后的位置
if (needUpdate) {
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
el.style.right = 'auto';
this.storage.setGlobalNow('position', { left: el.style.left, top: el.style.top });
}
}
// 初始化面板手动调整大小功能(仅桌面端)
_initResize() {
// 检测是否为桌面端(有鼠标悬停能力且是精确指针)
if (!window.matchMedia('(hover:hover) and (pointer:fine)').matches) return;
const el = this.el;
const handles = el.querySelectorAll('.ldsp-resize-handle');
if (!handles.length) return;
let startX, startY, startW, startH, startLeft, startTop, direction;
const minW = 220, maxW = 420, minH = 300;
const onMouseMove = (e) => {
if (!direction) return;
e.preventDefault();
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const { innerWidth: vw, innerHeight: vh } = window;
// 根据方向调整尺寸
if (direction.includes('e')) {
const newW = Math.max(minW, Math.min(maxW, startW + dx));
// 确保不超出右边界
if (startLeft + newW <= vw - 8) {
el.style.width = `${newW}px`;
el.style.setProperty('--w', `${newW}px`);
}
}
if (direction.includes('s')) {
const newH = Math.max(minH, Math.min(vh - startTop - 20, startH + dy));
el.style.maxHeight = `${newH}px`;
el.style.setProperty('--h', `${newH}px`);
}
};
const onMouseUp = () => {
if (!direction) return;
direction = null;
el.classList.remove('resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// 保存用户自定义的尺寸
this.storage.setGlobalNow('customSize', {
width: parseInt(el.style.width),
height: parseInt(el.style.maxHeight)
});
};
handles.forEach(handle => {
handle.addEventListener('mousedown', (e) => {
if (el.classList.contains('collapsed')) return;
e.preventDefault();
e.stopPropagation();
// 判断调整方向
if (handle.classList.contains('ldsp-resize-e')) direction = 'e';
else if (handle.classList.contains('ldsp-resize-s')) direction = 's';
else if (handle.classList.contains('ldsp-resize-se')) direction = 'se';
const rect = el.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
el.classList.add('resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
// 恢复用户自定义的尺寸
const customSize = this.storage.getGlobal('customSize');
if (customSize) {
if (customSize.width >= minW && customSize.width <= maxW) {
el.style.width = `${customSize.width}px`;
el.style.setProperty('--w', `${customSize.width}px`);
}
if (customSize.height >= minH) {
const maxH = window.innerHeight - 50;
const h = Math.min(customSize.height, maxH);
el.style.maxHeight = `${h}px`;
el.style.setProperty('--h', `${h}px`);
}
}
}
_toggle() {
const collapsing = !this.el.classList.contains('collapsed');
const rect = this.el.getBoundingClientRect();
const cfg = Screen.getConfig();
this.el.classList.add('anim');
if (collapsing) {
if (this.el.classList.contains('expand-left')) this.el.style.left = (rect.right - 44) + 'px';
const arrow = this.$.btnToggle.querySelector('.ldsp-toggle-arrow');
if (arrow) arrow.textContent = '▶';
} else {
this._updateExpandDir();
if (this.el.classList.contains('expand-left')) this.el.style.left = Math.max(0, rect.left - (cfg.width - 44)) + 'px';
const arrow = this.$.btnToggle.querySelector('.ldsp-toggle-arrow');
if (arrow) arrow.textContent = '◀';
this.animRing = true;
this.cachedReqs.length && setTimeout(() => this.renderer.renderReqs(this.cachedReqs), 100);
}
this.el.classList.toggle('collapsed');
this.storage.setGlobalNow('collapsed', collapsing);
setTimeout(() => {
this.el.classList.remove('anim');
this.storage.setGlobalNow('position', { left: this.el.style.left, top: this.el.style.top });
}, 400);
}
_switchTheme() {
const light = this.el.classList.toggle('light');
this.$.btnTheme.textContent = light ? '☀️' : '🌓';
this.storage.setGlobalNow('theme', light ? 'light' : 'dark');
}
// 从缓存加载头像(优先动态头像)
_loadAvatarFromCache() {
// 1. 优先使用缓存的动态头像
if (this.followManager?.animatedAvatar) {
this.renderer.renderAvatar(this.followManager.animatedAvatar);
return;
}
// 2. 其次使用缓存的普通头像
if (this.avatar) {
this.renderer.renderAvatar(this.avatar);
return;
}
// 3. 都没有则尝试从页面获取
const el = document.querySelector('.current-user img.avatar');
if (el) {
this._updateAvatar(el.src);
}
}
// 更新普通头像(从页面获取时调用)
_updateAvatar(url) {
if (!url) return;
if (url.startsWith('/')) url = `https://${CURRENT_SITE.domain}${url}`;
url = url.replace(PATTERNS.AVATAR_SIZE, '/128/');
// 只有头像变化时才更新
if (this.avatar === url) return;
this.avatar = url;
this.storage.set('userAvatar', url);
// 只有在没有动态头像时才渲染普通头像
if (!this.followManager?.animatedAvatar) {
this.renderer.renderAvatar(url);
}
}
_startReadingUpdate() {
if (this._readingTimer) return;
this._readingTimer = setInterval(() => {
this.readingTime = this.tracker.getTodayTime();
this.renderer.renderReading(this.readingTime, this.tracker.isActive);
}, CONFIG.INTERVALS.READING_UPDATE);
}
_setLoading(v) {
this.loading = v;
this.$.btnRefresh.disabled = v;
this.$.btnRefresh.style.animation = v ? 'spin 1s linear infinite' : '';
}
async fetch() {
if (this.loading) return;
this._setLoading(true);
this.$.reqs.innerHTML = ``;
try {
const url = CURRENT_SITE.apiUrl;
// 使用 network.fetch(包含 GM_xmlhttpRequest 绕过跨域,以及 fallback)
const html = await this.network.fetch(url);
if (!html) {
throw new Error('无法获取数据');
}
await this._parse(html);
} catch (e) {
this._showError(e.message || '网络错误');
} finally {
this._setLoading(false);
}
}
_showError(msg) {
this.$.reqs.innerHTML = ``;
}
// 更新信任等级到服务端和本地缓存
async _updateTrustLevel(connectLevel) {
// 同时检查 oauth 和 cloudSync.oauth 的登录状态
if (!this.oauth?.isLoggedIn() || !this.cloudSync?.oauth?.isLoggedIn()) return;
const userInfo = this.oauth.getUserInfo();
// v3.4.7: 兼容 trust_level 和 trustLevel 两种命名格式
const currentLevel = userInfo?.trust_level ?? userInfo?.trustLevel;
// 只有当等级变化时才更新
if (currentLevel === connectLevel) return;
try {
// 更新服务端(使用统一的 oauth 实例)
const result = await this.oauth.api('/api/user/trust-level', {
method: 'POST',
body: { trust_level: connectLevel }
});
if (result?.success) {
// 更新本地缓存
const updatedUserInfo = { ...userInfo, trust_level: connectLevel };
this.oauth.setUserInfo(updatedUserInfo);
}
} catch (e) { /* 同步失败,忽略 */ }
}
// 当没有升级要求表格时显示备选内容
// 优先级:1. 服务端同步的数据 2. summary API 数据
async _showFallbackStats(username, level) {
const $ = this.$;
// 优先从 OAuth 获取用户信息(更可靠,尤其在移动端)
let effectiveUsername = username;
let numLevel = parseInt(level) || 0;
if (this.oauth?.isLoggedIn()) {
const oauthUser = this.oauth.getUserInfo();
// 优先使用 OAuth 中的用户名
if (oauthUser?.username) {
effectiveUsername = oauthUser.username;
}
// 优先使用 OAuth 中的信任等级
const oauthTrustLevel = oauthUser?.trust_level ?? oauthUser?.trustLevel;
if (typeof oauthTrustLevel === 'number') {
numLevel = oauthTrustLevel;
}
}
// 确保阅读追踪器已初始化(_showFallbackStats 可能在 tracker.init 之前被调用)
if (effectiveUsername && effectiveUsername !== '未知') {
if (!this.username) {
this.storage.setUser(effectiveUsername);
this.username = effectiveUsername;
}
this.tracker.init(effectiveUsername);
this._startReadingUpdate();
this.readingTime = this.tracker.getTodayTime();
this.renderer.renderReading(this.readingTime, this.tracker.isActive);
} else {
// 即使没有用户名,也初始化匿名模式追踪
this.tracker.init('anonymous');
this._startReadingUpdate();
}
// 显示用户信息
if (effectiveUsername && effectiveUsername !== '未知') {
$.userDisplayName.textContent = effectiveUsername;
$.userHandle.textContent = '';
$.userHandle.style.display = 'none';
}
// === 方案1:优先从服务端获取已同步的升级要求数据 ===
// 这些数据是桌面端解析 connect 页面后上传的,最完整准确
if (this.oauth?.isLoggedIn() && this.cloudSync) {
this.$.reqs.innerHTML = ``;
try {
const cloudData = await this._fetchCloudRequirements();
if (cloudData && cloudData.length > 0) {
return this._renderCloudRequirements(cloudData, effectiveUsername, numLevel);
}
} catch (e) { /* 云端获取失败,继续尝试 summary */ }
}
// === 方案2:从 summary API 获取统计数据 ===
if (effectiveUsername && effectiveUsername !== '未知') {
this.$.reqs.innerHTML = ``;
const summaryData = await this._fetchSummaryData(effectiveUsername);
if (summaryData && Object.keys(summaryData).length > 0) {
return this._renderSummaryData(summaryData, effectiveUsername, numLevel);
}
}
// 如果无法获取 summary 数据,显示简要信息
this.$.reqs.innerHTML = `
📊
当前信任等级:${numLevel}
暂无升级进度数据
`;
// 初始化 todayData(用于今日趋势显示)
const todayData = this._getTodayData();
if (!todayData) {
this._setTodayData({}, true);
}
// 低信任等级用户也可以查看阅读时间趋势
const history = this.historyMgr.getHistory();
this.cachedHistory = history;
this.cachedReqs = []; // 空的升级要求数组
this._renderTrends(history, []);
}
/**
* 从服务端获取最近同步的升级要求数据
* @returns {Array|null} - 升级要求数组或 null
*/
async _fetchCloudRequirements() {
// 前置登录检查,避免无效请求
if (!this.cloudSync?.oauth?.isLoggedIn()) return null;
try {
// 获取最近一天的历史数据
const result = await this.cloudSync.oauth.api('/api/requirements/history?days=1');
if (!result?.success || !result.data?.history?.length) {
return null;
}
// 取最新的一条记录
const latestRecord = result.data.history[result.data.history.length - 1];
if (!latestRecord?.data) return null;
// 转换为升级要求数组格式
const reqs = [];
for (const [name, value] of Object.entries(latestRecord.data)) {
if (name === '阅读时间(分钟)') continue; // 跳过阅读时间
reqs.push({
name,
currentValue: value,
requiredValue: 0, // 从配置获取
isSuccess: false,
change: 0,
isReverse: /举报|禁言|封禁/.test(name)
});
}
return reqs.length > 0 ? reqs : null;
} catch (e) {
return null;
}
}
/**
* 渲染从云端获取的升级要求数据
* @param {Array} cloudReqs - 云端数据数组
* @param {string} username - 用户名
* @param {number} level - 信任等级
*/
_renderCloudRequirements(cloudReqs, username, level) {
// 2-4级用户的升级/保持要求配置(基于 connect 页面的 14 项要求)
const LEVEL_2_PLUS_REQUIREMENTS = {
'访问次数': 50, // 50%
'回复的话题': 10,
'浏览的话题': 500,
'浏览的话题(所有时间)': 200,
'已读帖子': 20000,
'已读帖子(所有时间)': 500,
'被举报的帖子': 5, // 最多5个(反向)
'发起举报的用户': 5, // 最多5个(反向)
'点赞': 30,
'获赞': 20,
'获赞:单日最高数量': 7,
'获赞:点赞用户数量': 5,
'被禁言(过去 6 个月)': 0, // 必须为0(反向)
'被封禁(过去 6 个月)': 0 // 必须为0(反向)
};
// 为云端数据填充要求值
const reqs = cloudReqs.map(req => {
const requiredValue = LEVEL_2_PLUS_REQUIREMENTS[req.name] ?? 0;
const isReverse = /举报|禁言|封禁/.test(req.name);
const isSuccess = isReverse
? req.currentValue <= requiredValue
: req.currentValue >= requiredValue;
return {
...req,
requiredValue,
isSuccess,
isReverse
};
});
// 按照配置顺序排序
const orderedNames = Object.keys(LEVEL_2_PLUS_REQUIREMENTS);
const orderedReqs = reqs.sort((a, b) => {
const idxA = orderedNames.indexOf(a.name);
const idxB = orderedNames.indexOf(b.name);
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
// 检查是否达标
const isOK = orderedReqs.every(r => r.isSuccess);
// 保存历史数据
const histData = {};
orderedReqs.forEach(r => histData[r.name] = r.currentValue);
const history = this.historyMgr.addHistory(histData, this.readingTime);
// 保存今日数据
const todayData = this._getTodayData();
this._setTodayData(histData, !todayData);
// 获取显示名称
let displayName = null;
if (this.hasLeaderboard && this.oauth?.isLoggedIn()) {
const oauthUser = this.oauth.getUserInfo();
if (oauthUser?.name && oauthUser.name !== oauthUser.username) {
displayName = oauthUser.name;
}
}
// 渲染
this.renderer.renderUser(username, level.toString(), isOK, orderedReqs, displayName);
this.renderer.renderReqs(orderedReqs, level);
this.cachedHistory = history;
this.cachedReqs = orderedReqs;
this._renderTrends(history, orderedReqs);
this._setLastVisit(histData);
this.prevReqs = orderedReqs;
return true;
}
/**
* 从 summary.json API 获取用户统计数据 (使用 user_summary 字段)
* @param {string} username - 用户名
* @returns {Object|null} - 统计数据对象或null
*/
async _fetchSummaryData(username) {
try {
const baseUrl = `https://${CURRENT_SITE.domain}`;
const data = {};
// 优先使用 summary.json API(Discourse 标准 API)的 user_summary 字段
const jsonUrl = `${baseUrl}/u/${encodeURIComponent(username)}/summary.json`;
// 尝试多种方式获取数据,兼容不同的用户脚本管理器
let jsonText = null;
// 方法1: 使用 GM_xmlhttpRequest
try {
jsonText = await this.network.fetch(jsonUrl, { maxRetries: 2, timeout: 10000 });
} catch (e) { /* GM fetch 失败 */ }
// 方法2: 如果 GM 方式失败,尝试原生 fetch(同源请求更可靠)
if (!jsonText) {
try {
const response = await fetch(jsonUrl, {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
if (response.ok) {
jsonText = await response.text();
}
} catch (e) { /* native fetch 失败 */ }
}
if (jsonText) {
try {
const json = JSON.parse(jsonText);
// 从 user_summary 字段提取统计数据
const stats = json?.user_summary;
if (stats) {
// 映射 Discourse API 字段到显示名称
if (stats.days_visited !== undefined) data['访问天数'] = stats.days_visited;
if (stats.topics_entered !== undefined) data['浏览话题'] = stats.topics_entered;
if (stats.posts_read_count !== undefined) data['已读帖子'] = stats.posts_read_count;
if (stats.likes_given !== undefined) data['送出赞'] = stats.likes_given;
if (stats.likes_received !== undefined) data['获赞'] = stats.likes_received;
if (stats.post_count !== undefined) data['回复'] = stats.post_count;
if (stats.topic_count !== undefined) data['创建话题'] = stats.topic_count;
// 额外有用的字段
if (stats.time_read !== undefined) data['阅读时间'] = Math.round(stats.time_read / 60); // 秒转分钟
if (Object.keys(data).length > 0) {
return data;
}
}
} catch (e) { /* JSON 解析失败 */ }
}
// 方法B:回退到 HTML 解析
const url = `${baseUrl}/u/${encodeURIComponent(username)}/summary`;
let html = null;
// 先尝试 GM_xmlhttpRequest
try {
html = await this.network.fetch(url, { maxRetries: 2 });
} catch (e) { /* GM fetch 失败 */ }
// 备用:原生 fetch
if (!html) {
try {
const resp = await fetch(url, { credentials: 'include' });
if (resp.ok) {
html = await resp.text();
}
} catch (e) { /* native fetch 失败 */ }
}
if (!html) {
return null;
}
const doc = new DOMParser().parseFromString(html, 'text/html');
// 辅助函数:解析数值(支持 k、m 等缩写和逗号分隔)
const parseValue = (text) => {
if (!text) return 0;
const cleaned = text.replace(/,/g, '').trim();
const match = cleaned.match(/([\d.]+)\s*([km万亿])?/i);
if (!match) return 0;
let value = parseFloat(match[1]);
const suffix = match[2]?.toLowerCase();
if (suffix === 'k' || suffix === '万') value *= 1000;
if (suffix === 'm' || suffix === '亿') value *= 1000000;
return Math.round(value);
};
// 方法1:通过 class 名称查找统计项(Discourse 标准结构)
const statItems = doc.querySelectorAll('li[class*="stats-"], .stat-item, .user-stat');
statItems.forEach(item => {
const className = item.className || '';
const valueEl = item.querySelector('.value .number, .value, .stat-value');
if (!valueEl) return;
// 优先从 title 获取完整数值
let value = 0;
const titleAttr = valueEl.getAttribute('title') || item.getAttribute('title');
if (titleAttr) {
value = parseValue(titleAttr);
} else {
value = parseValue(valueEl.textContent);
}
// 根据 class 名称映射
if (className.includes('days-visited')) data['访问天数'] = value;
else if (className.includes('topics-entered')) data['浏览话题'] = value;
else if (className.includes('posts-read')) data['已读帖子'] = value;
else if (className.includes('likes-given')) data['送出赞'] = value;
else if (className.includes('likes-received')) data['获赞'] = value;
else if (className.includes('post-count')) data['回复'] = value;
else if (className.includes('topic-count')) data['创建话题'] = value;
else if (className.includes('solved-count')) data['解决方案'] = value;
});
// 方法2:如果方法1没找到数据,尝试通过标签文本匹配
if (Object.keys(data).length === 0) {
// 查找所有可能包含统计数据的元素
const allStats = doc.querySelectorAll('.stats-section li, .top-section li, .user-summary-stat');
allStats.forEach(item => {
const text = item.textContent.trim();
const labelEl = item.querySelector('.label, .stat-label');
const valueEl = item.querySelector('.value, .number, .stat-value');
if (!labelEl && !valueEl) return;
const label = (labelEl?.textContent || '').toLowerCase().trim();
let value = 0;
if (valueEl) {
const titleAttr = valueEl.getAttribute('title') || item.getAttribute('title');
value = parseValue(titleAttr || valueEl.textContent);
}
// 根据标签文本匹配
if (label.includes('访问') || label.includes('visited') || text.includes('访问天数')) {
data['访问天数'] = value;
} else if (label.includes('浏览') && label.includes('话题') || label.includes('topics') || text.includes('浏览的话题')) {
data['浏览话题'] = value;
} else if (label.includes('已读') || label.includes('阅读') || label.includes('posts read') || text.includes('已读帖子')) {
data['已读帖子'] = value;
} else if (label.includes('送出') || label.includes('given') || text.includes('已送出')) {
data['送出赞'] = value;
} else if (label.includes('收到') || label.includes('received') || text.includes('已收到')) {
data['获赞'] = value;
} else if (label.includes('帖子') && !label.includes('已读') || label.includes('创建的帖子') || text.includes('创建的帖子')) {
data['回复'] = value;
} else if (label.includes('创建') && label.includes('话题') || text.includes('创建的话题')) {
data['创建话题'] = value;
}
});
}
// 方法3:通用文本解析(作为最后手段)
if (Object.keys(data).length === 0) {
const statsText = doc.body?.textContent || '';
// 尝试匹配 "数字+标签" 的模式
const patterns = [
{ regex: /([\d,.]+[km]?)\s*访问天数/i, key: '访问天数' },
{ regex: /([\d,.]+[km]?)\s*浏览的?话题/i, key: '浏览话题' },
{ regex: /([\d,.]+[km]?)\s*已读帖子/i, key: '已读帖子' },
{ regex: /([\d,.]+[km]?)\s*已?送出/i, key: '送出赞' },
{ regex: /([\d,.]+[km]?)\s*已?收到/i, key: '获赞' },
{ regex: /([\d,.]+[km]?)\s*创建的帖子/i, key: '回复' }
];
patterns.forEach(p => {
const match = statsText.match(p.regex);
if (match) data[p.key] = parseValue(match[1]);
});
}
return Object.keys(data).length > 0 ? data : null;
} catch (e) {
return null;
}
}
/**
* 渲染 summary 统计数据(低信任等级用户)
* 使用与 2 级用户相同的 renderReqs 方法显示进度
*/
_renderSummaryData(data, username, level) {
// 构建要求数据结构(用于显示和趋势)
const reqs = [];
// 这是 connect 页面获取失败时的 fallback 方案
// summary API 只能提供有限的累计统计数据(约8项)
// 注意:summary API 返回的是累计总数,而 2→3 级升级要求是"过去100天"的数据
// 因此这里的数据仅供参考,不能完全代表升级进度
// 升级要求参考: https://linux.do/t/topic/2460
let statsConfig;
if (level === 0) {
// 0级升1级要求:
// - 进入5个话题、阅读30篇帖子、阅读10分钟
statsConfig = [
{ key: '浏览话题', required: 5 },
{ key: '已读帖子', required: 30 },
{ key: '阅读时间', required: 10 } // 10分钟
];
} else if (level === 1) {
// 1级升2级要求:
// - 访问15天、浏览20话题、阅读100帖子、阅读60分钟
// - 送出和收到各1个赞、回复3个不同话题
statsConfig = [
{ key: '访问天数', required: 15 },
{ key: '浏览话题', required: 20 },
{ key: '已读帖子', required: 100 },
{ key: '阅读时间', required: 60 }, // 60分钟
{ key: '送出赞', required: 1 },
{ key: '获赞', required: 1 },
{ key: '回复', required: 3 } // 3个不同话题
];
} else {
// 2级及以上用户:仅显示统计数据,不显示升级要求
// 重要:2级用户的升级进度应从 connect 页面获取(有详细的100天内数据)
// 这里只是 connect 页面完全无法获取时的兜底方案
// summary API 返回的是累计数据,无法反映真实的升级进度
statsConfig = [
{ key: '访问天数', required: 0, isStats: true },
{ key: '浏览话题', required: 0, isStats: true },
{ key: '已读帖子', required: 0, isStats: true },
{ key: '送出赞', required: 0, isStats: true },
{ key: '获赞', required: 0, isStats: true },
{ key: '回复', required: 0, isStats: true },
{ key: '创建话题', required: 0, isStats: true },
{ key: '阅读时间', required: 0, isStats: true }
];
}
statsConfig.forEach(config => {
// 获取当前值(如果没有数据则默认为 0)
const currentValue = data[config.key] !== undefined ? data[config.key] : 0;
const requiredValue = config.required;
const isSuccess = currentValue >= requiredValue;
const prev = this.prevReqs.find(p => p.name === config.key);
reqs.push({
name: config.key,
currentValue,
requiredValue,
isSuccess,
change: prev ? currentValue - prev.currentValue : 0,
isReverse: false
});
});
// 如果没有任何配置项,返回 false
if (reqs.length === 0) return false;
// 检查升级条件
const requiredItems = reqs.filter(r => r.requiredValue > 0);
const metItems = requiredItems.filter(r => r.isSuccess);
const isOK = requiredItems.length > 0 && metItems.length === requiredItems.length;
// 通知检查
this.notifier.check(reqs);
// 保存历史数据
const histData = {};
reqs.forEach(r => histData[r.name] = r.currentValue);
const history = this.historyMgr.addHistory(histData, this.readingTime);
// 保存今日数据
const todayData = this._getTodayData();
this._setTodayData(histData, !todayData);
// 获取 OAuth 用户信息中的显示名称
let displayName = null;
if (this.hasLeaderboard && this.oauth?.isLoggedIn()) {
const oauthUser = this.oauth.getUserInfo();
if (oauthUser?.name && oauthUser.name !== oauthUser.username) {
displayName = oauthUser.name;
}
}
// 渲染用户信息和统计数据(与 2 级用户使用相同的 renderReqs 方法)
this.renderer.renderUser(username, level.toString(), isOK, reqs, displayName);
this.renderer.renderReqs(reqs, level);
// 保存缓存
this.cachedHistory = history;
this.cachedReqs = reqs;
this.prevReqs = reqs;
// 0-1级用户也触发数据同步(阅读时间等)
if (this.hasLeaderboard && this.cloudSync && this.oauth?.isLoggedIn()) {
// 同步阅读时间数据
this.cloudSync.upload().catch(() => {});
}
// 渲染趋势
this._renderTrends(history, reqs);
return true;
}
async _parse(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
// 尝试获取用户名(即使没有升级要求数据也可能有用户信息)
const avatarEl = doc.querySelector('img[src*="avatar"]');
// 尝试从页面提取用户名和信任等级
let username = null;
let level = '?';
let connectLevel = null; // 从 connect 页面获取的等级(最新)
// 1. 优先从 h1 标签获取等级信息: "你好,昵称 (username) X级用户"
const h1El = doc.querySelector('h1');
if (h1El) {
const h1Text = h1El.textContent;
const h1Match = h1Text.match(PATTERNS.TRUST_LEVEL_H1);
if (h1Match) {
username = h1Match[1]; // 括号内的 username
connectLevel = parseInt(h1Match[2]) || 0;
level = connectLevel.toString();
}
}
// 2. 从头像 alt 获取用户名(备用)
if (!username && avatarEl?.alt) {
username = avatarEl.alt;
}
// 3. 查找包含信任级别的区块获取更多信息
const section = [...doc.querySelectorAll('.bg-white.p-6.rounded-lg')].find(d => d.querySelector('h2')?.textContent.includes('信任级别'));
// 如果没找到 section,检查原因
if (!section) {
// 检查是否返回了错误的页面(主站而非 connect)
const isMainSite = html?.includes('欢迎来到 LINUX DO') || doc.querySelector('title')?.textContent?.includes('LINUX DO -');
const isConnectPage = html?.includes('信任级别') || html?.includes('trust level');
if (isMainSite && !isConnectPage) {
// 返回了主站页面,说明 connect 认证失败(常见于 iOS Safari + Stay)
// 使用 OAuth 缓存的用户信息
let oauthUsername = username;
let oauthLevel = level;
if (this.oauth?.isLoggedIn()) {
const oauthUser = this.oauth.getUserInfo();
if (oauthUser?.username) oauthUsername = oauthUser.username;
const trustLevel = oauthUser?.trust_level ?? oauthUser?.trustLevel;
if (typeof trustLevel === 'number') oauthLevel = trustLevel.toString();
}
// 直接使用 fallback 显示,不弹窗打扰用户
console.warn('[LDStatus Pro] Connect 页面认证失败,使用 summary 数据');
return await this._showFallbackStats(oauthUsername, oauthLevel);
}
return await this._showFallbackStats(username, level);
}
if (section) {
const heading = section.querySelector('h2').textContent;
const match = heading.match(PATTERNS.TRUST_LEVEL);
if (match) {
if (!username) username = match[1];
if (connectLevel === null) {
connectLevel = parseInt(match[2]) || 0;
level = match[2];
}
}
}
// 无论是否有升级要求,只要能识别用户就初始化阅读追踪
if (username && username !== '未知') {
this.storage.setUser(username);
this.username = username;
this.tracker.init(username);
this._startReadingUpdate();
} else {
// 即使没有用户名,也尝试使用匿名模式初始化阅读追踪
this.tracker.init('anonymous');
this._startReadingUpdate();
}
if (avatarEl) this._updateAvatar(avatarEl.src);
this.readingTime = this.tracker.getTodayTime();
this.renderer.renderReading(this.readingTime, this.tracker.isActive);
// 如果用户已登录,且从 connect 获取到了等级信息,更新本地缓存和服务端
if (connectLevel !== null && this.oauth?.isLoggedIn()) {
this._updateTrustLevel(connectLevel);
}
// 如果没有找到升级要求区块,fallback 到 summary 数据
if (!section) {
return await this._showFallbackStats(username, level);
}
const rows = section.querySelectorAll('table tr');
const reqs = [];
for (let i = 1; i < rows.length; i++) {
const cells = rows[i].querySelectorAll('td');
if (cells.length < 3) continue;
const name = cells[0].textContent.trim();
const curMatch = cells[1].textContent.match(PATTERNS.NUMBER);
const reqMatch = cells[2].textContent.match(PATTERNS.NUMBER);
const currentValue = curMatch ? +curMatch[1] : 0;
const requiredValue = reqMatch ? +reqMatch[1] : 0;
const isSuccess = cells[1].classList.contains('text-green-500');
const prev = this.prevReqs.find(p => p.name === name);
reqs.push({
name, currentValue, requiredValue, isSuccess,
change: prev ? currentValue - prev.currentValue : 0,
isReverse: PATTERNS.REVERSE.test(name)
});
}
const orderedReqs = Utils.reorderRequirements(reqs);
const isOK = !section.querySelector('p.text-red-500');
this.notifier.check(orderedReqs);
const histData = {};
orderedReqs.forEach(r => histData[r.name] = r.currentValue);
const history = this.historyMgr.addHistory(histData, this.readingTime);
// 触发升级要求数据上传(trust_level >= 2 时异步上传)
if (this.hasLeaderboard && this.cloudSync && this.oauth?.isLoggedIn()) {
this.cloudSync.uploadRequirements().catch(() => {});
}
const todayData = this._getTodayData();
this._setTodayData(histData, !todayData);
// 如果已登录,优先使用 OAuth 用户信息中的 name
let displayName = null;
if (this.hasLeaderboard && this.oauth?.isLoggedIn()) {
const oauthUser = this.oauth.getUserInfo();
if (oauthUser?.name && oauthUser.name !== oauthUser.username) {
displayName = oauthUser.name;
}
}
this.renderer.renderUser(username, level, isOK, orderedReqs, displayName);
this.renderer.renderReqs(orderedReqs, level);
this.cachedHistory = history;
this.cachedReqs = orderedReqs;
this._renderTrends(history, orderedReqs);
this._setLastVisit(histData);
this.prevReqs = orderedReqs;
}
_getTodayData() {
const stored = this.storage.get('todayData', null);
return stored?.date === Utils.getTodayKey() ? stored : null;
}
_setTodayData(data, isStart = false) {
const today = Utils.getTodayKey();
const existing = this._getTodayData();
const now = Date.now();
this.storage.set('todayData', isStart || !existing
? { date: today, startData: data, startTs: now, currentData: data, currentTs: now }
: { ...existing, currentData: data, currentTs: now }
);
}
_setLastVisit(data) {
this.storage.set('lastVisit', { ts: Date.now(), data });
}
_renderTrends(history, reqs) {
this.renderer.renderTrends(this.trendTab);
this.$.trends.querySelectorAll('.ldsp-subtab').forEach(tab => {
tab.addEventListener('click', () => {
this.trendTab = tab.dataset.tab;
this.storage.setGlobal('trendTab', this.trendTab);
this.$.trends.querySelectorAll('.ldsp-subtab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._renderTrendContent(history, reqs);
});
});
this._renderTrendContent(history, reqs);
}
_renderTrendContent(history, reqs) {
const container = this.$.trends.querySelector('.ldsp-trend-content');
if (this.trendTab === 'year') {
container.innerHTML = ``;
requestAnimationFrame(() => {
setTimeout(() => {
container.innerHTML = this.renderer.renderYearTrend(history, reqs, this.historyMgr, this.tracker);
// 自动滚动热力图到today位置(底部),使用程序性滚动避免显示滚动条
const heatmap = container.querySelector('.ldsp-year-heatmap');
if (heatmap) {
requestAnimationFrame(() => this._scrollTo(heatmap, heatmap.scrollHeight));
}
}, 50);
});
return;
}
const fns = {
// 使用 tracker.getTodayTime() 获取实时阅读时间,而不是缓存的 this.readingTime
today: () => this.renderer.renderTodayTrend(reqs, this.tracker.getTodayTime(), this._getTodayData()),
week: () => this.renderer.renderWeekTrend(history, reqs, this.historyMgr, this.tracker),
month: () => this.renderer.renderMonthTrend(history, reqs, this.historyMgr, this.tracker),
all: () => this.renderer.renderAllTrend(history, reqs, this.tracker)
};
container.innerHTML = fns[this.trendTab]?.() || '';
}
/**
* 加载并显示系统公告(公开接口,不需要登录)
*/
async _loadAnnouncement() {
try {
const result = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${CONFIG.LEADERBOARD_API}/api/config/announcement`,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
onload: res => {
if (res.status >= 200 && res.status < 300) {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(new Error('Parse error'));
}
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
if (!result.success || !result.data) return;
// 处理可能的双重嵌套: result.data.data 或 result.data
const announcement = result.data.data || result.data;
if (!announcement.enabled) return;
// v3.3.3: 支持多条公告 - 兼容旧版单条公告格式
let items = [];
if (Array.isArray(announcement.items) && announcement.items.length > 0) {
items = announcement.items;
} else if (announcement.content) {
// 兼容旧版单条公告格式
items = [{
content: announcement.content,
type: announcement.type || 'info',
expiresAt: announcement.expiresAt || null
}];
}
// 过滤已过期的公告
const now = Date.now();
items = items.filter(item => !item.expiresAt || item.expiresAt > now);
if (items.length === 0) {
return;
}
// 显示公告
this._showAnnouncements(items);
} catch (e) {
console.warn('[Announcement] Load failed:', e.message);
}
}
/**
* 显示多条公告轮播
* @param {Array} items - 公告数组 [{content, type, expiresAt}, ...]
*/
_showAnnouncements(items) {
if (!this.$.announcement || !this.$.announcementText) return;
// 清除之前的轮播定时器
if (this._announcementTimer) {
clearTimeout(this._announcementTimer);
this._announcementTimer = null;
}
this._announcementItems = items;
this._announcementIndex = 0;
// 显示第一条公告
this._displayCurrentAnnouncement();
// 显示公告栏
requestAnimationFrame(() => {
this.$.announcement.classList.add('active');
});
}
/**
* 安排下一条公告的切换(使用动画结束事件)
*/
_scheduleNextAnnouncement() {
if (this._announcementItems.length <= 1) return;
const inner = this.$.announcement.querySelector('.ldsp-announcement-inner');
if (!inner) return;
// 移除旧的监听器
if (this._announcementEndHandler) {
inner.removeEventListener('animationend', this._announcementEndHandler);
}
// 添加新的动画结束监听器
this._announcementEndHandler = () => {
this._announcementIndex = (this._announcementIndex + 1) % this._announcementItems.length;
this._displayCurrentAnnouncement();
};
inner.addEventListener('animationend', this._announcementEndHandler, { once: true });
}
/**
* 显示当前索引的公告
*/
_displayCurrentAnnouncement() {
const item = this._announcementItems[this._announcementIndex];
if (!item) return;
// 设置公告类型样式
this.$.announcement.className = 'ldsp-announcement active';
if (item.type && item.type !== 'info') {
this.$.announcement.classList.add(item.type);
}
// 设置公告内容(带序号,如果多条)
const prefix = this._announcementItems.length > 1
? `[${this._announcementIndex + 1}/${this._announcementItems.length}] `
: '';
this.$.announcementText.textContent = prefix + item.content;
// 根据文字长度设置滚动速度
const textLength = (prefix + item.content).length;
const duration = Math.max(10, Math.min(30, textLength * 0.3));
this.$.announcement.style.setProperty('--marquee-duration', `${duration}s`);
// 重置动画
const inner = this.$.announcement.querySelector('.ldsp-announcement-inner');
if (inner) {
inner.style.animation = 'none';
inner.offsetHeight; // 触发重排
inner.style.animation = '';
}
// 安排下一条公告切换
this._scheduleNextAnnouncement();
}
async _checkUpdate(autoCheck = false) {
const url = 'https://raw.githubusercontent.com/caigg188/LDStatusPro/main/LDStatusPro.user.js';
this.$.btnUpdate.textContent = '⏳';
try {
const text = await this.network.fetch(url, { maxRetries: 1 });
const match = text.match(PATTERNS.VERSION);
if (match) {
const remote = match[1];
const current = GM_info.script.version;
if (Utils.compareVersion(remote, current) > 0) {
this.$.btnUpdate.textContent = '🆕';
this.$.btnUpdate.title = `新版本 v${remote}`;
this.$.btnUpdate.classList.add('has-update');
this._remoteVersion = remote;
this._updateUrl = url;
// 检查是否已经提示过这个版本
const dismissedVer = this.storage.getGlobal('dismissedUpdateVer', '');
const shouldShowBubble = autoCheck
? (dismissedVer !== remote) // 自动检查:只有未忽略的版本才显示
: true; // 手动检查:总是显示
if (shouldShowBubble) {
this._showUpdateBubble(current, remote);
}
this.$.btnUpdate.onclick = () => this._showUpdateBubble(current, remote);
} else {
this.$.btnUpdate.textContent = '✅';
this.$.btnUpdate.title = '已是最新版本';
this.$.btnUpdate.classList.remove('has-update');
if (!autoCheck) {
this.renderer.showToast('✅ 已是最新版本');
}
setTimeout(() => {
this.$.btnUpdate.textContent = '🔍';
this.$.btnUpdate.title = '检查更新';
}, 2000);
}
}
} catch (e) {
this.$.btnUpdate.textContent = '❌';
this.$.btnUpdate.title = '检查失败';
if (!autoCheck) {
this.renderer.showToast('❌ 检查更新失败');
}
setTimeout(() => {
this.$.btnUpdate.textContent = '🔍';
this.$.btnUpdate.title = '检查更新';
}, 2000);
}
}
_showUpdateBubble(current, remote) {
this.$.updateBubbleVer.innerHTML = `v${current} → v${remote} `;
this.$.updateBubble.style.display = 'block';
// 延迟一帧添加动画类,确保过渡效果生效
requestAnimationFrame(() => {
this.$.updateBubble.classList.add('show');
});
// 绑定关闭按钮
this.$.updateBubbleClose.onclick = () => this._hideUpdateBubble(true);
// 绑定更新按钮
this.$.updateBubbleBtn.onclick = () => this._doUpdate();
}
_hideUpdateBubble(dismiss = false) {
// 如果用户主动关闭,记录已忽略的版本
if (dismiss && this._remoteVersion) {
this.storage.setGlobalNow('dismissedUpdateVer', this._remoteVersion);
}
this.$.updateBubble.classList.remove('show');
setTimeout(() => {
this.$.updateBubble.style.display = 'none';
}, 300);
}
_doUpdate() {
this.$.updateBubbleBtn.disabled = true;
this.$.updateBubbleBtn.textContent = '⏳ 更新中...';
// 打开更新链接,Tampermonkey 会自动弹出更新确认
window.open(this._updateUrl || 'https://raw.githubusercontent.com/caigg188/LDStatusPro/main/LDStatusPro.user.js');
// 提示用户
setTimeout(() => {
this.$.updateBubbleBtn.textContent = '✅ 请在弹出窗口确认更新';
setTimeout(() => {
this._hideUpdateBubble();
this.$.updateBubbleBtn.disabled = false;
this.$.updateBubbleBtn.textContent = '🚀 立即更新';
}, 3000);
}, 1000);
}
// ========== 登录相关 ==========
_updateLoginUI() {
// 无排行榜站点隐藏登录相关按钮
if (!this.hasLeaderboard) {
if (this.$.btnCloudSync) this.$.btnCloudSync.style.display = 'none';
if (this.$.logoutBtn) this.$.logoutBtn.style.display = 'none';
if (this.$.ticketBtn) this.$.ticketBtn.style.display = 'none';
if (this.$.loginBtn) this.$.loginBtn.style.display = 'none';
return;
}
const logged = this.oauth.isLoggedIn();
const forumLogged = !!this.storage.getUser(); // 论坛登录状态
this.$.user.classList.toggle('not-logged', !logged);
// 显示/隐藏云同步按钮
if (this.$.btnCloudSync) {
this.$.btnCloudSync.style.display = logged ? '' : 'none';
}
// 显示/隐藏注销按钮和工单按钮(未登录时都隐藏)
if (this.$.logoutBtn) {
this.$.logoutBtn.style.display = logged ? '' : 'none';
}
if (this.$.ticketBtn) {
this.$.ticketBtn.style.display = logged ? '' : 'none';
}
// 显示/隐藏登录按钮(已登录时隐藏)
if (this.$.loginBtn) {
this.$.loginBtn.style.display = logged ? 'none' : '';
}
// 显示/隐藏关注粉丝和天数(论坛未登录时隐藏整个容器)
const userMeta = this.el.querySelector('.ldsp-user-meta');
if (userMeta) {
userMeta.style.display = forumLogged ? '' : 'none';
}
if (!logged) {
this._bindUserLogin();
}
}
_bindLoginButton() {
if (this._loginBtnBound || !this.$.loginBtn) return;
this._loginBtnBound = true;
this.$.loginBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!this.oauth?.isLoggedIn()) {
await this._doLogin();
}
});
}
_bindUserLogin() {
if (this._userLoginBound) return;
this._userLoginBound = true;
const handle = async e => {
if (!this.oauth.isLoggedIn() && this.$.user.classList.contains('not-logged')) {
e.stopPropagation();
await this._doLogin();
}
};
this.$.user.querySelector('.ldsp-avatar-wrap')?.addEventListener('click', handle);
this.$.userDisplayName.addEventListener('click', handle);
// 绑定登录按钮
this._bindLoginButton();
}
/**
* 检查并处理待处理的 OAuth 登录结果
* 统一同窗口登录模式:用户授权后会跳转回原页面,登录结果通过 URL hash 传递
* 数据在脚本最开始就被捕获到 _pendingOAuthData 全局变量
*/
_checkPendingOAuthLogin() {
console.log('[OAuth] _checkPendingOAuthLogin called, _pendingOAuthData:', _pendingOAuthData ? 'present' : 'null');
// 优先使用脚本启动时捕获的数据(避免 Discourse 路由处理掉 hash)
let pendingResult = _pendingOAuthData;
_pendingOAuthData = null; // 清除已使用的数据
// 备用:再次尝试从 URL hash 读取
if (!pendingResult) {
console.log('[OAuth] No early captured data, trying URL hash fallback...');
pendingResult = this.oauth._checkUrlHashLogin();
}
console.log('[OAuth] pendingResult:', pendingResult ? { success: pendingResult.success, hasToken: !!pendingResult.token, hasUser: !!pendingResult.user } : 'null');
if (pendingResult?.success && pendingResult.token && pendingResult.user) {
console.log('[OAuth] ✅ Processing login result for user:', pendingResult.user?.username);
// 【关键】先同步保存登录信息,确保后续的 isLoggedIn() 检查能返回 true
this.oauth.setToken(pendingResult.token);
this.oauth.setUserInfo(pendingResult.user);
this.oauth.setJoined(pendingResult.isJoined || false);
// 处理登录结果(异步操作如同步、UI更新等)
this._handlePendingLoginResult(pendingResult);
return true; // 返回 true 表示有登录结果被处理
} else {
console.log('[OAuth] No valid pending login result');
return false;
}
}
// 处理待处理的登录结果(登录信息已在 _checkPendingOAuthLogin 中同步保存)
async _handlePendingLoginResult(result) {
try {
this.renderer.showToast('✅ 登录成功');
// 同步用户名到 storage
if (result.user?.username) {
this.storage.setUser(result.user.username);
this.storage.invalidateCache();
this.storage.migrate(result.user.username);
this._updateUserInfoFromOAuth(result.user);
}
this._updateLoginUI();
await this._syncPrefs();
// 登录成功后重新获取 connect 页面数据(此时 OAuth 用户信息已设置,可以正确显示信任等级)
this.fetch();
// 首次登录后的完整同步:阅读数据 + 要求数据
this.cloudSync.fullSync().then(() => {
// 阅读数据同步完成后,同步要求数据(信任等级进度)
return this.cloudSync.syncRequirementsOnLoad();
}).catch(e => console.warn('[CloudSync]', e));
} catch (e) {
console.error('[OAuth] Handle pending login error:', e);
}
}
async _doLogin() {
try {
this.renderer.showToast('⏳ 正在跳转到授权页面...');
// 统一同窗口登录:login() 会跳转页面,不会返回
// 登录成功后页面会跳转回来,由 _checkPendingOAuthLogin 处理结果
await this.oauth.login();
// 如果 login() 返回了用户(从 localStorage 读取的待处理结果),处理它
// 注意:正常情况下不会执行到这里,因为页面会跳转
} catch (e) {
this.renderer.showToast(`❌ ${e.message}`);
}
}
// 使用 OAuth 用户信息更新界面
_updateUserInfoFromOAuth(user) {
if (!user) return;
const $ = this.$;
// 显示用户名和昵称
if (user.name && user.name !== user.username) {
$.userDisplayName.textContent = user.name;
$.userHandle.textContent = `@${user.username}`;
$.userHandle.style.display = '';
} else {
$.userDisplayName.textContent = user.username;
$.userHandle.textContent = '';
$.userHandle.style.display = 'none';
}
// 更新头像(如果有)
if (user.avatar_url) {
this._updateAvatar(user.avatar_url.startsWith('http') ? user.avatar_url : `https://linux.do${user.avatar_url}`);
}
}
_checkLoginPrompt() {
const KEY = 'ldsp_login_prompt_version';
const VER = '3.0';
if (this.storage.getGlobal(KEY, null) === VER) {
this._updateLoginUI();
return;
}
const hasData = this.storage.get('readingTime', null);
const isUpgrade = hasData && Object.keys(hasData.dailyData || {}).length > 0;
setTimeout(() => {
const overlay = this.renderer.showLoginPrompt(isUpgrade);
this._bindLoginPrompt(overlay, KEY, VER);
}, 1500);
}
_bindLoginPrompt(overlay, key, ver) {
const close = (skipped = false) => {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 300);
this.storage.setGlobalNow(key, ver);
skipped && this._updateLoginUI();
};
const loginBtn = overlay.querySelector('#ldsp-modal-login');
loginBtn?.addEventListener('click', async () => {
loginBtn.disabled = true;
loginBtn.textContent = '⏳ 跳转中...';
try {
// 统一同窗口登录:会跳转到授权页面
// 登录成功后返回此页面,由 _checkPendingOAuthLogin 处理
await this.oauth.login();
// 正常情况下不会执行到这里,因为页面会跳转
} catch (e) {
this.renderer.showToast(`❌ ${e.message}`);
loginBtn.disabled = false;
loginBtn.textContent = '🚀 立即登录';
}
});
overlay.querySelector('#ldsp-modal-skip')?.addEventListener('click', () => close(true));
overlay.addEventListener('click', e => e.target === overlay && close(true));
}
async _syncPrefs() {
if (!this.hasLeaderboard || !this.oauth.isLoggedIn()) return;
try {
const result = await this.oauth.api('/api/user/status');
if (result.success && result.data) {
this.oauth.setJoined(result.data.isJoined || false);
if (this.oauth.isJoined()) this.leaderboard.startSync();
}
} catch (e) {
console.warn('[Prefs]', e);
}
}
// ========== 我的活动 ==========
async _renderActivity() {
if (!this.$.activity) return;
// 检查论坛登录状态:未登录时显示登录提示
const username = this.storage.getUser();
if (!username) {
this.$.activity.innerHTML = `
`;
return;
}
this.renderer.renderActivity(this.activitySubTab);
// 绑定子tab点击事件
this.$.activity.querySelectorAll('.ldsp-subtab').forEach(tab => {
tab.addEventListener('click', () => {
const tabId = tab.dataset.activity;
this.activitySubTab = tabId;
this.$.activity.querySelectorAll('.ldsp-subtab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// 清除缓存强制重新加载
this.activityMgr.clearCache(tabId);
this._renderActivityContent();
});
});
await this._renderActivityContent();
}
async _renderActivityContent() {
const container = this.$.activity.querySelector('.ldsp-activity-content');
if (!container) return;
// 清理之前的滚动事件
this._cleanupActivityScroll();
container.innerHTML = this.renderer.renderActivityLoading();
try {
switch (this.activitySubTab) {
case 'read':
await this._loadReadTopics(container);
break;
case 'bookmarks':
await this._loadBookmarks(container);
break;
case 'replies':
await this._loadReplies(container);
break;
case 'likes':
await this._loadLikes(container);
break;
case 'topics':
await this._loadMyTopics(container);
break;
default:
container.innerHTML = this.renderer.renderActivityEmpty('📭', '请选择一个分类');
}
} catch (e) {
container.innerHTML = this.renderer.renderActivityError(e.message || '加载失败');
container.querySelector('.ldsp-activity-retry')?.addEventListener('click', () => {
this.activityMgr.clearCache(this.activitySubTab);
this._renderActivityContent();
});
}
}
async _loadReadTopics(container) {
// 获取当前分页状态
let state = this.activityMgr.getPageState('read');
// 如果是首次加载,重置状态
if (state.allTopics.length === 0) {
state = { page: 0, allTopics: [], hasMore: true };
}
try {
const result = await this.activityMgr.getReadTopics(state.page);
// 合并话题(避免重复)
const existingIds = new Set(state.allTopics.map(t => t.id));
const newTopics = result.topics.filter(t => !existingIds.has(t.id));
state.allTopics = [...state.allTopics, ...newTopics];
state.hasMore = result.hasMore;
this.activityMgr.setPageState('read', state);
container.innerHTML = this.renderer.renderTopicList(state.allTopics, state.hasMore);
// 绑定瀑布流滚动加载
if (state.hasMore) {
this._bindActivityScroll(container);
}
} catch (e) {
if (state.allTopics.length > 0) {
// 如果已有数据,显示已有数据并提示加载更多失败
container.innerHTML = this.renderer.renderTopicList(state.allTopics, false);
this.renderer.showToast(`⚠️ ${e.message}`);
} else {
throw e;
}
}
}
async _loadBookmarks(container) {
// 获取当前用户名
const username = this.storage.getUser();
if (!username) {
container.innerHTML = this.renderer.renderActivityEmpty('🔒', '请先登录论坛');
return;
}
// 获取当前分页状态
let state = this.activityMgr.getPageState('bookmarks');
// 如果是首次加载,重置状态
if (!state.allItems || state.allItems.length === 0) {
state = { page: 0, allItems: [], hasMore: true };
}
try {
const result = await this.activityMgr.getBookmarks(state.page, username);
// 合并收藏(避免重复)
const existingIds = new Set(state.allItems.map(b => b.id));
const newItems = result.bookmarks.filter(b => !existingIds.has(b.id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState('bookmarks', state);
container.innerHTML = this.renderer.renderBookmarkList(state.allItems, state.hasMore);
this._bindBookmarkClicks(container);
// 绑定瀑布流滚动加载
if (state.hasMore) {
this._bindActivityScroll(container, 'bookmarks');
}
} catch (e) {
if (state.allItems && state.allItems.length > 0) {
// 如果已有数据,显示已有数据并提示加载更多失败
container.innerHTML = this.renderer.renderBookmarkList(state.allItems, false);
this._bindBookmarkClicks(container);
this.renderer.showToast(`⚠️ ${e.message}`);
} else {
throw e;
}
}
}
async _loadReplies(container) {
// 获取当前用户名
const username = this.storage.getUser();
if (!username) {
container.innerHTML = this.renderer.renderActivityEmpty('🔒', '请先登录论坛');
return;
}
// 获取当前分页状态
let state = this.activityMgr.getPageState('replies');
// 如果是首次加载,重置状态
if (!state.allItems || state.allItems.length === 0) {
state = { offset: 0, allItems: [], hasMore: true };
}
try {
const result = await this.activityMgr.getReplies(state.offset, username);
// 合并回复(避免重复,使用 post_id 作为唯一标识)
const existingIds = new Set(state.allItems.map(r => r.post_id));
const newItems = result.replies.filter(r => !existingIds.has(r.post_id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState('replies', state);
container.innerHTML = this.renderer.renderReplyList(state.allItems, state.hasMore);
this._bindReplyClicks(container);
// 绑定瀑布流滚动加载
if (state.hasMore) {
this._bindActivityScroll(container, 'replies');
}
} catch (e) {
if (state.allItems && state.allItems.length > 0) {
// 如果已有数据,显示已有数据并提示加载更多失败
container.innerHTML = this.renderer.renderReplyList(state.allItems, false);
this._bindReplyClicks(container);
this.renderer.showToast(`⚠️ ${e.message}`);
} else {
throw e;
}
}
}
_bindReplyClicks(container) {
container.querySelectorAll('.ldsp-reply-item[data-url]').forEach(item => {
item.addEventListener('click', (e) => {
// 如果点击的是excerpt内的链接,不阻止默认行为
if (e.target.closest('.ldsp-reply-excerpt a')) {
return;
}
e.preventDefault();
const url = item.dataset.url;
if (url && url !== '#') {
window.open(url, '_blank');
}
});
});
}
async _loadLikes(container) {
// 获取当前用户名
const username = this.storage.getUser();
if (!username) {
container.innerHTML = this.renderer.renderActivityEmpty('🔒', '请先登录论坛');
return;
}
// 获取当前分页状态
let state = this.activityMgr.getPageState('likes');
// 如果是首次加载,重置状态
if (!state.allItems || state.allItems.length === 0) {
state = { offset: 0, allItems: [], hasMore: true };
}
try {
const result = await this.activityMgr.getLikes(state.offset, username);
// 合并赞过(避免重复,使用 post_id 作为唯一标识)
const existingIds = new Set(state.allItems.map(l => l.post_id));
const newItems = result.likes.filter(l => !existingIds.has(l.post_id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState('likes', state);
container.innerHTML = this.renderer.renderLikeList(state.allItems, state.hasMore);
this._bindLikeClicks(container);
// 绑定瀑布流滚动加载
if (state.hasMore) {
this._bindActivityScroll(container, 'likes');
}
} catch (e) {
if (state.allItems && state.allItems.length > 0) {
// 如果已有数据,显示已有数据并提示加载更多失败
container.innerHTML = this.renderer.renderLikeList(state.allItems, false);
this._bindLikeClicks(container);
this.renderer.showToast(`⚠️ ${e.message}`);
} else {
throw e;
}
}
}
_bindLikeClicks(container) {
container.querySelectorAll('.ldsp-like-item[data-url]').forEach(item => {
item.addEventListener('click', (e) => {
// 如果点击的是excerpt内的链接,不阻止默认行为
if (e.target.closest('.ldsp-like-excerpt a')) {
return;
}
e.preventDefault();
const url = item.dataset.url;
if (url && url !== '#') {
window.open(url, '_blank');
}
});
});
}
async _loadMyTopics(container) {
// 获取当前用户名
const username = this.storage.getUser();
if (!username) {
container.innerHTML = this.renderer.renderActivityEmpty('🔒', '请先登录论坛');
return;
}
// 获取当前分页状态
let state = this.activityMgr.getPageState('topics');
// 如果是首次加载,重置状态
if (!state.allItems || state.allItems.length === 0) {
state = { page: 0, allItems: [], hasMore: true };
}
try {
const result = await this.activityMgr.getMyTopics(state.page, username);
// 合并话题(避免重复,使用 id 作为唯一标识)
const existingIds = new Set(state.allItems.map(t => t.id));
const newItems = result.topics.filter(t => !existingIds.has(t.id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState('topics', state);
container.innerHTML = this.renderer.renderMyTopicList(state.allItems, state.hasMore);
// 绑定瀑布流滚动加载
if (state.hasMore) {
this._bindActivityScroll(container, 'topics');
}
} catch (e) {
if (state.allItems && state.allItems.length > 0) {
// 如果已有数据,显示已有数据并提示加载更多失败
container.innerHTML = this.renderer.renderMyTopicList(state.allItems, false);
this.renderer.showToast(`⚠️ ${e.message}`);
} else {
throw e;
}
}
}
_bindActivityScroll(container, type = 'read') {
const content = this.el.querySelector('.ldsp-content');
if (!content) return;
let isLoading = false;
const threshold = 100; // 距离底部100px时触发加载
this._activityScrollHandler = async () => {
if (isLoading) return;
const scrollTop = content.scrollTop;
const scrollHeight = content.scrollHeight;
const clientHeight = content.clientHeight;
if (scrollHeight - scrollTop - clientHeight < threshold) {
const state = this.activityMgr.getPageState(type);
if (!state.hasMore) return;
isLoading = true;
const loadMoreEl = container.querySelector('.ldsp-load-more');
if (loadMoreEl) {
loadMoreEl.classList.add('loading');
}
try {
// 加载下一页
let result, newItems;
const username = this.storage.getUser();
if (type === 'bookmarks') {
state.page++;
result = await this.activityMgr.getBookmarks(state.page, username);
const existingIds = new Set(state.allItems.map(b => b.id));
newItems = result.bookmarks.filter(b => !existingIds.has(b.id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState(type, state);
container.innerHTML = this.renderer.renderBookmarkList(state.allItems, state.hasMore);
this._bindBookmarkClicks(container);
} else if (type === 'replies') {
state.offset += 30;
result = await this.activityMgr.getReplies(state.offset, username);
const existingIds = new Set(state.allItems.map(r => r.post_id));
newItems = result.replies.filter(r => !existingIds.has(r.post_id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState(type, state);
container.innerHTML = this.renderer.renderReplyList(state.allItems, state.hasMore);
this._bindReplyClicks(container);
} else if (type === 'likes') {
state.offset += 30;
result = await this.activityMgr.getLikes(state.offset, username);
const existingIds = new Set(state.allItems.map(l => l.post_id));
newItems = result.likes.filter(l => !existingIds.has(l.post_id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState(type, state);
container.innerHTML = this.renderer.renderLikeList(state.allItems, state.hasMore);
this._bindLikeClicks(container);
} else if (type === 'topics') {
state.page++;
result = await this.activityMgr.getMyTopics(state.page, username);
const existingIds = new Set(state.allItems.map(t => t.id));
newItems = result.topics.filter(t => !existingIds.has(t.id));
state.allItems = [...state.allItems, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState(type, state);
container.innerHTML = this.renderer.renderMyTopicList(state.allItems, state.hasMore);
} else {
state.page++;
result = await this.activityMgr.getReadTopics(state.page);
const existingIds = new Set(state.allTopics.map(t => t.id));
newItems = result.topics.filter(t => !existingIds.has(t.id));
state.allTopics = [...state.allTopics, ...newItems];
state.hasMore = result.hasMore;
this.activityMgr.setPageState(type, state);
container.innerHTML = this.renderer.renderTopicList(state.allTopics, state.hasMore);
}
if (state.hasMore) {
// 继续监听
isLoading = false;
} else {
this._cleanupActivityScroll();
}
} catch (e) {
this.renderer.showToast(`⚠️ 加载更多失败: ${e.message}`);
// 回退页码/偏移
if (type === 'replies' || type === 'likes') {
state.offset -= 30;
} else {
state.page--;
}
this.activityMgr.setPageState(type, state);
isLoading = false;
if (loadMoreEl) {
loadMoreEl.classList.remove('loading');
loadMoreEl.innerHTML = '加载失败,向下滚动重试 ';
}
}
}
};
content.addEventListener('scroll', this._activityScrollHandler, { passive: true });
}
_bindBookmarkClicks(container) {
container.querySelectorAll('.ldsp-bookmark-item[data-url]').forEach(item => {
item.addEventListener('click', (e) => {
// 如果点击的是excerpt内的链接,不阻止默认行为
if (e.target.closest('.ldsp-bookmark-excerpt a')) {
return;
}
e.preventDefault();
const url = item.dataset.url;
if (url && url !== '#') {
window.open(url, '_blank');
}
});
});
}
_cleanupActivityScroll() {
if (this._activityScrollHandler) {
const content = this.el.querySelector('.ldsp-content');
if (content) {
content.removeEventListener('scroll', this._activityScrollHandler);
}
this._activityScrollHandler = null;
}
// 重置分页状态
this.activityMgr.setPageState('read', { page: 0, allTopics: [], hasMore: true });
this.activityMgr.setPageState('bookmarks', { page: 0, allItems: [], hasMore: true });
this.activityMgr.setPageState('replies', { offset: 0, allItems: [], hasMore: true });
this.activityMgr.setPageState('likes', { offset: 0, allItems: [], hasMore: true });
this.activityMgr.setPageState('topics', { page: 0, allItems: [], hasMore: true });
}
// ========== 排行榜 ==========
async _renderLeaderboard() {
if (!this.hasLeaderboard || !this.$.leaderboard) return;
const logged = this.oauth.isLoggedIn();
const joined = this.oauth.isJoined();
this.renderer.renderLeaderboard(this.lbTab, logged, joined);
this.$.leaderboard.querySelectorAll('.ldsp-subtab').forEach(tab => {
tab.addEventListener('click', () => {
this.lbTab = tab.dataset.lb;
this.storage.setGlobal('leaderboardTab', this.lbTab);
this.$.leaderboard.querySelectorAll('.ldsp-subtab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._renderLeaderboardContent();
});
});
await this._renderLeaderboardContent();
}
async _renderLeaderboardContent() {
if (!this.hasLeaderboard) return;
const container = this.$.leaderboard.querySelector('.ldsp-lb-content');
if (!container) return;
const logged = this.oauth.isLoggedIn();
const joined = this.oauth.isJoined();
if (!logged) {
container.innerHTML = this.renderer.renderLeaderboardLogin();
const loginBtn = container.querySelector('#ldsp-lb-login');
if (loginBtn) {
loginBtn.onclick = async () => {
loginBtn.disabled = true;
loginBtn.textContent = '⏳ 跳转中...';
try {
// 统一同窗口登录:会跳转到授权页面
await this.oauth.login();
// 正常情况下不会执行到这里,因为页面会跳转
} catch (e) {
this.renderer.showToast(`❌ ${e.message}`);
loginBtn.disabled = false;
loginBtn.textContent = '🚀 立即登录';
}
};
}
return;
}
if (!joined) {
container.innerHTML = this.renderer.renderLeaderboardJoin();
const joinBtn = container.querySelector('#ldsp-lb-join');
if (joinBtn) {
joinBtn.onclick = async () => {
joinBtn.disabled = true;
joinBtn.textContent = '⏳ 加入中...';
try {
await this.leaderboard.join();
this.leaderboard.startSync();
this.renderer.showToast('✅ 已成功加入排行榜');
await this._renderLeaderboardContent();
} catch (e) {
this.renderer.showToast(`❌ ${e.message}`);
joinBtn.disabled = false;
joinBtn.textContent = '✨ 加入排行榜';
}
};
}
return;
}
container.innerHTML = this.renderer.renderLeaderboardLoading();
try {
const data = await this.leaderboard.getLeaderboard(this.lbTab);
const user = this.oauth.getUserInfo();
container.innerHTML = this.renderer.renderLeaderboardData(data, user?.id, joined, this.lbTab);
this._bindLeaderboardEvents(container, joined);
} catch (e) {
container.innerHTML = this.renderer.renderLeaderboardError(e.message || '加载失败');
container.querySelector('#ldsp-lb-retry')?.addEventListener('click', () => {
this.leaderboard.clearCache();
this._renderLeaderboardContent();
});
}
}
// 绑定排行榜内容区的事件(统一绑定,避免代码重复)
_bindLeaderboardEvents(container, joined) {
// 手动刷新按钮
const refreshBtn = container.querySelector('.ldsp-lb-refresh');
if (refreshBtn) {
refreshBtn.onclick = async (e) => {
const btn = e.target;
const type = btn.dataset.type;
if (btn.disabled) return;
const cooldown = this.leaderboard.getRefreshCooldown(type);
if (cooldown > 0) {
this.renderer.showToast(`⏳ 请等待 ${cooldown} 秒后再刷新`);
return;
}
btn.disabled = true;
btn.classList.add('spinning');
try {
const result = await this.leaderboard.forceRefresh(type);
this.renderer.showToast(result.fromCache ? '📦 获取缓存数据' : '✅ 已刷新排行榜');
const userData = this.oauth.getUserInfo();
container.innerHTML = this.renderer.renderLeaderboardData(result.data, userData?.id, joined, type);
this._bindLeaderboardEvents(container, joined);
} catch (err) {
this.renderer.showToast(`❌ ${err.message}`);
btn.disabled = false;
btn.classList.remove('spinning');
}
};
}
// 退出排行榜按钮
const quitBtn = container.querySelector('#ldsp-lb-quit');
if (quitBtn) {
quitBtn.onclick = async () => {
if (!confirm('确定要退出排行榜吗?')) return;
quitBtn.disabled = true;
quitBtn.textContent = '退出中...';
try {
await this.leaderboard.quit();
this.leaderboard.stopSync();
this.renderer.showToast('✅ 已退出排行榜');
await this._renderLeaderboardContent();
} catch (e) {
this.renderer.showToast(`❌ ${e.message}`);
quitBtn.disabled = false;
quitBtn.textContent = '退出排行榜';
}
};
}
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
// 清理定时器
if (this._refreshTimer) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
}
if (this._readingTimer) {
clearInterval(this._readingTimer);
this._readingTimer = null;
}
// 清理事件监听器
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
}
// 清理阅读追踪器
this.tracker?.destroy();
// 清理排行榜相关
if (this.hasLeaderboard) {
this.leaderboard?.destroy();
this.cloudSync?.destroy();
}
// 清理工单管理器
this.ticketManager?.destroy();
// 清理吃瓜助手
this.melonHelper?.destroy();
// 清理关注/粉丝管理器
this.followManager?.destroy();
// 保存数据
this.storage?.flush();
// 清理事件总线
EventBus.clear();
// 移除面板
this.el?.remove();
Logger.log('Panel destroyed');
}
}
// ==================== 启动 ====================
async function startup() {
// 初始化全局领导者管理器(必须在其他组件之前)
TabLeader.init();
// 性能优化:使用 requestIdleCallback 在空闲时加载非关键配置
requestIdleCallback(() => {
Network.loadReadingLevels().catch(() => {});
}, { timeout: 3000 });
// 创建面板
try {
new Panel();
} catch (e) {
Logger.error('Panel initialization failed:', e);
}
}
// 确保 DOM 就绪后启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startup, { once: true });
} else {
// 使用 requestAnimationFrame 确保在下一帧渲染
requestAnimationFrame(startup);
}
})();