// ==UserScript== // @name LDStatus Pro // @namespace http://tampermonkey.net/ // @version 3.5.0.0 // @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 // @icon https://linux.do/uploads/default/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_180x180.png // @downloadURL none // ==/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' }, // 用户特定的存储键 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, getSize() { const now = Date.now(); if (this._cache && (now - this._cacheTime) < CONFIG.CACHE.SCREEN_TTL) { return this._cache; } const { innerWidth: w, innerHeight: h } = window; this._cache = (w < 1400 || h < 800) ? 'small' : w < 1920 ? 'medium' : 'large'; this._cacheTime = now; return this._cache; }, getConfig() { const configs = { small: { width: 280, maxHeight: Math.min(innerHeight - 100, 450), fontSize: 11, padding: 10, avatarSize: 44, ringSize: 70 }, medium: { width: 300, maxHeight: Math.min(innerHeight - 100, 520), fontSize: 12, padding: 12, avatarSize: 48, ringSize: 76 }, large: { width: 320, maxHeight: 580, fontSize: 12, padding: 14, avatarSize: 52, ringSize: 80 } }; return configs[this.getSize()]; } }; // ==================== 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; } } 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);--shadow:0 20px 50px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.04);--shadow-lg:0 25px 70px rgba(0,0,0,.5),0 0 30px rgba(107,140,239,.06);--shadow-glow:0 0 20px rgba(107,140,239,.15);--glow-accent:0 0 15px rgba(107,140,239,.2);--scrollbar:rgba(140,150,175,.5);--scrollbar-hover:rgba(140,150,175,.7);--r-xs:4px;--r-sm:8px;--r-md:12px;--r-lg:16px;--r-xl:20px;--w:${c.width}px;--h:${c.maxHeight}px;--fs:${c.fontSize}px;--pd:${c.padding}px;--av:${c.avatarSize}px;--ring:${c.ringSize}px;display:flex;flex-direction:column;position:fixed;left:12px;top:80px;right:auto;width:var(--w);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:1px solid var(--border);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,.05);--border2:rgba(0,0,0,.08);--border-accent:rgba(80,112,208,.2);--shadow:0 20px 50px rgba(0,0,0,.07),0 0 0 1px rgba(0,0,0,.04);--shadow-lg:0 25px 70px rgba(0,0,0,.1);--glow-accent:0 0 15px rgba(80,112,208,.1);--scrollbar:var(--accent);--scrollbar-hover:var(--accent-light)} #ldsp-panel.collapsed{width:48px!important;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:10px 12px;gap:8px;background:var(--bg);border-bottom:1px solid var(--border);flex-shrink:0} .ldsp-tab{flex:1;padding:8px 12px;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} .ldsp-tab .ldsp-tab-icon{flex-shrink:0} .ldsp-tab .ldsp-tab-text{overflow:hidden;text-overflow:ellipsis} .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} @media (max-width:340px){.ldsp-tab{font-size:10px;padding:6px 8px;gap:3px}.ldsp-tab .ldsp-tab-icon{display:none}} @media (max-width:280px){.ldsp-tab{font-size:9px;padding:5px 6px}} .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;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} .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:calc(100% - 40px);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} .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:var(--txt);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-box{padding:16px 12px;max-width:220px;width:calc(100% - 24px)}.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 (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}} @media (min-width:1920px){#ldsp-panel{--w:340px;--fs:13px;--pd:16px;--av:50px;--ring:85px}} @media (max-height:700px){#ldsp-panel{top:60px}.ldsp-content{max-height:calc(100vh - 240px)}} @media (max-width:1200px){#ldsp-panel{left:10px;right:auto}} @media (max-width:768px){#ldsp-panel{--w:280px;--fs:12px;--pd:11px;left:8px;right:auto;top:60px}#ldsp-panel.collapsed{width:42px!important;height:42px!important;border-radius:12px}#ldsp-panel.collapsed .ldsp-toggle{font-size:16px}#ldsp-panel.collapsed .ldsp-toggle-logo{width:22px;height:22px}.ldsp-hdr{padding:8px 10px;gap:6px;min-height:46px}.ldsp-hdr-info{gap:6px}.ldsp-hdr-text{gap:0}.ldsp-site-icon{width:22px;height:22px;border-radius:6px}.ldsp-site-ver{font-size:8px;padding:1px 4px}.ldsp-title{font-size:12px}.ldsp-ver{font-size:8px}.ldsp-app-name{font-size:9px}.ldsp-hdr-btns{gap:3px}.ldsp-hdr-btns button{width:24px;height:24px;font-size:11px}.ldsp-update-bubble{width:200px;padding:14px 16px}.ldsp-content{max-height:calc(100vh - 240px)}.ldsp-rank-item{padding:10px}.ldsp-rank-num{width:26px;height:26px}.ldsp-rank-avatar{width:30px;height:30px}.ldsp-learn-trust{font-size:9px}} @media (max-width:480px){#ldsp-panel{--w:260px;--av:36px;--ring:68px;left:6px;right:auto;top:55px;border-radius:var(--r-md);max-height:70vh}#ldsp-panel.collapsed{width:38px!important;height:38px!important;border-radius:10px;max-height:none}#ldsp-panel.collapsed .ldsp-toggle{font-size:14px}#ldsp-panel.collapsed .ldsp-toggle-logo{width:20px;height:20px}.ldsp-hdr{padding:6px 8px;gap:4px;min-height:40px}.ldsp-hdr-info{gap:4px}.ldsp-hdr-text{gap:0}.ldsp-site-icon{width:18px;height:18px;border-radius:5px}.ldsp-site-ver{font-size:7px;padding:1px 3px}.ldsp-site-wrap::after{display:none}.ldsp-title{font-size:10px}.ldsp-ver{font-size:7px}.ldsp-app-name{font-size:8px}.ldsp-hdr-btns{gap:2px}.ldsp-hdr-btns button{width:22px;height:22px;font-size:10px;border-radius:5px}.ldsp-user{padding:8px 8px 20px;gap:8px}.ldsp-reading::after{bottom:-12px;font-size:7px}.ldsp-user-actions{gap:4px}.ldsp-action-btn{padding:4px 6px;font-size:9px;flex:0 1 calc(50% - 2px)}.ldsp-action-btn:only-child{flex:0 1 auto}.ldsp-reading{min-width:60px;padding:5px 8px}.ldsp-reading-icon{font-size:16px}.ldsp-reading-time{font-size:10px}.ldsp-reading-label{font-size:7px}.ldsp-tabs{padding:8px 10px;gap:6px}.ldsp-tab{padding:6px 10px;font-size:10px;border-radius:var(--r-sm)}.ldsp-section{padding:8px}.ldsp-content{max-height:none}.ldsp-rank-item{padding:8px 10px}.ldsp-rank-num{width:24px;height:24px;font-size:10px;border-radius:8px}.ldsp-rank-avatar{width:28px;height:28px;border-radius:8px}.ldsp-rank-display-name,.ldsp-rank-name-only{font-size:11px}.ldsp-rank-time{font-size:12px}.ldsp-my-rank{padding:10px}.ldsp-my-rank-val{font-size:16px}.ldsp-subtab{padding:5px 10px;font-size:9px}.ldsp-learn-trust{font-size:8px}} @media (max-height:500px){#ldsp-panel{top:40px}.ldsp-content{max-height:calc(100vh - 180px)}.ldsp-user{padding:8px 8px 18px}.ldsp-user-actions{display:none}.ldsp-tabs{padding:6px 8px}.ldsp-section{padding:6px}} .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:0 1 calc(50% - 3px);min-width:60px;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;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{flex:0 0 auto;min-width:auto;padding:5px 8px} .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-follow-stats{display:flex;gap:6px;padding:2px 0} .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-radius:var(--r-md);cursor:pointer;transition:all .15s ease;text-decoration:none;position:relative} .ldsp-topic-item::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s;border-radius:2px 0 0 2px} .ldsp-topic-item:hover{background:var(--bg-hover);border-color:rgba(107,140,239,.4);box-shadow:0 2px 8px rgba(0,0,0,.06)} .ldsp-topic-item:hover::before{opacity: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 5px;background:var(--bg-el);border-radius:6px;font-size:8px;color:var(--txt-sec);max-width:55px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ldsp-topic-tag-more{padding:1px 4px;background:rgba(107,140,239,.1);border-radius:6px;font-size:8px;color:var(--accent);font-weight:500} .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;position:relative;overflow:hidden} .ldsp-mytopic-item::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(180deg,#8b5cf6,#a78bfa);opacity:0;transition:opacity .15s} .ldsp-mytopic-item:hover{background:var(--bg-hover);border-color:rgba(139,92,246,.5);transform:translateX(2px);box-shadow:0 2px 8px rgba(139,92,246,.1)} .ldsp-mytopic-item:hover::before{opacity: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}`; } }; // ==================== 工单管理器 ==================== 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._pollTimer = null; this._isOverlayOpen = false; // 工单面板是否打开 } async init() { this._createOverlay(); await this._loadTicketTypes(); this._bindVisibilityHandler(); // 延迟 5 秒后首次检查 setTimeout(() => this._checkUnread(), 5000); } _bindVisibilityHandler() { // 页面可见性变化时控制轮询(切换标签页、最小化窗口等) this._visibilityHandler = () => { if (document.hidden) { // 页面隐藏时,停止轮询 this._stopUnreadPoll(); } else if (this._isOverlayOpen) { // 页面恢复可见且工单面板打开时,立即检查并恢复轮询 this._checkUnread(); this._startUnreadPoll(); } }; document.addEventListener('visibilitychange', this._visibilityHandler); } _createOverlay() { this.overlay = document.createElement('div'); this.overlay.className = 'ldsp-ticket-overlay'; this.overlay.innerHTML = `
v3.0 版本新增了 云同步 功能!
登录后,你的阅读数据将自动同步到云端,支持跨浏览器、跨设备访问。
` : `登录 Linux.do 账号后可以: