// ==UserScript== // @name 浏览器成就系统 // @namespace https://scriptcat.org/ // @version 2.1.0 // @author ScriptCat Developer // @description 监控上网行为解锁成就,并在配置的网站显示成就浮动窗。⚙️ 支持通过齿轮按钮自定义浮动窗显示网址。🐛 修复悬浮窗点击展开问题。 // @match *://*/* // @grant GM_info // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_log // @grant GM_registerMenuCommand // @grant GM_notification // @grant GM_openInTab // @grant GM_addValueChangeListener // @connect * // ==/UserScript== /* ==UserConfig== floatWindow: url: title: "浮动窗显示网址" description: "输入完整网址(含协议),只有在该网站才会显示成就浮动窗。修改后需刷新目标页面才能生效。" type: text default: 'https://347735.xyz' ==/UserConfig== */ // ============================================================ // 浏览器成就系统 v2.1.0 // ============================================================ // 本脚本在所有页面运行,负责成就追踪。 // 浮动窗仅在用户配置的网址(默认 https://347735.xyz)显示。 // 通过脚本猫齿轮按钮可修改 floatWindow.url 配置项。 // ============================================================ // ============================================================ // 成就列表(共10个) // ============================================================ // [x] 1. 初次启程 - 脚本首次运行 // [x] 2. 夜猫子 - 夜间(0-6点)浏览累计10次 // [x] 3. 信息猎人 - 访问50个不同网站 // [x] 4. 冲浪达人 - 访问500个不同网站 // [x] 5. Tab狂魔 - 单次打开20个以上标签页 // [x] 6. 效率大师 - 单次打开少于3个标签页 // [x] 7. 社交达人 - 在社交媒体网站累计停留2小时 // [x] 8. 视频迷 - 在视频网站观看20个视频 // [x] 9. 书签收藏家 - 添加50个书签 // [x] 10. 历史学家 - 浏览历史记录超过1000条 // ============================================================ (function() { "use strict"; // ============================================================ // 常量定义 // ============================================================ const STORAGE_PREFIX = "browser_achievement_"; const KEYS = { STATS: STORAGE_PREFIX + "stats", UNLOCKED: STORAGE_PREFIX + "unlocked", UNLOCK_TIMES: STORAGE_PREFIX + "unlock_times", FIRST_RUN: STORAGE_PREFIX + "first_run", LAST_CHECK: STORAGE_PREFIX + "last_check", UNIQUE_SITES: STORAGE_PREFIX + "unique_sites", // 修复:浮动窗状态持久化(最小化状态 + 位置) PANEL_STATE: STORAGE_PREFIX + "panel_state", }; /** 定时检查间隔(毫秒),10分钟 */ const CHECK_INTERVAL = 10 * 60 * 1000; // ============================================================ // URL 分类检测 // ============================================================ const SOCIAL_DOMAINS = [ "weibo.com", "zhihu.com", "douban.com", "tieba.baidu.com", "facebook.com", "twitter.com", "x.com", "instagram.com", "reddit.com", "linkedin.com", "tumblr.com", "pinterest.com", "discord.com", "snapchat.com", "t.me", "telegram.org" ]; const VIDEO_DOMAINS = [ "bilibili.com", "youtube.com", "youku.com", "iqiyi.com", "vimeo.com", "dailymotion.com", "twitch.tv", "douyu.com", "huya.com", "tiktok.com", "netflix.com", "hulu.com", "qq.com/x/page", "v.qq.com", "tv.sohu.com", "mgtv.com" ]; function isSocialSite(url) { if (!url) return false; const lower = url.toLowerCase(); for (let i = 0; i < SOCIAL_DOMAINS.length; i++) { if (lower.indexOf(SOCIAL_DOMAINS[i]) !== -1) return true; } return false; } function isVideoSite(url) { if (!url) return false; const lower = url.toLowerCase(); for (let i = 0; i < VIDEO_DOMAINS.length; i++) { if (lower.indexOf(VIDEO_DOMAINS[i]) !== -1) return true; } return false; } function extractHostname(url) { if (!url) return ""; try { return url.replace(/^https?:\/\//i, "").split("/")[0].split(":")[0]; } catch (e) { return url; } } // ============================================================ // 成就定义 // ============================================================ const ACHIEVEMENTS = [ { id: "first_step", name: "初次启程", description: "安装并运行浏览器成就系统", icon: "🚀", condition: function(stats, extra) { return true; }, getProgress: function(stats, extra) { return { current: 1, target: 1, label: "首次运行" }; } }, { id: "night_owl", name: "夜猫子", description: "夜间(0-6点)浏览网页累计10次", icon: "🦉", condition: function(stats, extra) { return (stats.nightBrowsingCount || 0) >= 10; }, getProgress: function(stats, extra) { return { current: stats.nightBrowsingCount || 0, target: 10, label: "夜间浏览" }; } }, { id: "info_hunter", name: "信息猎人", description: "访问50个不同网站", icon: "🔍", condition: function(stats, extra) { return extra.uniqueSitesCount >= 50; }, getProgress: function(stats, extra) { return { current: extra.uniqueSitesCount, target: 50, label: "已访问" }; } }, { id: "surf_master", name: "冲浪达人", description: "访问500个不同网站", icon: "🏄", condition: function(stats, extra) { return extra.uniqueSitesCount >= 500; }, getProgress: function(stats, extra) { return { current: extra.uniqueSitesCount, target: 500, label: "已访问" }; } }, { id: "tab_mania", name: "Tab狂魔", description: "单次打开20个以上标签页", icon: "🗂️", condition: function(stats, extra) { return (stats.maxTabsOpened || 0) >= 20; }, getProgress: function(stats, extra) { return { current: stats.maxTabsOpened || 0, target: 20, label: "最高同时打开" }; } }, { id: "efficiency_master", name: "效率大师", description: "曾有单次浏览会话仅打开少于3个标签页", icon: "⚡", condition: function(stats, extra) { return stats.minTabsOpened !== undefined && stats.minTabsOpened !== Infinity && stats.minTabsOpened < 3; }, getProgress: function(stats, extra) { const val = (stats.minTabsOpened !== undefined && stats.minTabsOpened !== Infinity) ? stats.minTabsOpened : 999; return { current: val < 3 ? 1 : 0, target: 1, label: "最少标签页: " + (val === 999 ? "?" : val) }; } }, { id: "social_butterfly", name: "社交达人", description: "在社交媒体网站累计停留2小时(7200秒)", icon: "🦋", condition: function(stats, extra) { return (stats.socialMediaTime || 0) >= 7200; }, getProgress: function(stats, extra) { const secs = stats.socialMediaTime || 0; return { current: secs, target: 7200, label: "停留 " + formatSeconds(secs) }; } }, { id: "video_fan", name: "视频迷", description: "在视频网站观看20个视频", icon: "🎬", condition: function(stats, extra) { return (stats.videoWatchedCount || 0) >= 20; }, getProgress: function(stats, extra) { return { current: stats.videoWatchedCount || 0, target: 20, label: "已观看" }; } }, { id: "bookmark_collector", name: "书签收藏家", description: "添加50个书签", icon: "⭐", condition: function(stats, extra) { return (stats.totalBookmarks || 0) >= 50; }, getProgress: function(stats, extra) { return { current: stats.totalBookmarks || 0, target: 50, label: "已收藏" }; } }, { id: "historian", name: "历史学家", description: "浏览历史记录超过1000条", icon: "📜", condition: function(stats, extra) { return (stats.historyEntriesCount || 0) >= 1000; }, getProgress: function(stats, extra) { return { current: stats.historyEntriesCount || 0, target: 1000, label: "历史记录" }; } } ]; function formatSeconds(seconds) { if (seconds < 60) return seconds + "秒"; if (seconds < 3600) return Math.floor(seconds / 60) + "分" + (seconds % 60) + "秒"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); return h + "小时" + (m > 0 ? m + "分" : ""); } // ============================================================ // 数据管理层 // ============================================================ function initStats() { return { totalWebsitesVisited: 0, maxTabsOpened: 0, minTabsOpened: Infinity, nightBrowsingCount: 0, socialMediaTime: 0, videoWatchedCount: 0, totalBookmarks: 0, historyEntriesCount: 0, }; } function getStats() { let stats = GM_getValue(KEYS.STATS); if (!stats) { stats = initStats(); GM_setValue(KEYS.STATS, stats); } return stats; } function saveStats(stats) { GM_setValue(KEYS.STATS, stats); } function getUnlocked() { let list = GM_getValue(KEYS.UNLOCKED); if (!list) { list = []; GM_setValue(KEYS.UNLOCKED, list); } return list; } function getUnlockTimes() { let times = GM_getValue(KEYS.UNLOCK_TIMES); if (!times) { times = {}; GM_setValue(KEYS.UNLOCK_TIMES, times); } return times; } function getUniqueSites() { let sites = GM_getValue(KEYS.UNIQUE_SITES); if (!sites) { sites = []; GM_setValue(KEYS.UNIQUE_SITES, sites); } return sites; } function getExtraData() { const sites = getUniqueSites(); return { uniqueSitesCount: sites.length }; } // ============================================================ // 活动记录系统 // ============================================================ function recordActivity(type, data) { if (!type) return; const stats = getStats(); let updated = false; switch (type) { case "visit": updated = handleVisit(stats, data); break; case "tabChange": if (data && typeof data.count === "number") { if (data.count > (stats.maxTabsOpened || 0)) { stats.maxTabsOpened = data.count; updated = true; GM_log("[成就系统] 标签页数更新: " + data.count + " (新纪录!)"); } if (stats.minTabsOpened === undefined || stats.minTabsOpened === Infinity) { stats.minTabsOpened = data.count; updated = true; } else if (data.count < stats.minTabsOpened) { stats.minTabsOpened = data.count; updated = true; GM_log("[成就系统] 最少标签页更新: " + data.count); } } break; case "bookmark": stats.totalBookmarks = (stats.totalBookmarks || 0) + (data && data.count || 1); updated = true; break; case "historyEntry": stats.historyEntriesCount = (stats.historyEntriesCount || 0) + (data && data.count || 1); updated = true; break; case "videoWatch": stats.videoWatchedCount = (stats.videoWatchedCount || 0) + (data && data.count || 1); updated = true; break; default: return; } if (updated) { saveStats(stats); } } function handleVisit(stats, data) { if (!data || !data.url) return false; const url = data.url; let updated = false; stats.totalWebsitesVisited = (stats.totalWebsitesVisited || 0) + 1; updated = true; const hostname = extractHostname(url); if (hostname) { const sites = getUniqueSites(); if (sites.indexOf(hostname) === -1) { sites.push(hostname); GM_setValue(KEYS.UNIQUE_SITES, sites); GM_log("[成就系统] 新站点: " + hostname + " (总数: " + sites.length + ")"); } } if (isSocialSite(url)) { stats.socialMediaTime = (stats.socialMediaTime || 0) + 300; updated = true; } if (isVideoSite(url)) { stats.videoWatchedCount = (stats.videoWatchedCount || 0) + 1; updated = true; } const hour = new Date().getHours(); if (hour >= 0 && hour < 6) { stats.nightBrowsingCount = (stats.nightBrowsingCount || 0) + 1; updated = true; } return updated; } // ============================================================ // 成就检测与解锁 // ============================================================ function unlockAchievement(achievement) { const unlocked = getUnlocked(); if (unlocked.indexOf(achievement.id) !== -1) return; unlocked.push(achievement.id); GM_setValue(KEYS.UNLOCKED, unlocked); const times = getUnlockTimes(); times[achievement.id] = Date.now(); GM_setValue(KEYS.UNLOCK_TIMES, times); GM_notification({ title: "成就解锁", text: achievement.icon + " " + achievement.name + "\n" + achievement.description, timeout: 5000, }); GM_log("[成就系统] 成就解锁: " + achievement.name); } function checkAchievements() { const stats = getStats(); const extra = getExtraData(); const unlocked = getUnlocked(); let newCount = 0; for (let i = 0; i < ACHIEVEMENTS.length; i++) { const ach = ACHIEVEMENTS[i]; if (unlocked.indexOf(ach.id) !== -1) continue; try { if (ach.condition(stats, extra)) { unlockAchievement(ach); newCount++; } } catch (e) { GM_log("[成就系统] 检测异常 [" + ach.id + "]: " + e.message); } } if (newCount > 0) { GM_log("[成就系统] 本次新解锁 " + newCount + " 个成就"); } } // ============================================================ // 首次运行处理 // ============================================================ function handleFirstRun() { if (GM_getValue(KEYS.FIRST_RUN)) return false; GM_log("[成就系统] 检测到首次运行,执行初始化..."); GM_setValue(KEYS.STATS, initStats()); GM_setValue(KEYS.UNLOCKED, []); GM_setValue(KEYS.UNLOCK_TIMES, {}); GM_setValue(KEYS.FIRST_RUN, true); GM_setValue(KEYS.LAST_CHECK, Date.now()); GM_setValue(KEYS.UNIQUE_SITES, []); GM_notification({ title: "浏览器成就系统已启动", text: "成就系统已运行,开始你的浏览之旅吧!\n在脚本猫齿轮按钮中可配置浮动窗显示网址。", timeout: 6000, }); GM_log("[成就系统] 初始化完成!"); return true; } // ============================================================ // 模拟测试数据 // ============================================================ function simulateTestData() { GM_log("[成就系统] 注入模拟测试数据..."); const testSites = [ "https://www.example.com", "https://www.google.com", "https://www.github.com", "https://www.baidu.com", "https://www.stackoverflow.com", "https://zh.wikipedia.org", "https://www.taobao.com", "https://www.jd.com", "https://www.bilibili.com", "https://www.youtube.com", "https://www.weibo.com", "https://www.zhihu.com", "https://www.reddit.com", "https://www.douban.com", "https://www.amazon.com", "https://www.ebay.com", "https://www.bing.com", "https://www.csdn.net", "https://www.iqiyi.com", "https://www.douyu.com", "https://www.twitch.tv", "https://www.netflix.com", "https://www.instagram.com", "https://www.twitter.com", "https://www.tiktok.com", ]; for (let i = 0; i < testSites.length; i++) { recordActivity("visit", { url: testSites[i] }); } recordActivity("tabChange", { count: 8 }); recordActivity("tabChange", { count: 15 }); recordActivity("tabChange", { count: 25 }); recordActivity("tabChange", { count: 2 }); recordActivity("bookmark", { count: 15 }); recordActivity("historyEntry", { count: 200 }); recordActivity("videoWatch", { count: 8 }); for (let i = 0; i < 6; i++) { recordActivity("visit", { url: "https://www.night-site-" + i + ".com" }); } GM_log("[成就系统] 模拟数据注入完成"); } // ============================================================ // 菜单命令 // ============================================================ function registerMenuCommands() { GM_registerMenuCommand("查看成就面板", function() { showAchievementsPanel(); }); GM_registerMenuCommand("注入模拟测试数据", function() { simulateTestData(); checkAchievements(); // 如果浮动窗存在则刷新 const listEl = document.getElementById("ach-panel-list"); if (listEl) { renderAchievementList(); } GM_notification({ title: "测试数据已注入", text: "已模拟访问多个网站,成就已更新。", timeout: 4000, }); }); GM_registerMenuCommand("重置所有数据", function() { GM_log("[成就系统] 用户请求重置所有数据"); const allKeys = GM_listValues(); let count = 0; for (let i = 0; i < allKeys.length; i++) { if (allKeys[i].startsWith(STORAGE_PREFIX)) { GM_deleteValue(allKeys[i]); count++; } } GM_log("[成就系统] 已清空 " + count + " 个存储项"); GM_notification({ title: "数据已重置", text: "所有成就进度和统计数据已被清空。\n刷新页面后可重新开始。", timeout: 4000, }); }); GM_log("[成就系统] 菜单命令注册完成"); } // ============================================================ // 成就面板页面(通过 GM_openInTab 打开) // UI风格:深色蓝灰背景 + 噪点纹理 + 左对齐布局 // ============================================================ function showAchievementsPanel() { const stats = getStats(); const extra = getExtraData(); const unlocked = getUnlocked(); const times = getUnlockTimes(); const total = ACHIEVEMENTS.length; const unlockedCount = unlocked.length; const totalPercent = Math.round((unlockedCount / total) * 100); let cardsHTML = ""; for (let i = 0; i < ACHIEVEMENTS.length; i++) { const ach = ACHIEVEMENTS[i]; const isUnlocked = unlocked.indexOf(ach.id) !== -1; const prog = ach.getProgress(stats, extra); const pct = Math.min(100, Math.round((prog.current / prog.target) * 100)); let timeHTML = ""; if (isUnlocked && times[ach.id]) { const d = new Date(times[ach.id]); timeHTML = '
解锁于 ' + d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, "0") + '-' + String(d.getDate()).padStart(2, "0") + ' ' + String(d.getHours()).padStart(2, "0") + ':' + String(d.getMinutes()).padStart(2, "0") + '
'; } const cls = isUnlocked ? "unlocked" : "locked"; const stText = isUnlocked ? "已解锁" : "未解锁"; const barHTML = isUnlocked ? '
' : '
'; const progText = isUnlocked ? "已完成" : prog.label + " " + prog.current + " / " + prog.target; cardsHTML += '
' + '
' + ach.icon + '
' + '
' + '
' + ach.name + '' + stText + '
' + '
' + ach.description + '
' + barHTML + '
' + progText + '
' + timeHTML + '
'; } // -- 面板页面 HTML(左对齐、深色蓝灰、噪点纹理) -- const html = '浏览器成就系统
' + '
' + '
浏览器成就系统
' + '
' + '' + unlockedCount + ' / ' + total + '' + '
' + '' + totalPercent + '%' + '
' + '
' + '
' + cardsHTML + '
' + '' + '
'; const dataUri = "data:text/html;charset=utf-8," + encodeURIComponent(html); GM_openInTab(dataUri, { active: true }); GM_log("[成就系统] 成就面板已打开: " + unlockedCount + "/" + total); } // ============================================================ // 浮动窗 CSS 样式 // 风格:深色蓝灰 + 噪点纹理 + 冷灰色调 // 约束:无紫色渐变、无 emoji 功能图标、无 ease-in-out // ============================================================ const PANEL_STYLES = ` #ach-panel { position: fixed; bottom: 20px; right: 20px; width: 370px; max-height: 500px; /* 深色蓝灰半透明背景 + 微噪点纹理 */ background-color: rgba(13, 18, 24, 0.96); background-image: radial-gradient(circle, rgba(107, 140, 174, 0.04) 1px, transparent 1px); background-size: 14px 14px; border: 1px solid #1a2230; border-radius: 12px; box-shadow: 0 6px 28px rgba(0, 0, 0, 0.5); z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: #98a4b2; display: flex; flex-direction: column; overflow: hidden; transition: all 0.3s cubic-bezier(0.2, 0, 0.1, 1); user-select: none; } #ach-panel.minimized { width: 48px; height: 48px; max-height: 48px; border-radius: 8px; } /* 标题栏(可拖拽) */ #ach-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: linear-gradient(135deg, #10161e, #0d1218); border-bottom: 1px solid #1a2230; cursor: move; flex-shrink: 0; } #ach-panel.minimized #ach-header { padding: 0; justify-content: center; border-bottom: none; width: 48px; height: 48px; } /* 标题文字(纯文本,无 emoji) */ #ach-title { font-size: 0.9em; font-weight: 600; color: #6b8cae; letter-spacing: 0.5px; } #ach-panel.minimized #ach-title { font-size: 1.2em; } /* 按钮容器 */ #ach-buttons { display: flex; gap: 4px; } #ach-panel.minimized #ach-buttons { display: none; } /* 按钮(Unicode 符号,无 emoji) */ .ach-btn { width: 26px; height: 26px; border: none; border-radius: 5px; background: #0f151c; color: #68788a; cursor: pointer; font-size: 13px; font-family: monospace; display: flex; align-items: center; justify-content: center; transition: background 0.2s cubic-bezier(0.2, 0, 0.1, 1), color 0.2s cubic-bezier(0.2, 0, 0.1, 1); } .ach-btn:hover { background: #1a2230; color: #98a4b2; } .ach-btn.close:hover { background: #5a2a28; color: #d4a8a6; } /* 进度摘要区 */ #ach-summary { padding: 10px 14px; border-bottom: 1px solid #0f151c; flex-shrink: 0; } #ach-panel.minimized #ach-summary { display: none; } #ach-summary-text { font-size: 0.8em; color: #68788a; margin-bottom: 5px; } #ach-summary-bar { width: 100%; height: 5px; background: #0a0f14; border-radius: 2px; overflow: hidden; } #ach-summary-fill { height: 100%; background: linear-gradient(90deg, #4a6a8a, #5a8a6a); border-radius: 2px; transition: width 0.5s cubic-bezier(0.2, 0, 0.1, 1); } /* 可滚动的成就列表 */ #ach-list { flex: 1; overflow-y: auto; padding: 6px 10px; min-height: 0; } #ach-panel.minimized #ach-list { display: none; } #ach-list::-webkit-scrollbar { width: 4px; } #ach-list::-webkit-scrollbar-track { background: transparent; } #ach-list::-webkit-scrollbar-thumb { background: #1a2230; border-radius: 2px; } /* 单个成就项 */ .ach-item { display: flex; align-items: center; gap: 10px; padding: 9px 10px; margin-bottom: 5px; background: #0a0f14; border-radius: 8px; border: 1px solid #11171f; transition: border-color 0.2s cubic-bezier(0.2, 0, 0.1, 1); } .ach-item.unlocked { border-color: #1f2a1c; background: linear-gradient(135deg, #0c100c, #0a0f14); } .ach-item-icon { font-size: 1.3em; flex-shrink: 0; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; background: #0d131a; border-radius: 7px; } .ach-item.unlocked .ach-item-icon { background: #111510; } .ach-item-info { flex: 1; min-width: 0; } .ach-item-name { font-size: 0.84em; font-weight: 600; color: #a8b4c2; margin-bottom: 2px; } .ach-item-desc { font-size: 0.7em; color: #4a5560; margin-bottom: 4px; } .ach-item-pbar { width: 100%; height: 3px; background: #0a0f14; border-radius: 1px; overflow: hidden; margin-bottom: 2px; } .ach-item-pfill { height: 100%; background: linear-gradient(90deg, #4a6a8a, #5a8a6a); border-radius: 1px; transition: width 0.4s cubic-bezier(0.2, 0, 0.1, 1); } .ach-item.unlocked .ach-item-pfill { background: linear-gradient(90deg, #7a9a5a, #8aaa6a); } .ach-item-ptext { font-size: 0.66em; color: #3a4550; } .ach-item.unlocked .ach-item-ptext { color: #7a9a5a; } .ach-item-status { font-size: 0.66em; padding: 2px 7px; border-radius: 8px; flex-shrink: 0; background: #0f151c; color: #68788a; } .ach-item.unlocked .ach-item-status { background: #1a2014; color: #7a9a5a; } /* 底部操作区 */ #ach-footer { padding: 7px 14px; border-top: 1px solid #0f151c; display: flex; justify-content: flex-end; gap: 6px; flex-shrink: 0; } #ach-panel.minimized #ach-footer { display: none; } .ach-fbtn { padding: 4px 12px; border: 1px solid #1a2230; border-radius: 5px; background: #0d131a; color: #68788a; cursor: pointer; font-size: 0.74em; font-family: inherit; transition: background 0.2s cubic-bezier(0.2, 0, 0.1, 1), color 0.2s cubic-bezier(0.2, 0, 0.1, 1); } .ach-fbtn:hover { background: #1a2230; color: #98a4b2; } `; // ============================================================ // 浮动窗 HTML 结构 // 按钮/标题使用纯文本和 Unicode 符号,无 emoji // ============================================================ function createPanelHTML() { const panel = document.createElement("div"); panel.id = "ach-panel"; panel.innerHTML = `
成就
加载中...
`; return panel; } // ============================================================ // 拖拽功能 + 点击/拖拽分离 // 修复:点击/拖拽分离 // 核心逻辑:mousedown 记录起点,mousemove 超过 5px 标记为拖拽, // mouseup 时判断:距离 < 5px → 点击展开;距离 ≥ 5px → 仅拖拽 // ============================================================ /** * 初始化拖拽与点击分离处理器 * @param {HTMLElement} panel - 浮动窗面板元素 * @param {HTMLElement} header - 可拖拽的标题栏区域 * @param {Function} onClickCallback - 点击(非拖拽)时的回调,用于展开浮动窗 */ function enableDrag(panel, header, onClickCallback) { // 修复:点击/拖拽分离 — 使用闭包内局部变量,避免全局污染 let isDragging = false; // 是否真正在拖拽(位移超过阈值) let isMouseDown = false; // 鼠标是否按下 let startX = 0, startY = 0; // mousedown 时的起始坐标 let startLeft = 0, startTop = 0; // 面板起始位置 const DRAG_THRESHOLD = 5; // 位移阈值(像素):<5px 视为点击,≥5px 视为拖拽 header.addEventListener("mousedown", function(e) { // 忽略按钮上的点击(按钮有自己的事件处理) if (e.target.tagName === "BUTTON") return; isMouseDown = true; isDragging = false; // 初始为 false,等移动超过阈值才置 true startX = e.clientX; startY = e.clientY; // 记录面板起始位置,切换为绝对定位 const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; panel.style.transition = "none"; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.left = startLeft + "px"; panel.style.top = startTop + "px"; e.preventDefault(); }); // 修复:点击/拖拽分离 — mousemove 时判断位移是否超过阈值 document.addEventListener("mousemove", function(e) { if (!isMouseDown) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const distance = Math.hypot(dx, dy); // 位移超过阈值 → 标记为拖拽 if (distance >= DRAG_THRESHOLD) { isDragging = true; } // 无论是否超过阈值都更新位置(让拖拽手感连续) if (isDragging) { panel.style.left = (startLeft + dx) + "px"; panel.style.top = (startTop + dy) + "px"; } }); // 修复:点击/拖拽分离 — mouseup 时根据位移判断是点击还是拖拽 document.addEventListener("mouseup", function(e) { if (!isMouseDown) return; isMouseDown = false; panel.style.transition = "all 0.3s cubic-bezier(0.2, 0, 0.1, 1)"; const dx = e.clientX - startX; const dy = e.clientY - startY; const distance = Math.hypot(dx, dy); if (distance < DRAG_THRESHOLD) { // 位移 < 5px → 这是点击,不是拖拽 → 触发展开回调 GM_log("[成就系统] 检测到点击(位移 " + distance.toFixed(1) + "px < 5px),触发展开"); if (typeof onClickCallback === "function") { onClickCallback(); } } else { // 位移 ≥ 5px → 这是拖拽,保存位置到存储 GM_log("[成就系统] 检测到拖拽(位移 " + distance.toFixed(1) + "px ≥ 5px),保存位置"); savePanelState(panel); } isDragging = false; }); } // ============================================================ // 浮动窗状态持久化 // 修复:浮动窗状态持久化(最小化状态 + 位置) // ============================================================ /** * 保存浮动窗状态到 GM 存储 * @param {HTMLElement} panel - 浮动窗元素 * @param {boolean} isMinimized - 是否最小化 */ function savePanelState(panel, isMinimized) { const rect = panel.getBoundingClientRect(); const state = { minimized: isMinimized, left: rect.left, top: rect.top, }; GM_setValue(KEYS.PANEL_STATE, state); GM_log("[成就系统] 浮动窗状态已保存: " + JSON.stringify(state)); } /** * 从 GM 存储恢复浮动窗状态 * @param {HTMLElement} panel - 浮动窗元素 * @returns {Object|null} 状态对象 { minimized, left, top } */ function restorePanelState(panel) { const state = GM_getValue(KEYS.PANEL_STATE); if (!state) return null; // 恢复位置 if (typeof state.left === "number" && typeof state.top === "number") { panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.left = state.left + "px"; panel.style.top = state.top + "px"; } GM_log("[成就系统] 浮动窗状态已恢复: " + JSON.stringify(state)); return state; } // ============================================================ // 浮动窗渲染 // ============================================================ function renderAchievementList() { const listEl = document.getElementById("ach-list"); const summaryText = document.getElementById("ach-summary-text"); const summaryFill = document.getElementById("ach-summary-fill"); if (!listEl) return; const stats = getStats(); const extra = getExtraData(); const unlocked = getUnlocked(); const total = ACHIEVEMENTS.length; const unlockedCount = unlocked.length; const totalPercent = Math.round((unlockedCount / total) * 100); summaryText.textContent = "已解锁 " + unlockedCount + " / " + total + " (" + totalPercent + "%)"; summaryFill.style.width = totalPercent + "%"; let html = ""; for (let i = 0; i < ACHIEVEMENTS.length; i++) { const ach = ACHIEVEMENTS[i]; const isUnlocked = unlocked.indexOf(ach.id) !== -1; const prog = ach.getProgress(stats, extra); const pct = Math.min(100, Math.round((prog.current / prog.target) * 100)); const cls = isUnlocked ? "unlocked" : ""; const progText = isUnlocked ? "已完成" : prog.label + " " + prog.current + " / " + prog.target; const statusText = isUnlocked ? "已解锁" : "未解锁"; html += '
' + '
' + ach.icon + '
' + '
' + '
' + ach.name + '
' + '
' + ach.description + '
' + '
' + '
' + progText + '
' + '
' + '
' + statusText + '
' + '
'; } listEl.innerHTML = html; } // ============================================================ // 浮动窗按钮事件 // ============================================================ function setupPanelButtons(panel) { const btnMinimize = document.getElementById("ach-btn-minimize"); const btnClose = document.getElementById("ach-btn-close"); const btnRefresh = document.getElementById("ach-btn-refresh"); const btnTestVisit = document.getElementById("ach-btn-test-visit"); const btnTestTab = document.getElementById("ach-btn-test-tab"); // 修复:浮动窗状态持久化 — isMinimized 状态由调用方传入初始值 // 这里不再声明局部变量,而是通过 panel.dataset 管理 // panel.dataset.minimized = "true" / "false" /** * 展开浮动窗(从最小化状态恢复) * 修复:点击/拖拽分离 — 提供给 enableDrag 的回调 */ function expandFloatWindow() { if (panel.dataset.minimized === "true") { panel.dataset.minimized = "false"; panel.classList.remove("minimized"); btnMinimize.textContent = "−"; btnMinimize.title = "最小化"; renderAchievementList(); // 修复:浮动窗状态持久化 — 保存展开状态 savePanelState(panel, false); GM_log("[成就系统] 浮动窗已展开"); } } // 最小化按钮事件 btnMinimize.addEventListener("click", function(e) { e.stopPropagation(); const willMinimize = panel.dataset.minimized !== "true"; if (willMinimize) { panel.dataset.minimized = "true"; panel.classList.add("minimized"); btnMinimize.textContent = "+"; btnMinimize.title = "展开"; // 修复:浮动窗状态持久化 — 保存最小化状态 savePanelState(panel, true); } else { // 通过按钮展开 expandFloatWindow(); } }); btnClose.addEventListener("click", function(e) { e.stopPropagation(); panel.style.display = "none"; // 10 秒后自动恢复 setTimeout(function() { panel.style.display = "flex"; renderAchievementList(); }, 10000); }); btnRefresh.addEventListener("click", function(e) { e.stopPropagation(); renderAchievementList(); GM_log("[成就系统] 手动刷新成就列表"); }); btnTestVisit.addEventListener("click", function(e) { e.stopPropagation(); const testUrls = [ "https://www.google.com", "https://www.github.com", "https://www.bilibili.com", "https://www.zhihu.com", "https://www.taobao.com", "https://www.youtube.com", ]; const url = testUrls[Math.floor(Math.random() * testUrls.length)]; recordActivity("visit", { url: url }); checkAchievements(); renderAchievementList(); GM_log("[成就系统] 模拟访问: " + url); }); btnTestTab.addEventListener("click", function(e) { e.stopPropagation(); const counts = [3, 8, 15, 22, 1, 5, 30]; const count = counts[Math.floor(Math.random() * counts.length)]; recordActivity("tabChange", { count: count }); checkAchievements(); renderAchievementList(); GM_log("[成就系统] 模拟标签页: " + count); }); // 修复:点击/拖拽分离 — 返回展开函数,供 enableDrag 回调使用 return { expandFloatWindow: expandFloatWindow }; } // ============================================================ // 页面事件监听 // ============================================================ function setupPageListeners() { // 监听