// ==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.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() {
// 监听