// ==UserScript== // @name 网站快照存储与恢复助手 // @namespace https://github.com/moyefu/BrowserScript/WebSnapshotManager // @version 1.2.4 // @description 针对指定网站实现快照(Cookie、LocalStorage、SessionStorage)的一键存储、命名、加密备份、二维码生成/扫码与一键恢复 // @author MOYEFU // @icon https://pic1.imgdb.cn/i/034D4F8VwYLLoU73kkQs3l.gif // @homepage https://scriptcat.org/zh-CN/script-show-page/7633 // @supportURL https://scriptcat.org/zh-CN/script-show-page/7633/issue // @license MIT // @match http*://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_cookie // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @connect api.github.com // @require https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js // @require https://cdn.jsdelivr.net/npm/zxing-wasm@1.2.14/dist/iife/reader/index.js // @require https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js // @tag MOYEFU // @run-at document-idle // @noframes // ==/UserScript== /* ==UserConfig== Config: filter_mode: title: 域名过滤模式 description: 白名单模式:仅对列表中的网站生效;黑名单模式:对除列表中之外的所有网站生效 type: select values: - [whitelist, 白名单模式 (仅在列表中生效)] - [blacklist, 黑名单模式 (列表中的不生效)] default: whitelist host_list: title: 域名列表 (每行一条) description: 每行一条,支持通配符 * ,例:https://*.example.org* 或 *.baidu.com;白名单模式下仅列表内网站显示,黑名单模式下列表内网站不显示 type: textarea default: "" enable_encryption: title: 本地数据加密 description: 启用 AES-GCM 256 位本地数据加密存储 type: checkbox default: false auto_reload_after_restore: title: 恢复后直接刷新/跳转 description: 恢复快照成功后直接刷新或跳转至来源页面(不再弹窗确认) type: checkbox default: false enable_gist_sync: title: GitHub Gist 云同步 description: 启用通过 GitHub Private Gist 实现多设备跨端快照及规则自动同步 type: checkbox default: false gist_token: title: GitHub Token (PAT) description: 具有 gist 权限的 GitHub Personal Access Token (例如 ghp_xxxx) type: text default: "" gist_id: title: Gist ID (可选) description: 绑定的 Gist ID。留空时首次同步将自动创建专属私有 Gist 并自动填充;其他设备填入相同 ID 即可绑定 type: text default: "" auto_sync_on_change: title: 快照变更自动同步 description: 保存、重命名、删除或导入快照时自动触发云端同步 type: checkbox default: true ==/UserConfig== */ // 全局暴露的 UI 实例,供菜单命令与外部调度使用 let LSM_UI = null; // 获取域名过滤模式,自动提取前面英文关键词(whitelist / blacklist) function getFilterMode() { let val = GM_getValue("Config.filter_mode", "whitelist"); if (Array.isArray(val)) { val = val[0]; } const match = String(val || "").match(/[a-zA-Z]+/); const mode = match ? match[0].toLowerCase() : "whitelist"; return mode === "blacklist" ? "blacklist" : "whitelist"; } (async () => { "use strict"; // ========================================================================= // 0. 用户配置:filter_mode(白名单/黑名单)+ host_list(域名列表,支持 * 通配符) // 默认白名单模式:仅匹配到列表中的网站才运行脚本 // 黑名单模式:匹配到列表中的网站不运行,其他网站均运行 // ========================================================================= function getHostRules() { let raw = GM_getValue("Config.host_list", null); if (raw === null || raw === undefined) { raw = GM_getValue("Config.show_host", ""); } return String(raw || "") .split("\n") .map((s) => s.trim()) .filter(Boolean); } function isHostMatched() { const lines = getHostRules(); if (!lines.length) return false; const candidates = [ location.href, location.origin, location.protocol + "//" + location.host, location.host, location.hostname ]; return lines.some((line) => { const re = new RegExp(line ? ("^" + line.replace(/[.+?^${}()|[\\]\\]/g, "\\$&").replace(/\\*/g, ".*") + "$") : "^$", "i"); return candidates.some((c) => re.test(c)); }); } function hostBlocked() { try { const mode = getFilterMode(); const matched = isHostMatched(); const lines = getHostRules(); if (mode === "blacklist") return matched; if (!lines.length) return true; return !matched; } catch (e) { return true; } } // ========================================================================= // 菜单命令注册(Tampermonkey / ScriptCat 菜单) // 1. 🔑 快照管理助手 // 2. 🛡️ 过滤模式切换(白名单 / 黑名单) // 3. 📝 编辑域名名单列表 // 4. 🔒 本地数据加密状态切换 // 5. 🔄 恢复后刷新跳转状态切换 // ========================================================================= function registerAllMenuCommands() { // 1. 主入口 GM_registerMenuCommand("🔑 快照管理助手", () => { if (hostBlocked()) { showBlockedDialog(); } else { showMainDialog(); } }); // 2. 切换黑/白名单模式 const currentMode = getFilterMode(); const modeText = currentMode === "blacklist" ? "🛡️ 当前为【黑名单】模式 (点击切换为白名单)" : "🛡️ 当前为【白名单】模式 (点击切换为黑名单)"; GM_registerMenuCommand(modeText, () => { showSwitchFilterModeDialog(); }); // 3. 编辑黑/白名单列表 GM_registerMenuCommand("📝 编辑域名规则列表 (黑/白名单)", () => { showEditHostListDialog(); }); // 4. 本地数据加密状态 const isEnc = GM_getValue("Config.enable_encryption", true); const encText = isEnc ? "🔒 本地数据【已加密】 (点击切换/关闭)" : "🔓 本地数据【未加密】 (点击切换/开启)"; GM_registerMenuCommand(encText, () => { showToggleEncryptionDialog(); }); // 5. 恢复后刷新状态 const isAutoReload = GM_getValue("Config.auto_reload_after_restore", false); const reloadText = isAutoReload ? "🔄 恢复后【自动刷新/跳转】 (点击切换为不刷新)" : "⏸️ 恢复后【不默认刷新】 (点击切换为自动刷新)"; GM_registerMenuCommand(reloadText, () => { showToggleAutoReloadDialog(); }); // 6. 立即同步到 Gist GM_registerMenuCommand("☁️ 立即同步快照到 Gist 云端", () => { if (!GistSyncManager.isEnabled() || !GistSyncManager.getToken()) { showGistSyncDialog(); } else { GistSyncManager.sync({ silent: false }); } }); // 7. Gist 云同步设置 GM_registerMenuCommand("⚙️ GitHub Gist 云同步设置", () => { showGistSyncDialog(); }); } registerAllMenuCommands(); if (hostBlocked()) return; initApp(); })(); // 永久开启当前站点(白名单模式下加入列表,黑名单模式下移出列表) function enableCurrentHost() { try { const mode = getFilterMode(); let raw = GM_getValue("Config.host_list", null); if (raw === null || raw === undefined) { raw = GM_getValue("Config.show_host", ""); } let lines = String(raw || "") .split("\n") .map((s) => s.trim()) .filter(Boolean); const candidates = [ location.href, location.origin, location.protocol + "//" + location.host, location.host, location.hostname ]; if (mode === "blacklist") { lines = lines.filter((line) => { const re = new RegExp( "^" + line.replace(/[.+?^${}()|[\\]\\]/g, "\\$&").replace(/\\*/g, ".*") + "$", "i" ); return !candidates.some((c) => re.test(c)); }); } else { const entry = location.origin; if (!lines.some((l) => l === entry)) { lines.push(entry); } } GM_setValue("Config.host_list", lines.join("\n")); } catch (e) { console.error("[LSM] 写入配置失败:", e); } } const addHostToShowList = enableCurrentHost; // 永久关闭当前站点(白名单模式下移出列表,黑名单模式下加入列表) function disableCurrentHost() { try { const mode = getFilterMode(); let raw = GM_getValue("Config.host_list", null); if (raw === null || raw === undefined) { raw = GM_getValue("Config.show_host", ""); } let lines = String(raw || "") .split("\n") .map((s) => s.trim()) .filter(Boolean); const candidates = [ location.href, location.origin, location.protocol + "//" + location.host, location.host, location.hostname ]; if (mode === "blacklist") { const entry = location.origin; if (!lines.some((l) => l === entry)) { lines.push(entry); } } else { lines = lines.filter((line) => { const re = new RegExp( "^" + line.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$", "i" ); return !candidates.some((c) => re.test(c)); }); } GM_setValue("Config.host_list", lines.join("\n")); } catch (e) { console.error("[LSM] 移除配置失败:", e); } } const removeHostFromShowList = disableCurrentHost; // --------------------------------------------------------------------------- // 移动端/全平台弹窗滚动穿透防护助手 // --------------------------------------------------------------------------- function bindScrollLock(mask, scrollableSelector) { let startY = 0; // 1. PC 端鼠标滚轮事件精确拦截 mask.addEventListener( "wheel", (e) => { e.stopPropagation(); const scrollable = scrollableSelector && e.target.closest ? e.target.closest(scrollableSelector) : null; if (!scrollable) { e.preventDefault(); return; } const { scrollTop, scrollHeight, clientHeight } = scrollable; const deltaY = e.deltaY; if ((deltaY < 0 && scrollTop <= 0) || (deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1)) { e.preventDefault(); } }, { passive: false } ); // 2. 移动端触摸滑动事件精确拦截 mask.addEventListener( "touchstart", (e) => { if (e.touches.length === 1) { startY = e.touches[0].clientY; } }, { passive: true } ); mask.addEventListener( "touchmove", (e) => { if (e.touches.length !== 1) return; const scrollable = scrollableSelector && e.target.closest ? e.target.closest(scrollableSelector) : null; if (!scrollable) { if (e.cancelable) e.preventDefault(); e.stopPropagation(); return; } const currentY = e.touches[0].clientY; const deltaY = currentY - startY; // >0 为下拉,<0 为上滑 const { scrollTop, scrollHeight, clientHeight } = scrollable; if (scrollHeight <= clientHeight) { // 容器内无需滚动时直接阻止穿透 if (e.cancelable) e.preventDefault(); e.stopPropagation(); return; } if (deltaY > 0 && scrollTop <= 0) { // 顶部继续下拉,拦截 if (e.cancelable) e.preventDefault(); e.stopPropagation(); } else if (deltaY < 0 && scrollTop + clientHeight >= scrollHeight - 1) { // 底部继续上滑,拦截 if (e.cancelable) e.preventDefault(); e.stopPropagation(); } else { // 容器内部正常滚动,允许并阻止冒泡 e.stopPropagation(); } }, { passive: false } ); } function ensureHostAnimationStyle() { if (!document.getElementById("lsm-host-animations")) { const style = document.createElement("style"); style.id = "lsm-host-animations"; style.textContent = "@keyframes lsmFadeIn{from{opacity:0;transform:scale(0.96)}to{opacity:1;transform:scale(1)}}"; (document.head || document.documentElement).appendChild(style); } } function showBlockedDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, null); const box = document.createElement("div"); box.style.cssText = "width:360px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "🔑 快照管理助手未激活"; title.style.cssText = "margin-bottom:10px;display:flex;align-items:center;gap:6px;"; const mode = getFilterMode(); const desc = document.createElement("div"); desc.textContent = mode === "blacklist" ? "当前网站已被加入「黑名单」列表中,快照助手未在此站点激活。你可以选择:" : "当前网站不在「白名单」列表中,快照助手未在此站点激活。你可以选择:"; desc.style.cssText = "font-size:13px;color:#64748b;line-height:1.6;margin-bottom:18px;"; const tempBtn = document.createElement("button"); tempBtn.textContent = "临时显示(仅本次生效)"; tempBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-bottom:10px;border:none;border-radius:10px;" + "background:linear-gradient(135deg,#3b82f6,#2563eb);color:#ffffff;font-size:13px;cursor:pointer;font-weight:600;box-shadow:0 2px 8px rgba(37,99,235,0.25);"; const permBtn = document.createElement("button"); permBtn.textContent = mode === "blacklist" ? "永久开启(移出黑名单)" : "永久开启(加入白名单)"; permBtn.style.cssText = "display:block;width:100%;padding:10px 0;border:1px solid #e2e8f0;border-radius:10px;" + "background:#f8fafc;color:#1e293b;font-size:13px;cursor:pointer;font-weight:600;"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-top:8px;border:none;background:none;" + "color:#94a3b8;font-size:12px;cursor:pointer;"; const close = () => mask.remove(); tempBtn.addEventListener("click", () => { close(); initApp(); }); permBtn.addEventListener("click", () => { close(); addHostToShowList(); initApp(); }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); box.append(title, desc, tempBtn, permBtn, cancelBtn); mask.appendChild(box); document.documentElement.appendChild(mask); } // 脚本正常运行时的菜单弹窗:打开管理窗 / 临时关闭 / 永久关闭 function showMainDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, null); const box = document.createElement("div"); box.style.cssText = "width:360px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "🔑 快照管理助手"; title.style.cssText = "margin-bottom:10px;display:flex;align-items:center;gap:6px;"; const mode = getFilterMode(); const desc = document.createElement("div"); desc.textContent = mode === "blacklist" ? "当前网站处于黑名单排除范围之外,功能就绪。你可以选择:" : "当前网站已在白名单允许列表中,功能就绪。你可以选择:"; desc.style.cssText = "font-size:13px;color:#64748b;line-height:1.6;margin-bottom:18px;"; const openBtn = document.createElement("button"); openBtn.textContent = "打开管理窗口"; openBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-bottom:10px;border:none;border-radius:10px;" + "background:linear-gradient(135deg,#3b82f6,#2563eb);color:#ffffff;font-size:13px;cursor:pointer;font-weight:600;box-shadow:0 2px 8px rgba(37,99,235,0.25);"; const tmpBtn = document.createElement("button"); tmpBtn.textContent = "临时隐藏悬浮球(刷新后恢复)"; tmpBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-bottom:10px;border:1px solid #e2e8f0;border-radius:10px;" + "background:#f8fafc;color:#334155;font-size:13px;cursor:pointer;font-weight:500;"; const permBtn = document.createElement("button"); permBtn.textContent = mode === "blacklist" ? "永久关闭(加入黑名单)" : "永久关闭(从白名单移除)"; permBtn.style.cssText = "display:block;width:100%;padding:10px 0;border:1px solid #fecdd3;border-radius:10px;" + "background:#fff1f2;color:#e11d48;font-size:13px;cursor:pointer;font-weight:500;"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-top:8px;border:none;background:none;" + "color:#94a3b8;font-size:12px;cursor:pointer;"; const close = () => mask.remove(); const hideAll = () => { if (LSM_UI) { if (LSM_UI.ball) LSM_UI.ball.style.display = "none"; if (LSM_UI.win) { LSM_UI.win.style.display = "none"; LSM_UI.win.classList.add("hidden"); } } }; openBtn.addEventListener("click", async () => { close(); if (!LSM_UI) { await initApp(); } if (LSM_UI && typeof LSM_UI.openWindow === "function") { LSM_UI.openWindow(); } }); tmpBtn.addEventListener("click", () => { close(); hideAll(); }); permBtn.addEventListener("click", () => { close(); removeHostFromShowList(); hideAll(); }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); box.append(title, desc, openBtn, tmpBtn, permBtn, cancelBtn); mask.appendChild(box); document.documentElement.appendChild(mask); } // --------------------------------------------------------------------------- // 菜单命令弹窗:1. 切换黑/白名单模式 // --------------------------------------------------------------------------- function showSwitchFilterModeDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); const currentMode = getFilterMode(); const targetMode = currentMode === "blacklist" ? "whitelist" : "blacklist"; const targetModeLabel = targetMode === "blacklist" ? "黑名单模式" : "白名单模式"; const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, null); const box = document.createElement("div"); box.style.cssText = "width:380px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "🛡️ 切换域名过滤模式"; title.style.cssText = "margin-bottom:10px;display:flex;align-items:center;gap:6px;"; const desc = document.createElement("div"); desc.innerHTML = `当前模式:${currentMode === "blacklist" ? "黑名单模式 (列表中的网站不生效)" : "白名单模式 (仅在列表中生效)"}
` + `点击下方按钮将切换为:${targetModeLabel}
` + `切换后将立即生效并刷新当前页面。`; desc.style.cssText = "font-size:13px;color:#64748b;line-height:1.6;margin-bottom:18px;"; const confirmBtn = document.createElement("button"); confirmBtn.textContent = `确认切换为「${targetModeLabel}」`; confirmBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-bottom:10px;border:none;border-radius:10px;" + "background:linear-gradient(135deg,#3b82f6,#2563eb);color:#ffffff;font-size:13px;cursor:pointer;font-weight:600;box-shadow:0 2px 8px rgba(37,99,235,0.25);"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-top:4px;border:none;background:none;" + "color:#94a3b8;font-size:12px;cursor:pointer;"; const close = () => mask.remove(); confirmBtn.addEventListener("click", () => { GM_setValue("Config.filter_mode", targetMode); close(); location.reload(); }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); box.append(title, desc, confirmBtn, cancelBtn); mask.appendChild(box); document.documentElement.appendChild(mask); } // --------------------------------------------------------------------------- // 菜单命令弹窗:2. 编辑黑/白名单域名列表 // --------------------------------------------------------------------------- function showEditHostListDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); let raw = GM_getValue("Config.host_list", null); if (raw === null || raw === undefined) { raw = GM_getValue("Config.show_host", ""); } const mode = getFilterMode(); const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, "textarea"); const box = document.createElement("div"); box.style.cssText = "width:460px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "📝 编辑域名规则列表"; title.style.cssText = "margin-bottom:8px;display:flex;align-items:center;gap:6px;"; const desc = document.createElement("div"); desc.innerHTML = `当前生效模式:${mode === "blacklist" ? "黑名单模式 (列表中不生效)" : "白名单模式 (仅在列表中生效)"}
` + `每行一条规则,支持通配符 *(例:https://*.example.com**.baidu.com):`; desc.style.cssText = "font-size:12.5px;color:#64748b;line-height:1.5;margin-bottom:12px;"; const textarea = document.createElement("textarea"); textarea.value = String(raw || ""); textarea.placeholder = "*.google.com\nhttps://github.com/*\n*.example.org"; textarea.style.cssText = "width:100%;height:160px;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;" + "padding:10px 12px;font-size:13px;line-height:1.5;font-family:Consolas,Monaco,monospace;color:#1e293b;resize:vertical;outline:none;" + "background:#f8fafc;transition:border-color .15s,box-shadow .15s;margin-bottom:16px;"; textarea.addEventListener("focus", () => { textarea.style.borderColor = "#3b82f6"; textarea.style.boxShadow = "0 0 0 3px rgba(59,130,246,0.15)"; textarea.style.background = "#ffffff"; }); textarea.addEventListener("blur", () => { textarea.style.borderColor = "#cbd5e1"; textarea.style.boxShadow = "none"; textarea.style.background = "#f8fafc"; }); const btnRow = document.createElement("div"); btnRow.style.cssText = "display:flex;gap:10px;justify-content:flex-end;align-items:center;"; const addCurrBtn = document.createElement("button"); addCurrBtn.textContent = "+ 添加当前网站"; addCurrBtn.style.cssText = "padding:8px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#f1f5f9;color:#334155;font-size:12px;cursor:pointer;font-weight:500;"; addCurrBtn.addEventListener("click", () => { const origin = location.origin; const lines = textarea.value.split("\n").map((s) => s.trim()).filter(Boolean); if (!lines.includes(origin)) { lines.push(origin); textarea.value = lines.join("\n"); } }); const saveBtn = document.createElement("button"); saveBtn.textContent = "保存并应用"; saveBtn.style.cssText = "padding:8px 18px;border:none;border-radius:8px;background:linear-gradient(135deg,#3b82f6,#2563eb);color:#ffffff;font-size:12.5px;cursor:pointer;font-weight:600;box-shadow:0 2px 8px rgba(37,99,235,0.25);"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "padding:8px 14px;border:none;background:none;color:#94a3b8;font-size:12px;cursor:pointer;"; const close = () => mask.remove(); saveBtn.addEventListener("click", () => { const formatted = textarea.value .split("\n") .map((s) => s.trim()) .filter(Boolean) .join("\n"); GM_setValue("Config.host_list", formatted); close(); location.reload(); }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); btnRow.append(addCurrBtn, cancelBtn, saveBtn); box.append(title, desc, textarea, btnRow); mask.appendChild(box); document.documentElement.appendChild(mask); } // --------------------------------------------------------------------------- // 菜单命令弹窗:3. 本地数据加密切换 // --------------------------------------------------------------------------- function showToggleEncryptionDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); const isEnc = GM_getValue("Config.enable_encryption", true); const targetEnc = !isEnc; const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, null); const box = document.createElement("div"); box.style.cssText = "width:380px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "🔒 本地数据加密设置"; title.style.cssText = "margin-bottom:10px;display:flex;align-items:center;gap:6px;"; const desc = document.createElement("div"); desc.innerHTML = `当前状态:${isEnc ? "已开启 AES-GCM 256 位加密" : "未开启(明文存储)"}
` + `点击确认将切换为:${targetEnc ? "开启本地数据加密" : "关闭本地数据加密"}
` + `(新保存的快照将按新设置执行,已保存的旧快照依然支持正常读取)`; desc.style.cssText = "font-size:13px;color:#64748b;line-height:1.6;margin-bottom:18px;"; const confirmBtn = document.createElement("button"); confirmBtn.textContent = targetEnc ? "确认开启加密" : "确认关闭加密"; confirmBtn.style.cssText = `display:block;width:100%;padding:10px 0;margin-bottom:10px;border:none;border-radius:10px;` + `background:${targetEnc ? "linear-gradient(135deg,#3b82f6,#2563eb)" : "linear-gradient(135deg,#e11d48,#be123c)"};color:#ffffff;font-size:13px;cursor:pointer;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,0.15);`; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-top:4px;border:none;background:none;" + "color:#94a3b8;font-size:12px;cursor:pointer;"; const close = () => mask.remove(); confirmBtn.addEventListener("click", () => { GM_setValue("Config.enable_encryption", targetEnc); close(); location.reload(); }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); box.append(title, desc, confirmBtn, cancelBtn); mask.appendChild(box); document.documentElement.appendChild(mask); } // --------------------------------------------------------------------------- // 菜单命令弹窗:4. 恢复后刷新/跳转状态切换 // --------------------------------------------------------------------------- function showToggleAutoReloadDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); const isAutoReload = GM_getValue("Config.auto_reload_after_restore", false); const targetState = !isAutoReload; const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, null); const box = document.createElement("div"); box.style.cssText = "width:380px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "🔄 恢复后自动刷新设置"; title.style.cssText = "margin-bottom:10px;display:flex;align-items:center;gap:6px;"; const desc = document.createElement("div"); desc.innerHTML = `当前状态:${isAutoReload ? "已开启自动刷新/跳转(无需二次弹窗确认)" : "不默认刷新(恢复后弹窗提示是否刷新)"}
` + `点击确认将切换为:${targetState ? "恢复后直接自动刷新/跳转" : "恢复后二次弹窗确认刷新"}。`; desc.style.cssText = "font-size:13px;color:#64748b;line-height:1.6;margin-bottom:18px;"; const confirmBtn = document.createElement("button"); confirmBtn.textContent = targetState ? "确认切换为「自动刷新/跳转」" : "确认切换为「不默认刷新」"; confirmBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-bottom:10px;border:none;border-radius:10px;" + "background:linear-gradient(135deg,#3b82f6,#2563eb);color:#ffffff;font-size:13px;cursor:pointer;font-weight:600;box-shadow:0 2px 8px rgba(37,99,235,0.25);"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "display:block;width:100%;padding:10px 0;margin-top:4px;border:none;background:none;" + "color:#94a3b8;font-size:12px;cursor:pointer;"; const close = () => mask.remove(); confirmBtn.addEventListener("click", () => { GM_setValue("Config.auto_reload_after_restore", targetState); close(); location.reload(); }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); box.append(title, desc, confirmBtn, cancelBtn); mask.appendChild(box); document.documentElement.appendChild(mask); } // --------------------------------------------------------------------------- // 全局通用 Toast 提示 // --------------------------------------------------------------------------- function showToastGlobal(msg, type = "info") { if (LSM_UI && typeof LSM_UI.showToast === "function") { LSM_UI.showToast(msg, type); return; } let t = document.getElementById("lsm-global-toast"); if (!t) { t = document.createElement("div"); t.id = "lsm-global-toast"; t.style.cssText = "position:fixed;top:24px;left:50%;transform:translateX(-50%);background:#0f172a;color:#ffffff;" + "padding:9px 18px;border-radius:10px;font-size:12.5px;font-weight:500;box-shadow:0 10px 25px -5px rgba(15,23,42,0.3);" + "z-index:2147483647;transition:all 0.25s cubic-bezier(0.16,1,0.3,1);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; document.documentElement.appendChild(t); } if (type === "success") t.style.background = "#059669"; else if (type === "error") t.style.background = "#dc2626"; else t.style.background = "#0f172a"; t.textContent = msg; t.style.display = "block"; t.style.opacity = "1"; clearTimeout(t._timer); t._timer = setTimeout(() => { t.style.opacity = "0"; setTimeout(() => { t.style.display = "none"; }, 250); }, 3000); } // ========================================================================= // GitHub Gist 云同步引擎 (跨设备全量智能双向同步) // ========================================================================= const GistSyncManager = { syncDebounceTimer: null, isSyncing: false, lastSyncTime: GM_getValue("LSM_LAST_GIST_SYNC_TIME", 0), isEnabled() { return !!GM_getValue("Config.enable_gist_sync", false); }, getToken() { return String(GM_getValue("Config.gist_token", "") || "").trim(); }, getGistId() { return String(GM_getValue("Config.gist_id", "") || "").trim(); }, isAutoSyncOnChange() { return !!GM_getValue("Config.auto_sync_on_change", true); }, // 防抖触发自动同步 triggerAutoSync(delay = 2000) { if (!this.isEnabled() || !this.getToken() || !this.isAutoSyncOnChange()) return; if (this.syncDebounceTimer) clearTimeout(this.syncDebounceTimer); this.syncDebounceTimer = setTimeout(() => { this.sync({ silent: true }).catch((err) => { console.warn("[LSM GistSync] 自动同步异常:", err); }); }, delay); }, // GM_xmlhttpRequest 异步 Promise 封装 request(options) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("当前运行环境未授予 GM_xmlhttpRequest 权限,请在扩展设置中检查")); return; } GM_xmlhttpRequest({ timeout: 20000, ...options, headers: { "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", ...(options.headers || {}) }, onload: (res) => { if (res.status >= 200 && res.status < 300) { try { const data = res.responseText ? JSON.parse(res.responseText) : null; resolve(data); } catch (e) { resolve(res.responseText); } } else { let errMsg = `GitHub API 请求失败 (${res.status})`; try { const errObj = JSON.parse(res.responseText); if (errObj && errObj.message) errMsg += `: ${errObj.message}`; } catch (e) {} reject(new Error(errMsg)); } }, onerror: (err) => reject(new Error("网络连接失败,请检查网络或 GitHub 代理设置")), ontimeout: () => reject(new Error("请求 GitHub API 超时,请检查网络")) }); }); }, // 获取所有本地快照全量数据 getLocalFullData() { let allKeys = []; try { if (typeof GM_listValues === "function") { allKeys = GM_listValues() || []; } } catch (e) {} if (!allKeys || !allKeys.length) { allKeys = [`SESSION_DATA_${location.hostname}`]; } const snapshots = {}; for (const key of allKeys) { if (typeof key === "string" && key.startsWith("SESSION_DATA_")) { const domain = key.replace("SESSION_DATA_", ""); const records = GM_getValue(key, []); if (Array.isArray(records) && records.length > 0) { snapshots[domain] = records; } } } let hostListRaw = GM_getValue("Config.host_list", null); if (hostListRaw === null || hostListRaw === undefined) { hostListRaw = GM_getValue("Config.show_host", ""); } return { version: "1.2.1", updatedAt: Date.now(), config: { filter_mode: GM_getValue("Config.filter_mode", "whitelist"), host_list: String(hostListRaw || ""), enable_encryption: GM_getValue("Config.enable_encryption", false), auto_reload_after_restore: GM_getValue("Config.auto_reload_after_restore", false) }, snapshots: snapshots }; }, // 创建私有 Gist async createGist(token, initialData) { const fileName = "web_snapshots_backup.json"; const body = { description: "网站快照存储与恢复助手 - 跨设备全量云备份 (Private)", public: false, files: { [fileName]: { content: JSON.stringify(initialData, null, 2) } } }; const res = await this.request({ method: "POST", url: "https://api.github.com/gists", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, data: JSON.stringify(body) }); if (!res || !res.id) { throw new Error("创建 Gist 失败,未能获取新 Gist ID"); } return res.id; }, // 获取云端 Gist 数据 async fetchGist(token, gistId) { const res = await this.request({ method: "GET", url: `https://api.github.com/gists/${gistId}`, headers: { "Authorization": `Bearer ${token}` } }); if (!res || !res.files) { throw new Error("未能获取 Gist 数据或 Gist 格式不正确"); } const fileKey = Object.keys(res.files).find((k) => k.endsWith(".json")) || Object.keys(res.files)[0]; const fileObj = res.files[fileKey]; if (!fileObj) throw new Error("Gist 中未找到快照备份文件"); let contentStr = fileObj.content; if (fileObj.truncated && fileObj.raw_url) { contentStr = await this.request({ method: "GET", url: fileObj.raw_url, headers: { "Authorization": `Bearer ${token}` } }); if (typeof contentStr === "object") return contentStr; } if (!contentStr) return null; return typeof contentStr === "string" ? JSON.parse(contentStr) : contentStr; }, // 更新云端 Gist async updateGist(token, gistId, data) { const fileName = "web_snapshots_backup.json"; const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); const timeStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const body = { description: `网站快照存储与恢复助手 - 跨设备云备份 (最后同步: ${timeStr})`, files: { [fileName]: { content: JSON.stringify(data, null, 2) } } }; await this.request({ method: "PATCH", url: `https://api.github.com/gists/${gistId}`, headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, data: JSON.stringify(body) }); }, // 自动从用户 GitHub 账号检索已有的备份 Gist async findExistingGist(token) { try { const gists = await this.request({ method: "GET", url: "https://api.github.com/gists?per_page=100", headers: { "Authorization": `Bearer ${token}` } }); if (Array.isArray(gists)) { const found = gists.find((g) => { if (g.files && g.files["web_snapshots_backup.json"]) return true; if (g.description && g.description.includes("网站快照存储与恢复助手")) return true; return false; }); if (found && found.id) { return found.id; } } } catch (e) { console.warn("[LSM GistSync] 自动查找已有 Gist 失败:", e); } return null; }, // 核心智能双向合并同步 async sync(options = {}) { if (this.isSyncing) { if (!options.silent) showToastGlobal("正在同步中,请稍候...", "info"); return; } const token = this.getToken(); if (!token) { if (!options.silent) showToastGlobal("请先在 Gist 设置中填写 GitHub Token", "error"); return; } this.isSyncing = true; this.updateUIStatus("syncing"); try { let gistId = this.getGistId(); const localData = this.getLocalFullData(); // 1. 若没有 Gist ID,先尝试在 GitHub 账号中自动检索是否已有历史备份 if (!gistId) { if (!options.silent) showToastGlobal("正在云端查找已有备份...", "info"); const existingId = await this.findExistingGist(token); if (existingId) { gistId = existingId; GM_setValue("Config.gist_id", gistId); if (!options.silent) showToastGlobal(`已自动找到并绑定已有云备份 (ID: ${gistId.slice(0, 8)}...)`, "success"); } else { // 账号下没有备份则创建新的私有 Gist if (!options.silent) showToastGlobal("未找到已有备份,正在创建私有 Gist...", "info"); gistId = await this.createGist(token, localData); GM_setValue("Config.gist_id", gistId); this.lastSyncTime = Date.now(); GM_setValue("LSM_LAST_GIST_SYNC_TIME", this.lastSyncTime); this.updateUIStatus("success"); if (!options.silent) showToastGlobal(`Gist 云备份已创建!ID: ${gistId.slice(0, 8)}...`, "success"); return { success: true, gistId, created: true }; } } // 2. 拉取云端数据 let remoteData = null; try { remoteData = await this.fetchGist(token, gistId); } catch (err) { if (String(err.message).includes("404")) { // 尝试重新检索或创建 const reFound = await this.findExistingGist(token); if (reFound && reFound !== gistId) { gistId = reFound; GM_setValue("Config.gist_id", gistId); remoteData = await this.fetchGist(token, gistId); } else { gistId = await this.createGist(token, localData); GM_setValue("Config.gist_id", gistId); this.lastSyncTime = Date.now(); GM_setValue("LSM_LAST_GIST_SYNC_TIME", this.lastSyncTime); this.updateUIStatus("success"); if (!options.silent) showToastGlobal("原 Gist 未找到,已自动重新创建", "success"); return { success: true, gistId, recreated: true }; } } else { throw err; } } if (!remoteData || typeof remoteData !== "object") { remoteData = { snapshots: {}, config: {} }; } // 3. 执行智能双向增量合并 const remoteSnapshots = remoteData.snapshots || {}; const localSnapshots = localData.snapshots || {}; const allDomains = Array.from(new Set([...Object.keys(localSnapshots), ...Object.keys(remoteSnapshots)])); const mergedSnapshots = {}; let totalMergedCount = 0; for (const domain of allDomains) { const localList = localSnapshots[domain] || []; const remoteList = remoteSnapshots[domain] || []; const recordMap = new Map(); for (const item of localList) { if (item && item.id) { recordMap.set(item.id, item); } } for (const rItem of remoteList) { if (!rItem || !rItem.id) continue; if (!recordMap.has(rItem.id)) { recordMap.set(rItem.id, rItem); } else { const lItem = recordMap.get(rItem.id); const rTime = rItem.updatedAt || rItem.createdAt || 0; const lTime = lItem.updatedAt || lItem.createdAt || 0; if (rTime > lTime) { recordMap.set(rItem.id, rItem); } } } const domainMergedList = Array.from(recordMap.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); mergedSnapshots[domain] = domainMergedList; totalMergedCount += domainMergedList.length; // 写入本地存储 GM_setValue(`SESSION_DATA_${domain}`, domainMergedList); } // 合并域名规则列表 const localHostLines = String(localData.config.host_list || "").split("\n").map((s) => s.trim()).filter(Boolean); const remoteHostLines = String(remoteData.config?.host_list || "").split("\n").map((s) => s.trim()).filter(Boolean); const mergedHostLines = Array.from(new Set([...localHostLines, ...remoteHostLines])); const mergedHostStr = mergedHostLines.join("\n"); GM_setValue("Config.host_list", mergedHostStr); const mergedFullData = { version: "1.2.1", updatedAt: Date.now(), config: { filter_mode: localData.config.filter_mode || remoteData.config?.filter_mode || "whitelist", host_list: mergedHostStr, enable_encryption: localData.config.enable_encryption, auto_reload_after_restore: localData.config.auto_reload_after_restore }, snapshots: mergedSnapshots }; // 4. 将合并后的全量数据回写至云端 Gist await this.updateGist(token, gistId, mergedFullData); this.lastSyncTime = Date.now(); GM_setValue("LSM_LAST_GIST_SYNC_TIME", this.lastSyncTime); this.updateUIStatus("success"); if (LSM_UI && typeof LSM_UI.refreshList === "function") { LSM_UI.refreshList(); } if (!options.silent) { showToastGlobal(`☁️ Gist 云同步完成 (共 ${totalMergedCount} 条快照)`, "success"); } return { success: true, gistId, count: totalMergedCount }; } catch (err) { this.updateUIStatus("error"); console.error("[LSM GistSync] 同步异常:", err); if (!options.silent) { showToastGlobal(`云同步失败: ${err.message}`, "error"); } throw err; } finally { this.isSyncing = false; } }, updateUIStatus(status) { if (LSM_UI && typeof LSM_UI.updateGistStatus === "function") { LSM_UI.updateGistStatus(status); } } }; // --------------------------------------------------------------------------- // 菜单命令弹窗:5. GitHub Gist 云同步设置弹窗 // --------------------------------------------------------------------------- function showGistSyncDialog() { if (document.querySelector(".lsm-dlg-mask")) return; ensureHostAnimationStyle(); const isEnabled = GM_getValue("Config.enable_gist_sync", false); const token = GM_getValue("Config.gist_token", ""); const gistId = GM_getValue("Config.gist_id", ""); const autoSync = GM_getValue("Config.auto_sync_on_change", true); const lastSync = GM_getValue("LSM_LAST_GIST_SYNC_TIME", 0); const escapeHtml = (str) => { if (!str) return ""; return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; const formatTime = (timestamp) => { if (!timestamp) return "-"; const d = new Date(timestamp); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; const mask = document.createElement("div"); mask.className = "lsm-dlg-mask"; mask.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" + "display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"; bindScrollLock(mask, "input"); const box = document.createElement("div"); box.style.cssText = "width:420px;max-width:calc(100vw - 40px);background:#ffffff;border-radius:16px;" + "padding:24px;box-shadow:0 20px 45px -10px rgba(15,23,42,0.25),0 0 0 1px rgba(15,23,42,0.06);box-sizing:border-box;animation:lsmFadeIn .2s ease-out;"; const title = document.createElement("div"); title.innerHTML = "☁️ GitHub Gist 云同步设置"; title.style.cssText = "margin-bottom:8px;display:flex;align-items:center;gap:6px;"; const desc = document.createElement("div"); desc.innerHTML = "使用 GitHub Private Gist 实现多设备全量快照与规则双向同步。
" + "🔗 点击快速生成包含 gist 权限的 GitHub Token"; desc.style.cssText = "font-size:12px;color:#64748b;line-height:1.5;margin-bottom:14px;"; const form = document.createElement("div"); form.style.cssText = "display:flex;flex-direction:column;gap:12px;margin-bottom:18px;"; // 1. 启用开关 const enableRow = document.createElement("label"); enableRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;cursor:pointer;background:#f8fafc;padding:10px 12px;border-radius:10px;border:1px solid #e2e8f0;"; enableRow.innerHTML = `
启用 Gist 云同步
开启多设备跨端云同步漫游
`; // 2. Token 输入 const tokenBox = document.createElement("div"); tokenBox.innerHTML = ` `; // 3. Gist ID const gistIdBox = document.createElement("div"); gistIdBox.innerHTML = `
💡 忘记 ID?留空直接同步,脚本会自动找回 在 GitHub Gist 查看
`; // 4. 自动同步 const autoSyncRow = document.createElement("label"); autoSyncRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;cursor:pointer;background:#f8fafc;padding:10px 12px;border-radius:10px;border:1px solid #e2e8f0;"; autoSyncRow.innerHTML = `
快照变更时自动同步
快照增删改时自动防抖推送到云端
`; // 上次同步状态 const statusInfo = document.createElement("div"); const timeStr = lastSync ? formatTime(lastSync) : "从未同步"; statusInfo.style.cssText = "font-size:11.5px;color:#64748b;padding:4px 0;"; statusInfo.innerHTML = `上次同步时间:${timeStr}`; form.append(enableRow, tokenBox, gistIdBox, autoSyncRow, statusInfo); // 绑定自动查找 Gist 按钮 const autoFindBtn = gistIdBox.querySelector("#lsm-btn-auto-find-gist"); if (autoFindBtn) { autoFindBtn.addEventListener("click", async () => { const tok = form.querySelector("#lsm-cfg-gist-token").value.trim(); if (!tok) { alert("请先填写上方 GitHub Token (PAT)"); return; } autoFindBtn.textContent = "🔍 查找中..."; autoFindBtn.disabled = true; try { const foundId = await GistSyncManager.findExistingGist(tok); if (foundId) { form.querySelector("#lsm-cfg-gist-id").value = foundId; showToastGlobal(`已自动找到并回填云备份 Gist: ${foundId.slice(0, 8)}...`, "success"); } else { showToastGlobal("未在您的 GitHub 账号中找到历史快照备份", "info"); } } catch (e) { showToastGlobal("查找异常: " + e.message, "error"); } finally { autoFindBtn.textContent = "🔍 自动查找账号中的备份"; autoFindBtn.disabled = false; } }); } // 按钮行 const btnRow = document.createElement("div"); btnRow.style.cssText = "display:flex;gap:8px;justify-content:flex-end;"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "取消"; cancelBtn.style.cssText = "padding:8px 14px;border:1px solid #e2e8f0;background:#ffffff;border-radius:8px;color:#64748b;font-size:12.5px;cursor:pointer;"; const saveBtn = document.createElement("button"); saveBtn.textContent = "仅保存配置"; saveBtn.style.cssText = "padding:8px 14px;border:1px solid #cbd5e1;background:#f8fafc;border-radius:8px;color:#334155;font-size:12.5px;cursor:pointer;font-weight:500;"; const syncNowBtn = document.createElement("button"); syncNowBtn.textContent = "保存并立即同步"; syncNowBtn.style.cssText = "padding:8px 14px;border:none;background:linear-gradient(135deg,#3b82f6,#2563eb);border-radius:8px;color:#ffffff;font-size:12.5px;cursor:pointer;font-weight:600;box-shadow:0 2px 6px rgba(37,99,235,0.25);"; const close = () => mask.remove(); const saveFormValues = () => { const en = form.querySelector("#lsm-cfg-enable-gist").checked; const tok = form.querySelector("#lsm-cfg-gist-token").value.trim(); const gid = form.querySelector("#lsm-cfg-gist-id").value.trim(); const auto = form.querySelector("#lsm-cfg-auto-sync").checked; GM_setValue("Config.enable_gist_sync", en); GM_setValue("Config.gist_token", tok); GM_setValue("Config.gist_id", gid); GM_setValue("Config.auto_sync_on_change", auto); if (GistSyncManager && typeof GistSyncManager.updateUIStatus === "function") { GistSyncManager.updateUIStatus(en && tok ? (gid ? "success" : "ready") : "unconfigured"); } }; saveBtn.addEventListener("click", () => { saveFormValues(); close(); showToastGlobal("Gist 配置已保存", "success"); }); syncNowBtn.addEventListener("click", async () => { saveFormValues(); const tok = form.querySelector("#lsm-cfg-gist-token").value.trim(); if (!tok) { alert("请填写 GitHub Personal Access Token (PAT)"); return; } syncNowBtn.textContent = "正在同步中..."; syncNowBtn.disabled = true; try { await GistSyncManager.sync({ silent: false }); close(); } catch (e) { alert("同步失败: " + e.message); syncNowBtn.textContent = "保存并立即同步"; syncNowBtn.disabled = false; } }); cancelBtn.addEventListener("click", close); mask.addEventListener("click", (e) => { if (e.target === mask) close(); }); btnRow.append(cancelBtn, saveBtn, syncNowBtn); box.append(title, desc, form, btnRow); mask.appendChild(box); document.documentElement.appendChild(mask); } // ========================================================================= // 主应用逻辑初始化 // ========================================================================= async function initApp() { if (document.getElementById("lsm-session-manager-root")) { if (LSM_UI && LSM_UI.ball) { LSM_UI.ball.style.display = ""; LSM_UI.ball.classList.remove("hidden"); } return; } const isEncryptionEnabled = () => GM_getValue("Config.enable_encryption", true); const isAutoReloadEnabled = () => GM_getValue("Config.auto_reload_after_restore", false); // ----------------------------------------------------------------------- // 加密与安全擦除引擎 (AES-GCM 256) // ----------------------------------------------------------------------- const CryptoEngine = { keyCache: new Map(), // 安全派生密钥:支持 v3(跨设备强通用密钥)、v2(域名绑定密钥)、legacy(UA 绑定历史密钥) async getDerivedKey(saltString, domain, version = "v3") { const host = (domain || location.hostname || "").trim().toLowerCase(); const salt = saltString || "SESSION_MGR_SALT_2026"; const cacheKey = `${host}___${salt}___${version}`; if (this.keyCache.has(cacheKey)) { return this.keyCache.get(cacheKey); } const enc = new TextEncoder(); let baseKeyMaterial = ""; if (version === "v3") { // v3: 全局稳定密钥材料,彻底消除跨设备、跨浏览器、二级域名或跨站点恢复时的环境不一致问题 baseKeyMaterial = "LSM_STABLE_UNIVERSAL_KEY_MATERIAL_2026_SECURE"; } else if (version === "v2") { // v2: 基于主域名的派生密钥材料 baseKeyMaterial = `LSM_KEY_V2_SNAPSHOT_${host}`; } else { // legacy: 兼容旧版本保存的历史快照数据 (含 UA 前缀) baseKeyMaterial = `LSM_KEY_${navigator.userAgent.slice(0, 32)}_${host}`; } const keyMaterial = await crypto.subtle.importKey( "raw", enc.encode(baseKeyMaterial), { name: "PBKDF2" }, false, ["deriveKey"] ); const derivedKey = await crypto.subtle.deriveKey( { name: "PBKDF2", salt: enc.encode(salt), iterations: 100000, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); this.keyCache.set(cacheKey, derivedKey); return derivedKey; }, async encrypt(plainObject, domain) { if (!isEncryptionEnabled() || !crypto.subtle) { return { encrypted: false, payload: JSON.stringify(plainObject) }; } try { const iv = crypto.getRandomValues(new Uint8Array(12)); // 默认使用 v3 跨设备强通用稳定密钥加密 const key = await this.getDerivedKey("SESSION_SALT_GCM", domain, "v3"); const encodedData = new TextEncoder().encode(JSON.stringify(plainObject)); const cipherBuffer = await crypto.subtle.encrypt( { name: "AES-GCM", iv: iv }, key, encodedData ); const ivBase64 = btoa(String.fromCharCode(...iv)); const cipherBase64 = btoa(String.fromCharCode(...new Uint8Array(cipherBuffer))); return { encrypted: true, v: "3", // 标注密钥协议版本 iv: ivBase64, payload: cipherBase64 }; } catch (err) { console.warn("[LSM] 加密失败,使用原始格式:", err); return { encrypted: false, payload: JSON.stringify(plainObject) }; } }, async decrypt(cipherObj, domain) { if (!cipherObj) return null; if (!cipherObj.encrypted) { return typeof cipherObj.payload === "string" ? JSON.parse(cipherObj.payload) : cipherObj.payload; } try { const iv = new Uint8Array( atob(cipherObj.iv) .split("") .map((c) => c.charCodeAt(0)) ); const cipherData = new Uint8Array( atob(cipherObj.payload) .split("") .map((c) => c.charCodeAt(0)) ); const tryDecryptWithKey = async (targetDomain, version) => { try { const key = await this.getDerivedKey("SESSION_SALT_GCM", targetDomain, version); const decryptedBuffer = await crypto.subtle.decrypt( { name: "AES-GCM", iv: iv }, key, cipherData ); const decryptedStr = new TextDecoder().decode(decryptedBuffer); return JSON.parse(decryptedStr); } catch (e) { return null; } }; // 智能自适应多级回退解密管道: // 1. 优先尝试 v3 跨设备强通用密钥 (零环境依赖) let res = await tryDecryptWithKey("", "v3"); if (res) return res; // 2. 尝试 v2 密钥 (快照原始 domain) if (domain) { res = await tryDecryptWithKey(domain, "v2"); if (res) return res; } // 3. 尝试 v2 密钥 (当前页面 location.hostname) if (location.hostname && location.hostname !== domain) { res = await tryDecryptWithKey(location.hostname, "v2"); if (res) return res; } // 4. 尝试 legacy 密钥 (快照原始 domain) if (domain) { res = await tryDecryptWithKey(domain, "legacy"); if (res) return res; } // 5. 尝试 legacy 密钥 (当前 location.hostname) if (location.hostname && location.hostname !== domain) { res = await tryDecryptWithKey(location.hostname, "legacy"); if (res) return res; } throw new Error("数据解密失败,快照可能损坏或加密密钥不匹配"); } catch (err) { console.error("[LSM] 解密失败:", err); throw err instanceof Error ? err : new Error("数据解密失败"); } }, wipeMemory(obj) { if (typeof obj === "object" && obj !== null) { for (const key of Object.keys(obj)) { if (typeof obj[key] === "string") { obj[key] = ""; } else if (typeof obj[key] === "object") { this.wipeMemory(obj[key]); } delete obj[key]; } } } }; // ----------------------------------------------------------------------- // Cookie & WebStorage 捕获与恢复 // ----------------------------------------------------------------------- const SessionManager = { hasGmCookie() { return ( typeof GM_cookie !== "undefined" && GM_cookie && typeof GM_cookie.list === "function" && typeof GM_cookie.set === "function" ); }, async getCookies() { if (this.hasGmCookie()) { return new Promise((resolve) => { try { GM_cookie.list({ url: location.href }, (cookies, error) => { if (error || !cookies) { resolve(this.getDocumentCookies()); } else { resolve( cookies.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path || "/", secure: !!c.secure, httpOnly: !!c.httpOnly, sameSite: c.sameSite || "unspecified", expirationDate: c.expirationDate })) ); } }); } catch (e) { resolve(this.getDocumentCookies()); } }); } return this.getDocumentCookies(); }, getDocumentCookies() { const raw = document.cookie; if (!raw || !raw.trim()) return []; return raw .split(";") .map((pair) => { const idx = pair.indexOf("="); if (idx === -1) return null; const name = pair.slice(0, idx).trim(); const value = pair.slice(idx + 1).trim(); if (!name) return null; return { name, value, domain: location.hostname, path: "/", secure: location.protocol === "https:", httpOnly: false }; }) .filter(Boolean); }, getWebStorage() { const local = {}; const session = {}; try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key) local[key] = localStorage.getItem(key); } } catch (e) {} try { for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); if (key) session[key] = sessionStorage.getItem(key); } } catch (e) {} return { localStorage: local, sessionStorage: session }; }, async captureCurrentSession() { const cookies = await this.getCookies(); const storage = this.getWebStorage(); const sessionObj = { domain: location.hostname, url: location.href, timestamp: Date.now(), cookies: cookies, localStorage: storage.localStorage, sessionStorage: storage.sessionStorage }; const approxBytes = new Blob([JSON.stringify(sessionObj)]).size; return { ...sessionObj, summary: { cookieCount: cookies.length, localCount: Object.keys(storage.localStorage).length, sessionCount: Object.keys(storage.sessionStorage).length, approxBytes: approxBytes } }; }, async clearAllData() { let cookieCount = 0; const hostname = location.hostname; const hostParts = hostname.split("."); if (this.hasGmCookie()) { try { // 1. 获取当前页面 URL 作用域下的 Cookie const cookiesByUrl = await new Promise((resolve) => { GM_cookie.list({ url: location.href }, (c, err) => { if (err || !c) resolve([]); else resolve(c); }); }); // 2. 获取当前域名及所有可能父级域名的 Cookie(覆盖带点和不带点) const domainList = [hostname, "." + hostname]; for (let i = 0; i < hostParts.length - 1; i++) { const d = hostParts.slice(i).join("."); domainList.push(d); domainList.push("." + d); } const domainCookies = []; for (const d of Array.from(new Set(domainList))) { try { const list = await new Promise((resolve) => { GM_cookie.list({ domain: d }, (c, err) => { if (err || !c) resolve([]); else resolve(c); }); }); if (Array.isArray(list)) domainCookies.push(...list); } catch (e) {} } // 合并去重 const allCookiesMap = new Map(); for (const c of [...cookiesByUrl, ...domainCookies]) { const key = `${c.name}___${c.domain || ""}___${c.path || ""}`; allCookiesMap.set(key, c); } // 并发删除所有已收集的 Cookie const deletePromises = Array.from(allCookiesMap.values()).map((c) => { return new Promise((resolve) => { const delDetails = { url: location.href, name: c.name }; if (c.domain) delDetails.domain = c.domain; if (c.path) delDetails.path = c.path; GM_cookie.delete(delDetails, () => { cookieCount++; resolve(); }); }); }); await Promise.all(deletePromises); } catch (e) {} } // 无论是否使用了 GM_cookie,均通过 document.cookie 进行逐级域名和 Path 的全域兜底双向清除 try { const docCookies = this.getDocumentCookies(); for (const c of docCookies) { document.cookie = `${encodeURIComponent(c.name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; document.cookie = `${encodeURIComponent(c.name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${hostname}`; document.cookie = `${encodeURIComponent(c.name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${hostname}`; for (let i = 0; i < hostParts.length - 1; i++) { const domain = hostParts.slice(i).join("."); document.cookie = `${encodeURIComponent(c.name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${domain}`; document.cookie = `${encodeURIComponent(c.name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`; } cookieCount++; } } catch (e) {} let storageCount = 0; try { storageCount += localStorage.length; localStorage.clear(); } catch (e) {} try { storageCount += sessionStorage.length; sessionStorage.clear(); } catch (e) {} return { cookieCount, storageCount }; }, async restoreSession(sessionData) { // 切换与恢复前,先彻底清空当前所有 Cookie 与 WebStorage await this.clearAllData(); let cookieSuccessCount = 0; let cookieFailCount = 0; if (Array.isArray(sessionData.cookies)) { if (this.hasGmCookie()) { const cookieSetPromises = sessionData.cookies.map((c) => { return new Promise((resolve) => { try { let targetDomain = c.domain || location.hostname; // 跨浏览器域名规范化:若当前主域名与保存域名的基准一致,统一写入当前 hostname if (targetDomain.startsWith(".")) { const noDot = targetDomain.slice(1); if (location.hostname === noDot) { targetDomain = location.hostname; } } const cookieDetails = { url: location.href, name: c.name, value: c.value, path: c.path || "/", domain: targetDomain, secure: !!c.secure, httpOnly: !!c.httpOnly }; if (c.sameSite && c.sameSite !== "unspecified") cookieDetails.sameSite = c.sameSite; if (c.expirationDate) cookieDetails.expirationDate = c.expirationDate; GM_cookie.set(cookieDetails, (err) => { if (err) cookieFailCount++; else cookieSuccessCount++; resolve(); }); } catch (e) { cookieFailCount++; resolve(); } }); }); await Promise.all(cookieSetPromises); } else { for (const c of sessionData.cookies) { try { let cookieStr = `${encodeURIComponent(c.name)}=${encodeURIComponent(c.value)}; path=${c.path || "/"}`; if (c.domain && !c.domain.startsWith(".")) cookieStr += `; domain=${c.domain}`; if (c.secure || location.protocol === "https:") cookieStr += "; Secure"; if (c.expirationDate) cookieStr += `; expires=${new Date(c.expirationDate * 1000).toUTCString()}`; document.cookie = cookieStr; cookieSuccessCount++; } catch (e) { cookieFailCount++; } } } } let localCount = 0; if (sessionData.localStorage && typeof sessionData.localStorage === "object") { try { localStorage.clear(); for (const [k, v] of Object.entries(sessionData.localStorage)) { if (v !== null && v !== undefined) { localStorage.setItem(k, v); localCount++; } } } catch (e) {} } let sessionCount = 0; if (sessionData.sessionStorage && typeof sessionData.sessionStorage === "object") { try { sessionStorage.clear(); for (const [k, v] of Object.entries(sessionData.sessionStorage)) { if (v !== null && v !== undefined) { sessionStorage.setItem(k, v); sessionCount++; } } } catch (e) {} } return { cookieSuccessCount, cookieFailCount, localCount, sessionCount }; } }; // ----------------------------------------------------------------------- // 数据库与存储管理 // ----------------------------------------------------------------------- const DB = { getStorageKey(domain) { return `SESSION_DATA_${domain || location.hostname}`; }, getRecords(domain) { const key = this.getStorageKey(domain); const raw = GM_getValue(key, []); return Array.isArray(raw) ? raw : []; }, saveRecords(records, domain) { const key = this.getStorageKey(domain); GM_setValue(key, records); }, async addRecord(name, rawSessionData) { const domain = location.hostname; const records = this.getRecords(domain); const cipherObject = await CryptoEngine.encrypt(rawSessionData); const newRecord = { id: "sess_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7), name: name.trim(), domain: domain, url: location.href, createdAt: Date.now(), updatedAt: Date.now(), summary: rawSessionData.summary, cipherData: cipherObject }; records.unshift(newRecord); this.saveRecords(records, domain); if (typeof GistSyncManager !== "undefined") { GistSyncManager.triggerAutoSync(); } CryptoEngine.wipeMemory(rawSessionData); return newRecord; }, updateRecordName(id, newName, domain) { const d = domain || location.hostname; const records = this.getRecords(d); const target = records.find((r) => r.id === id); if (target) { target.name = newName.trim(); target.updatedAt = Date.now(); this.saveRecords(records, d); if (typeof GistSyncManager !== "undefined") { GistSyncManager.triggerAutoSync(); } return true; } return false; }, deleteRecord(id, domain) { const d = domain || location.hostname; let records = this.getRecords(d); const initialLen = records.length; records = records.filter((r) => r.id !== id); if (records.length !== initialLen) { this.saveRecords(records, d); if (typeof GistSyncManager !== "undefined") { GistSyncManager.triggerAutoSync(); } return true; } return false; }, importRecords(newRecords, domain) { const d = domain || location.hostname; const existing = this.getRecords(d); let count = 0; let skipped = 0; for (const item of newRecords) { if (!item || !item.name || !item.cipherData) continue; // 对比核心数据内容与 ID,已存在相同快照数据则直接跳过 const itemCipherStr = typeof item.cipherData === "string" ? item.cipherData : JSON.stringify(item.cipherData); const isDuplicate = existing.some((r) => { if (!r || !r.cipherData) return false; const rCipherStr = typeof r.cipherData === "string" ? r.cipherData : JSON.stringify(r.cipherData); return rCipherStr === itemCipherStr || (r.id && item.id && r.id === item.id); }); if (isDuplicate) { skipped++; continue; } // 如果 ID 冲突则重新生成,避免重复 const record = { ...item, id: item.id && !existing.some((r) => r.id === item.id) ? item.id : "sess_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7), importedAt: Date.now() }; existing.unshift(record); count++; } if (count > 0) { this.saveRecords(existing, d); if (typeof GistSyncManager !== "undefined") { GistSyncManager.triggerAutoSync(); } } return { count, skipped }; }, async getDecryptedSession(id, domain) { const d = domain || location.hostname; const records = this.getRecords(d); const target = records.find((r) => r.id === id); if (!target) throw new Error("未找到对应快照记录"); const recDomain = target.domain || d; return await CryptoEngine.decrypt(target.cipherData, recDomain); } }; // ----------------------------------------------------------------------- // 辅助工具 // ----------------------------------------------------------------------- function formatTime(timestamp) { if (!timestamp) return "-"; const d = new Date(timestamp); if (isNaN(d.getTime())) return "-"; const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function getDefaultName() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return `${location.hostname}_${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function escapeHtml(str) { if (!str) return ""; return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function downloadJsonFile(filename, contentObj) { try { const jsonStr = JSON.stringify(contentObj, null, 2); const blob = new Blob([jsonStr], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); } catch (e) { alert("下载文件失败: " + e.message); } } function downloadCanvasAsImage(canvas, filename) { try { const url = canvas.toDataURL("image/png"); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); }, 100); } catch (e) { alert("下载图片失败: " + e.message); } } function readFileAsJson(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { const json = JSON.parse(e.target.result); resolve(json); } catch (err) { reject(new Error("文件解析失败,请确认选择的是正确的 JSON 格式文件")); } }; reader.onerror = () => reject(new Error("读取文件出错")); reader.readAsText(file); }); } // ----------------------------------------------------------------------- // UI 结构与样式 // ----------------------------------------------------------------------- const uid = "lsm-" + Math.random().toString(36).slice(2, 8); const container = document.createElement("div"); container.id = "lsm-session-manager-root"; const shadow = container.attachShadow({ mode: "open" }); document.documentElement.appendChild(container); const style = document.createElement("style"); style.textContent = ` #${uid}-root { all: initial; display: block; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "PingFang SC", "Microsoft YaHei", sans-serif; color: #0f172a; font-size: 13px; line-height: 1.5; text-align: left; -webkit-font-smoothing: antialiased; } #${uid}-root *, #${uid}-root *::before, #${uid}-root *::after { box-sizing: border-box; } #${uid}-root input, #${uid}-root select, #${uid}-root textarea, #${uid}-root button { font-family: inherit; } /* 滚动条美化 */ .${uid}-content::-webkit-scrollbar { width: 6px; } .${uid}-content::-webkit-scrollbar-track { background: transparent; } .${uid}-content::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } .${uid}-content::-webkit-scrollbar-thumb:hover { background: #94a3b8; } /* 悬浮球 */ #${uid}-ball { position: fixed; left: auto; top: auto; right: 25px; bottom: 80px; z-index: 2147483646; width: 50px; height: 50px; border-radius: 50%; background: linear-gradient(135deg, #1e40af 0%, #2563eb 50%, #3b82f6 100%); color: #ffffff; font-weight: 700; font-size: 14px; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 24px -4px rgba(37, 99, 235, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.2) inset; cursor: grab; user-select: none; opacity: 0.65; transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease, opacity 0.25s ease, left 0.3s cubic-bezier(0.2, 0, 0, 1), top 0.3s cubic-bezier(0.2, 0, 0, 1); } #${uid}-ball:hover { opacity: 1; transform: scale(1.06); box-shadow: 0 12px 30px -4px rgba(37, 99, 235, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.3) inset; } #${uid}-ball.dragging { opacity: 1; cursor: grabbing; transform: scale(0.96); transition: none; } #${uid}-ball svg { width: 24px; height: 24px; fill: currentColor; pointer-events: none; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15)); } /* 悬浮球右上角微型关闭/菜单按钮 */ .${uid}-ball-close { position: absolute; top: -3px; right: -3px; width: 18px; height: 18px; border-radius: 50%; line-height: 16px; background: #0f172a; color: #ffffff; font-size: 11px; text-align: center; cursor: pointer; display: none; z-index: 3; border: 1.5px solid #ffffff; box-shadow: 0 2px 6px rgba(0,0,0,0.25); transition: background 0.15s ease, transform 0.15s ease; } #${uid}-ball:hover .${uid}-ball-close { display: block; } .${uid}-ball-close:hover { background: #e11d48; transform: scale(1.15); } /* 徽标 */ .${uid}-badge { position: absolute; top: -3px; left: -3px; background: linear-gradient(135deg, #f43f5e, #e11d48); color: #ffffff; font-size: 10px; font-weight: 700; min-width: 18px; height: 18px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid #ffffff; box-shadow: 0 2px 6px rgba(225, 29, 72, 0.4); } /* 悬浮球快捷菜单遮罩与弹窗 */ .${uid}-menu-mask { position: fixed; inset: 0; z-index: 2147483646; background: rgba(15, 23, 42, 0.45); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } .${uid}-menu-mask.hidden { display: none; } .${uid}-ball-menu { position: fixed; left: 50%; top: 45%; transform: translate(-50%, -50%); z-index: 2147483647; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 45px -10px rgba(15, 23, 42, 0.25), 0 0 0 1px rgba(15, 23, 42, 0.06); padding: 18px; width: 280px; } .${uid}-ball-menu-title { font-weight: 700; margin: 0 0 12px; font-size: 14px; color: #0f172a; display: flex; align-items: center; gap: 6px; } .${uid}-ball-menu button { display: flex; align-items: center; width: 100%; margin-top: 8px; padding: 9px 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #f8fafc; cursor: pointer; font-size: 12px; font-weight: 500; text-align: left; color: #334155; transition: all 0.15s ease; } .${uid}-ball-menu button:hover { background: #f1f5f9; border-color: #cbd5e1; color: #0f172a; } .${uid}-ball-menu button[data-a="forever"] { border-color: #fecdd3; background: #fff1f2; color: #e11d48; } .${uid}-ball-menu button[data-a="forever"]:hover { background: #ffe4e6; } /* 主管理窗口 */ #${uid}-window { position: fixed; left: auto; top: auto; right: 30px; bottom: 90px; z-index: 2147483646; width: 560px; height: 660px; max-width: calc(100vw - 20px); max-height: calc(100vh - 30px); background: #ffffff; border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25), 0 0 0 1px rgba(15, 23, 42, 0.08); overscroll-behavior: contain; touch-action: none; } #${uid}-window.hidden, #${uid}-ball.hidden { display: none !important; } /* 头部 Header */ #${uid}-header { flex: none; display: flex; align-items: center; justify-content: space-between; padding: 13px 18px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #334155 100%); color: #f8fafc; font-size: 14px; font-weight: 600; cursor: grab; user-select: none; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } #${uid}-header.dragging { cursor: grabbing; } .${uid}-header-left { display: flex; align-items: center; gap: 10px; } .${uid}-header-title { display: flex; align-items: center; gap: 6px; letter-spacing: 0.2px; } .${uid}-domain-tag { background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.18); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: #f8fafc; font-size: 11px; padding: 2px 8px; border-radius: 9999px; font-weight: 500; max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; } .${uid}-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .${uid}-header-actions button { border: none; background: rgba(255, 255, 255, 0.12); color: #f8fafc; border-radius: 8px; height: 26px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 13px; box-sizing: border-box; transition: all 0.15s ease; } #${uid}-btn-close { width: 26px !important; height: 26px !important; padding: 0 !important; font-size: 16px !important; line-height: 1 !important; } .${uid}-header-actions button:hover { background: rgba(255, 255, 255, 0.25); transform: scale(1.03); } /* 状态条 */ .${uid}-status-bar { padding: 7px 18px; background: #f8fafc; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: #64748b; flex: none; } .${uid}-status-item { display: flex; align-items: center; gap: 6px; font-weight: 500; } .${uid}-status-gist-btn { cursor: pointer; padding: 3px 8px; margin: -3px 0; border-radius: 6px; background: rgba(241, 245, 249, 0.65); border: 1px solid #e2e8f0; transition: all 0.15s ease; user-select: none; display: inline-flex; align-items: center; gap: 5px; } .${uid}-status-gist-btn:hover { background: #f1f5f9; border-color: #cbd5e1; color: #0f172a; } .${uid}-status-gist-btn:hover .${uid}-gist-sync-icon { color: #2563eb; } .${uid}-gist-sync-icon { color: #64748b; display: inline-block; vertical-align: middle; flex-shrink: 0; transition: transform 0.2s ease, color 0.15s ease; } .${uid}-gist-sync-icon.spinning { animation: ${uid}-spin 0.8s linear infinite; color: #2563eb; } @keyframes ${uid}-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .${uid}-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; } .${uid}-dot-green { background: #10b981; box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); } .${uid}-dot-amber { background: #f59e0b; box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); } .${uid}-dot-blue { background: #3b82f6; box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25); animation: ${uid}-pulse 1.5s infinite ease-in-out; } .${uid}-dot-red { background: #ef4444; box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25); } @keyframes ${uid}-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.25); } } .${uid}-btn-cloud { border: 1px solid rgba(255, 255, 255, 0.25) !important; background: rgba(255, 255, 255, 0.15) !important; color: #ffffff !important; border-radius: 8px !important; height: 26px !important; width: auto !important; min-width: unset !important; padding: 0 8px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; gap: 4px !important; cursor: pointer !important; font-size: 11px !important; font-weight: 600 !important; white-space: nowrap !important; flex-shrink: 0 !important; box-sizing: border-box !important; line-height: 1 !important; transition: all 0.15s ease !important; } .${uid}-btn-cloud:hover { background: rgba(255, 255, 255, 0.28) !important; transform: scale(1.02) !important; } /* 操作工具栏 */ .${uid}-toolbar { padding: 10px 18px; background: #ffffff; border-bottom: 1px solid #f1f5f9; display: flex; flex-direction: column; gap: 8px; flex: none; } .${uid}-toolbar-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: nowrap; } .${uid}-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 12px; font-size: 12px; font-weight: 600; border-radius: 8px; border: 1px solid transparent; cursor: pointer; user-select: none; white-space: nowrap; transition: all 0.15s ease; } .${uid}-btn-primary { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: #ffffff !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25); } .${uid}-btn-primary:hover { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35); color: #ffffff !important; } .${uid}-btn-secondary { background: #f8fafc; color: #334155; border-color: #e2e8f0; } .${uid}-btn-secondary:hover { background: #f1f5f9; border-color: #cbd5e1; color: #0f172a; } .${uid}-btn-danger { background: #fff1f2; color: #e11d48; border-color: #fecdd3; } .${uid}-btn-danger:hover { background: #ffe4e6; border-color: #fda4af; } .${uid}-btn-restore-pill { background: #f0fdf4 !important; color: #15803d !important; border-color: #bbf7d0 !important; font-weight: 600; } .${uid}-btn-restore-pill:hover { background: #dcfce7 !important; border-color: #86efac !important; } .${uid}-btn-sm { padding: 4px 10px; font-size: 11px; border-radius: 6px; } .${uid}-btn-icon { padding: 7px 9px !important; min-width: 32px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; } /* 更多操作下拉菜单 */ .${uid}-dropdown-wrapper { position: relative; display: inline-flex; flex-shrink: 0; } .${uid}-dropdown-menu { position: absolute; top: calc(100% + 6px); right: 0; min-width: 175px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 6px; box-shadow: 0 12px 30px -4px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(15, 23, 42, 0.05); z-index: 50; display: flex; flex-direction: column; gap: 2px; } .${uid}-dropdown-menu.hidden { display: none !important; } .${uid}-dropdown-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; font-weight: 500; color: #334155; border-radius: 8px; cursor: pointer; user-select: none; transition: all 0.12s ease; white-space: nowrap; } .${uid}-dropdown-item:hover { background: #f1f5f9; color: #0f172a; } .${uid}-dropdown-divider { height: 1px; background: #f1f5f9; margin: 4px 0; } .${uid}-item-accent { color: #15803d !important; font-weight: 600; } .${uid}-item-accent:hover { background: #f0fdf4 !important; color: #166534 !important; } /* 移动端与小屏幕自适应响应式布局 */ @media (max-width: 480px) { #${uid}-window { left: 10px !important; right: 10px !important; bottom: 15px !important; width: auto !important; max-width: calc(100vw - 20px) !important; height: 82vh !important; border-radius: 14px; } #${uid}-header { padding: 10px 14px; } .${uid}-domain-tag { max-width: 170px; font-size: 10px; padding: 1px 6px; } .${uid}-toolbar { padding: 8px 12px; gap: 6px; } .${uid}-toolbar-row { gap: 6px; } .${uid}-btn { padding: 6px 8px; font-size: 11px; gap: 4px; } .${uid}-content { padding: 10px 12px; gap: 10px; } .${uid}-card { padding: 10px 12px; } .${uid}-card-chips { gap: 4px; } .${uid}-chip { font-size: 10px; padding: 1px 6px; } .${uid}-card-actions { flex-wrap: wrap; gap: 4px; justify-content: flex-end; } .${uid}-card-actions .${uid}-btn { padding: 4px 7px; font-size: 10px; } .${uid}-search-input { height: 30px; font-size: 11px; padding: 0 26px 0 28px; } } /* 搜索栏精细化美化 */ .${uid}-search-wrap { position: relative; display: flex; align-items: center; width: 100%; margin-top: 2px; } .${uid}-search-icon { position: absolute; left: 10px; pointer-events: none; color: #94a3b8; display: flex; align-items: center; justify-content: center; transition: color 0.15s ease; } .${uid}-search-input { width: 100%; height: 32px; padding: 0 28px 0 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; font-size: 12px; color: #1e293b; outline: none; box-sizing: border-box; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .${uid}-search-input::placeholder { color: #94a3b8; font-size: 11px; } .${uid}-search-input:focus { background: #ffffff; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } .${uid}-search-wrap:focus-within .${uid}-search-icon { color: #3b82f6; } .${uid}-search-clear { position: absolute; right: 7px; width: 18px; height: 18px; border-radius: 50%; border: none; background: #e2e8f0; color: #64748b; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; transition: all 0.15s ease; } .${uid}-search-clear:hover { background: #cbd5e1; color: #0f172a; transform: scale(1.08); } .${uid}-search-clear.hidden { display: none !important; } /* 记录列表区域 */ .${uid}-content { padding: 14px 18px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: 12px; min-height: 0; background: #f8fafc; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; } .${uid}-card { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .${uid}-card:hover { border-color: #cbd5e1; transform: translateY(-2px); box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.08); } .${uid}-card.${uid}-card-active { border-color: #86efac; background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 60%); box-shadow: 0 4px 14px -2px rgba(34, 197, 94, 0.15); } .${uid}-badge-active { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; background: #dcfce7; color: #15803d; border: 1px solid #bbf7d0; margin-left: 4px; flex-shrink: 0; } .${uid}-card-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .${uid}-card-name { font-weight: 700; font-size: 13px; color: #0f172a; word-break: break-all; display: flex; align-items: center; gap: 6px; } .${uid}-card-time { font-size: 11px; color: #94a3b8; flex-shrink: 0; } /* 凭证 Chips 徽章组 */ .${uid}-card-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .${uid}-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 9999px; line-height: 1.4; } .${uid}-chip-cookie { background: #fffbeb; color: #b45309; border: 1px solid #fef3c7; } .${uid}-chip-local { background: #f0fdf4; color: #15803d; border: 1px solid #dcfce7; } .${uid}-chip-session { background: #faf5ff; color: #7e22ce; border: 1px solid #f3e8ff; } .${uid}-chip-encrypted { background: #f0f9ff; color: #0284c7; border: 1px solid #e0f2fe; } /* 来源链接小标签 */ .${uid}-card-origin { display: flex; align-items: center; gap: 6px; background: #f8fafc; border: 1px solid #f1f5f9; border-radius: 6px; padding: 4px 8px; margin-top: 2px; font-size: 11px; color: #64748b; } .${uid}-card-url { color: #2563eb; text-decoration: none; word-break: break-all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; max-width: calc(100% - 60px); } .${uid}-card-url:hover { text-decoration: underline; color: #1d4ed8; } /* 卡片操作栏 */ .${uid}-card-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; border-top: 1px solid #f1f5f9; padding-top: 8px; margin-top: 2px; } .${uid}-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 50px 0; color: #94a3b8; text-align: center; gap: 10px; } /* 内置保存抽屉弹窗 */ .${uid}-save-dialog { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #ffffff; display: flex; flex-direction: column; padding: 24px; gap: 16px; transform: translateY(100%); transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); z-index: 10; overscroll-behavior: contain; overflow-y: auto; -webkit-overflow-scrolling: touch; touch-action: pan-y; } .${uid}-save-dialog.open { transform: translateY(0); } .${uid}-save-dialog-title { font-size: 15px; font-weight: 700; color: #0f172a; display: flex; align-items: center; gap: 6px; } .${uid}-input-group { display: flex; flex-direction: column; gap: 6px; } .${uid}-input-label { font-size: 12px; font-weight: 600; color: #334155; } .${uid}-input { width: 100%; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 13px; outline: none; transition: all 0.15s ease; } .${uid}-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } .${uid}-grid-preview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 4px; } .${uid}-stat-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px; text-align: center; } .${uid}-stat-num { font-size: 18px; font-weight: 700; color: #0f172a; margin-top: 2px; } .${uid}-stat-label { font-size: 11px; color: #64748b; } /* 快照二维码展示抽屉弹窗 */ .${uid}-qr-dialog { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #ffffff; display: flex; flex-direction: column; padding: 20px; gap: 12px; transform: translateY(100%); transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); z-index: 12; overscroll-behavior: contain; overflow-y: auto; -webkit-overflow-scrolling: touch; touch-action: pan-y; } .${uid}-qr-dialog.open { transform: translateY(0); } .${uid}-qr-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; } .${uid}-qr-canvas-wrap { background: #ffffff; padding: 10px; border-radius: 12px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0,0,0,0.04); display: flex; align-items: center; justify-content: center; max-width: 100%; } .${uid}-qr-canvas-wrap canvas { max-width: 100%; height: auto !important; display: block; border-radius: 6px; } .${uid}-qr-overflow-box { background: #fffbeb; border: 1px solid #fde68a; border-radius: 12px; padding: 16px 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; max-width: 100%; box-shadow: 0 2px 8px rgba(245, 158, 11, 0.08); } .${uid}-qr-chunk-player { display: none; flex-direction: column; align-items: center; gap: 10px; width: 100%; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .${uid}-qr-chunk-header { display: flex; align-items: center; justify-content: space-between; width: 100%; font-size: 12px; color: #334155; font-weight: 600; } .${uid}-qr-chunk-badge { display: inline-flex; align-items: center; gap: 5px; background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; padding: 3px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; } .${uid}-qr-chunk-bar-wrap { width: 100%; height: 6px; background: #e2e8f0; border-radius: 9999px; overflow: hidden; } .${uid}-qr-chunk-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #06b6d4); border-radius: 9999px; transition: width 0.15s ease; } .${uid}-qr-chunk-controls { display: flex; align-items: center; justify-content: space-between; gap: 6px; width: 100%; } /* 扫码与综合导入抽屉弹窗 */ .${uid}-scan-dialog { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #ffffff; display: flex; flex-direction: column; padding: 20px; gap: 14px; transform: translateY(100%); transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); z-index: 12; overscroll-behavior: contain; overflow-y: auto; -webkit-overflow-scrolling: touch; touch-action: pan-y; } .${uid}-scan-dialog.open { transform: translateY(0); } .${uid}-dialog-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #f1f5f9; padding-bottom: 10px; } .${uid}-dialog-title { font-size: 15px; font-weight: 700; color: #0f172a; display: flex; align-items: center; gap: 6px; } .${uid}-dialog-close { background: none; border: none; font-size: 18px; line-height: 1; color: #94a3b8; cursor: pointer; padding: 4px; border-radius: 6px; transition: color 0.15s ease, background 0.15s ease; } .${uid}-dialog-close:hover { color: #0f172a; background: #f1f5f9; } /* 摄像头扫码视口与取景框 */ .${uid}-camera-viewport { position: relative; width: 100%; height: 350px; background: #090d16; border-radius: 12px; overflow: hidden; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(0,0,0,0.15) inset; } .${uid}-camera-video { width: 100%; height: 100%; object-fit: cover; display: block; } .${uid}-camera-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; gap: 8px; padding: 20px; text-align: center; font-size: 12px; background: rgba(15, 23, 42, 0.92); z-index: 2; } .${uid}-scan-chunk-hud { position: absolute; bottom: 10px; left: 10px; right: 10px; top: auto; background: rgba(15, 23, 42, 0.94); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid rgba(56, 189, 248, 0.4); border-radius: 12px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; z-index: 6; color: #ffffff; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); } .${uid}-scan-chunk-title { font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; color: #f1f5f9; } .${uid}-scan-chunk-chips { display: flex; flex-wrap: wrap; gap: 4px; max-height: 84px; overflow-y: auto; padding: 2px 1px; } @keyframes ${uid}-chunk-pop { 0% { transform: scale(0.8); } 50% { transform: scale(1.15); } 100% { transform: scale(1); } } .${uid}-scan-chunk-dot { min-width: 24px; height: 24px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; border-radius: 5px; background: rgba(255, 255, 255, 0.12); color: #cbd5e1; border: 1px solid rgba(255, 255, 255, 0.15); transition: all 0.15s ease; } .${uid}-scan-chunk-dot.received { background: linear-gradient(135deg, #10b981, #059669); color: #ffffff; border-color: #34d399; box-shadow: 0 0 8px rgba(16, 185, 129, 0.7); animation: ${uid}-chunk-pop 0.25s ease-out; } .${uid}-scan-frame { position: absolute; width: 220px; height: 220px; border: 2px solid rgba(59, 130, 246, 0.7); border-radius: 12px; box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.45); z-index: 3; pointer-events: none; } .${uid}-scan-corner { position: absolute; width: 16px; height: 16px; border-color: #38bdf8; border-style: solid; } .${uid}-scan-corner-tl { top: -2px; left: -2px; border-width: 3px 0 0 3px; border-top-left-radius: 8px; } .${uid}-scan-corner-tr { top: -2px; right: -2px; border-width: 3px 3px 0 0; border-top-right-radius: 8px; } .${uid}-scan-corner-bl { bottom: -2px; left: -2px; border-width: 0 0 3px 3px; border-bottom-left-radius: 8px; } .${uid}-scan-corner-br { bottom: -2px; right: -2px; border-width: 0 3px 3px 0; border-bottom-right-radius: 8px; } .${uid}-scan-laser { position: absolute; left: 6px; right: 6px; height: 2px; background: linear-gradient(90deg, rgba(56,189,248,0) 0%, #38bdf8 50%, rgba(56,189,248,0) 100%); box-shadow: 0 0 10px #38bdf8, 0 0 4px #0284c7; animation: ${uid}-laser-anim 2s infinite ease-in-out; z-index: 4; pointer-events: none; } @keyframes ${uid}-laser-anim { 0% { top: 8px; opacity: 0.8; } 50% { top: calc(100% - 10px); opacity: 1; } 100% { top: 8px; opacity: 0.8; } } /* 导入上传选择区 */ .${uid}-import-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .${uid}-dropzone-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 12px 8px; background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 10px; cursor: pointer; transition: all 0.15s ease; color: #334155; gap: 6px; text-align: center; } .${uid}-dropzone-btn:hover { background: #f1f5f9; border-color: #3b82f6; color: #1d4ed8; } .${uid}-dropzone-label { font-size: 12px; font-weight: 600; } .${uid}-dropzone-hint { font-size: 10px; color: #94a3b8; } /* 识别成功结果展示卡片 */ .${uid}-result-card { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 8px; } .${uid}-result-title { font-size: 13px; font-weight: 700; color: #166534; display: flex; align-items: center; gap: 6px; } .${uid}-result-domain-warn { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 8px 10px; font-size: 11px; color: #b45309; line-height: 1.5; } /* Toast 提示 */ .${uid}-toast { position: fixed; top: 24px; left: 50%; transform: translateX(-50%) translateY(-10px); background: #0f172a; color: #ffffff; padding: 8px 16px; border-radius: 10px; font-size: 12px; font-weight: 500; box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.3); opacity: 0; pointer-events: none; z-index: 2147483647; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .${uid}-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .${uid}-toast.success { background: #059669; } .${uid}-toast.error { background: #dc2626; } .${uid}-toast.info { background: #0f172a; } `; shadow.appendChild(style); const wrapper = document.createElement("div"); wrapper.id = `${uid}-root`; wrapper.className = `${uid}-root`; wrapper.innerHTML = `
×
`; shadow.appendChild(wrapper); // ----------------------------------------------------------------------- // DOM 元素引用与 UI 控制器 // ----------------------------------------------------------------------- const ball = shadow.getElementById(`${uid}-ball`); const win = shadow.getElementById(`${uid}-window`); const header = shadow.getElementById(`${uid}-header`); const menuMask = shadow.getElementById(`${uid}-menu-mask`); const badge = shadow.getElementById(`${uid}-badge`); const listEl = shadow.getElementById(`${uid}-list`); const saveDialog = shadow.getElementById(`${uid}-save-dialog`); const inputName = shadow.getElementById(`${uid}-input-name`); const previewBox = shadow.getElementById(`${uid}-preview-box`); const toastEl = shadow.getElementById(`${uid}-toast`); // 二维码展示抽屉相关元素 const qrDialog = shadow.getElementById(`${uid}-qr-dialog`); const qrRecName = shadow.getElementById(`${uid}-qr-rec-name`); const qrRecMeta = shadow.getElementById(`${uid}-qr-rec-meta`); const qrCanvasWrap = shadow.getElementById(`${uid}-qr-canvas-wrap`); const qrCanvas = shadow.getElementById(`${uid}-qr-canvas`); const qrOverflowBox = shadow.getElementById(`${uid}-qr-overflow-box`); const qrOverflowDesc = shadow.getElementById(`${uid}-qr-overflow-desc`); const btnStartChunkQr = shadow.getElementById(`${uid}-btn-start-chunk-qr`); const qrChunkPlayer = shadow.getElementById(`${uid}-qr-chunk-player`); const qrChunkBadge = shadow.getElementById(`${uid}-qr-chunk-badge`); const qrChunkIdxText = shadow.getElementById(`${uid}-qr-chunk-idx-text`); const qrChunkBarFill = shadow.getElementById(`${uid}-qr-chunk-bar-fill`); const btnChunkPrev = shadow.getElementById(`${uid}-btn-chunk-prev`); const btnChunkPlayToggle = shadow.getElementById(`${uid}-btn-chunk-play-toggle`); const chunkPlayIcon = shadow.getElementById(`${uid}-chunk-play-icon`); const btnChunkNext = shadow.getElementById(`${uid}-btn-chunk-next`); const btnChunkExit = shadow.getElementById(`${uid}-btn-chunk-exit`); const qrTip = shadow.getElementById(`${uid}-qr-tip`); const btnDownloadQr = shadow.getElementById(`${uid}-btn-download-qr`); const btnCopyQrData = shadow.getElementById(`${uid}-btn-copy-qr-data`); const btnCloseQr = shadow.getElementById(`${uid}-btn-close-qr`); const btnCloseQrBottom = shadow.getElementById(`${uid}-btn-close-qr-bottom`); // 扫码与综合导入抽屉相关元素 const scanDialog = shadow.getElementById(`${uid}-scan-dialog`); const scanViewMain = shadow.getElementById(`${uid}-scan-view-main`); const scanViewResult = shadow.getElementById(`${uid}-scan-view-result`); const cameraVideo = shadow.getElementById(`${uid}-camera-video`); const scanChunkHud = shadow.getElementById(`${uid}-scan-chunk-hud`); const scanChunkProgressText = shadow.getElementById(`${uid}-scan-chunk-progress-text`); const scanChunkBarFill = shadow.getElementById(`${uid}-scan-chunk-bar-fill`); const scanChunkChips = shadow.getElementById(`${uid}-scan-chunk-chips`); const scanFrame = shadow.getElementById(`${uid}-scan-frame`); const cameraPlaceholder = shadow.getElementById(`${uid}-camera-placeholder`); const cameraStatusText = shadow.getElementById(`${uid}-camera-status-text`); const btnStartCamera = shadow.getElementById(`${uid}-btn-start-camera`); const btnChooseImg = shadow.getElementById(`${uid}-btn-choose-img`); const btnChooseJson = shadow.getElementById(`${uid}-btn-choose-json`); const fileScanImage = shadow.getElementById(`${uid}-file-scan-image`); const fileScanJson = shadow.getElementById(`${uid}-file-scan-json`); const scanHiddenCanvas = shadow.getElementById(`${uid}-scan-hidden-canvas`); const btnCloseScan = shadow.getElementById(`${uid}-btn-close-scan`); const resName = shadow.getElementById(`${uid}-res-name`); const resChips = shadow.getElementById(`${uid}-res-chips`); const resMeta = shadow.getElementById(`${uid}-res-meta`); const resDomainWarning = shadow.getElementById(`${uid}-res-domain-warning`); const btnScanRestoreNow = shadow.getElementById(`${uid}-btn-scan-restore-now`); const btnScanSaveDb = shadow.getElementById(`${uid}-btn-scan-save-db`); const btnScanRetry = shadow.getElementById(`${uid}-btn-scan-retry`); let tempCapturedData = null; let toastTimer = null; function showToast(msg, type = "info") { toastEl.textContent = msg; toastEl.className = `${uid}-toast ${type} show`; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.classList.remove("show"); }, 3000); } // ----------------------------------------------------------------------- // 位置持久化 // ----------------------------------------------------------------------- function getUIPositions() { return GM_getValue("lsm_ui_positions", {}); } function saveUIPos() { try { const allPos = getUIPositions(); const hostKey = location.hostname; const cur = allPos[hostKey] || {}; const br = ball.getBoundingClientRect(); if (br.width > 0 && br.height > 0) { cur.ball = { x: Math.round(br.left), y: Math.round(br.top) }; } const wr = win.getBoundingClientRect(); if (wr.width > 0 && wr.height > 0) { cur.win = { x: Math.round(wr.left), y: Math.round(wr.top) }; } allPos[hostKey] = cur; GM_setValue("lsm_ui_positions", allPos); } catch {} } function restoreUIPos() { try { const allPos = getUIPositions(); const cur = allPos[location.hostname]; if (!cur) return; if (cur.ball && typeof cur.ball.x === "number") { const x = Math.max(10, Math.min(window.innerWidth - 60, cur.ball.x)); const y = Math.max(10, Math.min(window.innerHeight - 60, cur.ball.y)); ball.style.left = x + "px"; ball.style.top = y + "px"; ball.style.right = "auto"; ball.style.bottom = "auto"; } if (cur.win && typeof cur.win.x === "number") { const x = Math.max(10, Math.min(window.innerWidth - 490, cur.win.x)); const y = Math.max(10, Math.min(window.innerHeight - 530, cur.win.y)); win.style.left = x + "px"; win.style.top = y + "px"; win.style.right = "auto"; win.style.bottom = "auto"; } } catch {} } // ----------------------------------------------------------------------- // 拖拽逻辑(兼容鼠标与移动端触摸) // ----------------------------------------------------------------------- function makeDraggable(el, handle, onClick) { let dragging = false, moved = false, sx, sy, sLeft, sTop; const onStart = (clientX, clientY, target) => { const tag = target.tagName; if (["BUTTON", "SELECT", "TEXTAREA", "INPUT"].includes(tag)) return false; if (target.closest && target.closest(`.${uid}-ball-close`)) return false; dragging = true; moved = false; const rect = el.getBoundingClientRect(); sx = clientX; sy = clientY; sLeft = rect.left; sTop = rect.top; handle.classList.add("dragging"); return true; }; const onMove = (clientX, clientY) => { if (!dragging) return false; const dx = clientX - sx, dy = clientY - sy; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true; if (!moved) return false; let nl = Math.max(0, Math.min(sLeft + dx, window.innerWidth - el.offsetWidth)); let nt = Math.max(0, Math.min(sTop + dy, window.innerHeight - el.offsetHeight)); el.style.left = nl + "px"; el.style.top = nt + "px"; el.style.right = "auto"; el.style.bottom = "auto"; return true; }; const onEnd = () => { if (!dragging) return; dragging = false; handle.classList.remove("dragging"); if (moved) { if (el === ball) { // 悬浮球松手后智能平滑贴边吸附 const curLeft = el.offsetLeft; const ballWidth = el.offsetWidth || 50; const winWidth = window.innerWidth; const margin = 12; const targetLeft = curLeft + ballWidth / 2 < winWidth / 2 ? margin : winWidth - ballWidth - margin; el.style.left = targetLeft + "px"; const curTop = Math.max(margin, Math.min(el.offsetTop, window.innerHeight - (el.offsetHeight || 50) - margin)); el.style.top = curTop + "px"; setTimeout(saveUIPos, 350); } else { saveUIPos(); } } if (!moved && onClick) onClick(); }; // 鼠标事件 handle.addEventListener("mousedown", (e) => { if (onStart(e.clientX, e.clientY, e.target)) { e.preventDefault(); } }); document.addEventListener("mousemove", (e) => { onMove(e.clientX, e.clientY); }); document.addEventListener("mouseup", () => { onEnd(); }); // 触摸事件 (移动端) handle.addEventListener( "touchstart", (e) => { if (e.touches.length === 1) { const t = e.touches[0]; if (onStart(t.clientX, t.clientY, e.target)) { // 记录触摸启动,避免同时触发默认手势 } } }, { passive: true } ); document.addEventListener( "touchmove", (e) => { if (dragging && e.touches.length === 1) { const t = e.touches[0]; if (onMove(t.clientX, t.clientY)) { if (e.cancelable) e.preventDefault(); e.stopPropagation(); } } }, { passive: false } ); document.addEventListener("touchend", () => { onEnd(); }); document.addEventListener("touchcancel", () => { onEnd(); }); } // ----------------------------------------------------------------------- // 当前生效快照状态管理 // ----------------------------------------------------------------------- function getActiveRecordId() { return GM_getValue("lsm_active_" + location.hostname, ""); } function setActiveRecordId(id) { GM_setValue("lsm_active_" + location.hostname, id || ""); } // ----------------------------------------------------------------------- // UI 窗口交互与记录列表渲染 // ----------------------------------------------------------------------- function refreshList(keyword) { const allRecords = DB.getRecords(); const activeId = getActiveRecordId(); if (allRecords.length > 0) { badge.textContent = allRecords.length > 99 ? "99+" : allRecords.length; badge.style.display = "flex"; } else { badge.style.display = "none"; } const filterText = String(keyword !== undefined ? keyword : (shadow.getElementById(`${uid}-search-input`) ? shadow.getElementById(`${uid}-search-input`).value : "")).trim().toLowerCase(); let records = allRecords; if (filterText) { records = allRecords.filter((r) => { const nameMatch = (r.name || "").toLowerCase().includes(filterText); const timeMatch = formatTime(r.createdAt || r.createTime || r.updatedAt).includes(filterText); const urlMatch = (r.url || "").toLowerCase().includes(filterText); return nameMatch || timeMatch || urlMatch; }); } if (allRecords.length === 0) { listEl.innerHTML = `
当前网站暂无已保存的快照信息
点击上方“一键保存”即可快速备份
`; return; } if (records.length === 0 && filterText) { listEl.innerHTML = `
未找到匹配的快照记录
可尝试更换关键词或点击清空搜索
`; return; } let html = ""; records.forEach((r) => { const cookieCount = r.summary ? r.summary.cookieCount : "?"; const localCount = r.summary ? r.summary.localCount : "?"; const sessionCount = r.summary ? r.summary.sessionCount : "?"; const isActive = r.id === activeId; html += `
${escapeHtml(r.name)} ${isActive ? `✓ 当前生效` : ""} ${formatTime(r.createdAt || r.createTime || r.updatedAt)}
🍪 Cookie: ${cookieCount} 💾 Local: ${localCount} 📦 Session: ${sessionCount} 🔒 ${r.cipherData && r.cipherData.encrypted ? "AES-GCM" : "明文"}
${ r.url ? `
来源: ${escapeHtml(r.url)}
` : "" }
`; }); listEl.innerHTML = html; listEl.querySelectorAll(".btn-restore").forEach((btn) => { btn.addEventListener("click", async () => { const id = btn.getAttribute("data-id"); const originUrl = btn.getAttribute("data-url"); try { showToast("正在解密并恢复快照...", "info"); const sessionData = await DB.getDecryptedSession(id); const res = await SessionManager.restoreSession(sessionData); CryptoEngine.wipeMemory(sessionData); setActiveRecordId(id); refreshList(); showToast(`恢复成功: Cookie ${res.cookieSuccessCount}个, Storage ${res.localCount + res.sessionCount}项`, "success"); setTimeout(() => { const hasSpecificUrl = originUrl && originUrl.startsWith("http") && originUrl !== location.href; const targetJumpUrl = hasSpecificUrl ? originUrl : location.href; if (isAutoReloadEnabled()) { if (hasSpecificUrl) { location.href = targetJumpUrl; } else { location.reload(); } return; } const confirmMsg = hasSpecificUrl ? `快照已恢复!\n检测到该记录保存自页面:\n${originUrl}\n\n是否立即跳转/刷新至该页面以应用快照?` : "快照已恢复!是否立即刷新网页以应用快照?"; if (confirm(confirmMsg)) { if (hasSpecificUrl) { location.href = originUrl; } else { location.reload(); } } }, 300); } catch (e) { showToast(`恢复失败: ${e.message}`, "error"); } }); }); listEl.querySelectorAll(".btn-qrcode").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-id"); const records = DB.getRecords(); const target = records.find((r) => r.id === id); if (!target) { showToast("未找到对应快照记录", "error"); return; } openQrCodeDialog(target); }); }); listEl.querySelectorAll(".btn-copy-single").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-id"); const records = DB.getRecords(); const target = records.find((r) => r.id === id); if (!target) { showToast("未找到对应快照记录", "error"); return; } const exportData = { type: "LSM_SINGLE_EXPORT", version: "1.2.0", domain: target.domain || location.hostname, exportTime: Date.now(), record: target }; try { GM_setClipboard(JSON.stringify(exportData, null, 2), "text"); showToast(`快照「${target.name}」已复制至剪贴板!`, "success"); } catch (err) { showToast(`复制失败: ${err.message}`, "error"); } }); }); listEl.querySelectorAll(".btn-export-single").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-id"); const records = DB.getRecords(); const target = records.find((r) => r.id === id); if (!target) { showToast("未找到对应记录", "error"); return; } const exportData = { type: "LSM_SINGLE_EXPORT", version: "1.2.0", domain: target.domain || location.hostname, exportTime: Date.now(), record: target }; const safeName = (target.name || "session").replace(/[\\/:*?"<>|]/g, "_"); downloadJsonFile(`${location.hostname}_${safeName}.json`, exportData); showToast("单条记录已导出为 JSON", "success"); }); }); listEl.querySelectorAll(".btn-rename").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-id"); const oldName = btn.getAttribute("data-name"); const newName = prompt("请输入新的记录名称:", oldName); if (newName && newName.trim() && newName.trim() !== oldName) { DB.updateRecordName(id, newName.trim()); refreshList(); showToast("已重命名", "success"); } }); }); listEl.querySelectorAll(".btn-delete").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-id"); const name = btn.getAttribute("data-name"); if (confirm(`确定要删除记录 [${name}] 吗?`)) { DB.deleteRecord(id); if (getActiveRecordId() === id) { setActiveRecordId(""); } refreshList(); showToast("已删除记录", "info"); } }); }); } async function openSaveDialog() { inputName.value = getDefaultName(); previewBox.innerHTML = `正在扫描当前快照...`; saveDialog.classList.add("open"); // 自动聚焦并全选输入框 setTimeout(() => { inputName.focus(); inputName.select(); }, 50); try { const data = await SessionManager.captureCurrentSession(); tempCapturedData = data; const sizeKb = data.summary.approxBytes ? (data.summary.approxBytes / 1024).toFixed(1) : "0"; const isTooLarge = data.summary.approxBytes && data.summary.approxBytes > 1.5 * 1024 * 1024; previewBox.innerHTML = `
🍪 Cookie
${data.summary.cookieCount}
💾 Local
${data.summary.localCount}
📦 Session
${data.summary.sessionCount}
预估体积: ${sizeKb} KB ${isTooLarge ? '⚠️ 快照体积偏大 (>1.5MB)' : '✓ 状态良好'}
`; } catch (e) { previewBox.innerHTML = `扫描异常: ${e.message}`; } } function closeSaveDialog() { saveDialog.classList.remove("open"); if (tempCapturedData) { CryptoEngine.wipeMemory(tempCapturedData); tempCapturedData = null; } } // ----------------------------------------------------------------------- // 快照二维码展示抽屉逻辑 & 分片轮播播放器 // ----------------------------------------------------------------------- let currentQrRecord = null; let currentQrJson = ""; let activeChunks = []; let currentChunkIndex = 0; let chunkCarouselTimer = null; let isChunkPlaying = true; /** * 使用 qrcode-generator 渲染二维码至指定的 Canvas 元素 */ function renderQrCodeToCanvas(canvas, text, options = {}) { const { margin = 2, cellSize = 4, colorDark = "#0f172a", colorLight = "#ffffff", errorCorrectionLevel = "M" } = options; const qrFactory = typeof qrcode !== "undefined" ? qrcode : (typeof window !== "undefined" && window.qrcode ? window.qrcode : null); if (!qrFactory) { throw new Error("QR 编码库 (qrcode-generator) 未成功加载,请检查网络或脚本 @require 声明"); } // version 0 表示自动计算最佳版本 (1-40) const qr = qrFactory(0, errorCorrectionLevel); qr.addData(text); qr.make(); const count = qr.getModuleCount(); const targetSize = options.size || 220; const computedCellSize = Math.max(2, Math.floor(targetSize / (count + margin * 2))); const totalSize = (count + margin * 2) * computedCellSize; canvas.width = totalSize; canvas.height = totalSize; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("无法初始化 Canvas 2D 绘图上下文"); // 填充背景色 ctx.fillStyle = colorLight; ctx.fillRect(0, 0, totalSize, totalSize); // 绘制暗色模块 ctx.fillStyle = colorDark; for (let r = 0; r < count; r++) { for (let c = 0; c < count; c++) { if (qr.isDark(r, c)) { ctx.fillRect( (c + margin) * computedCellSize, (r + margin) * computedCellSize, computedCellSize, computedCellSize ); } } } } function getZXingWASMDecoder() { if (typeof ZXingWASM !== "undefined") return ZXingWASM; if (typeof window !== "undefined" && window.ZXingWASM) return window.ZXingWASM; if (typeof globalThis !== "undefined" && globalThis.ZXingWASM) return globalThis.ZXingWASM; return null; } function getJsQRDecoder() { if (typeof jsQR !== "undefined") return jsQR; if (typeof window !== "undefined" && window.jsQR) return window.jsQR; if (typeof globalThis !== "undefined" && globalThis.jsQR) return globalThis.jsQR; return null; } /** * 将较长数据切割成 LSM_CHUNK 分片包 * 将单片切片容量优化为 350 字符左右,纠错等级采用 L 级 * 二维码 Version 降至 5~7 左右(点阵低密度稀疏,单个点大且清晰) * 极大提升摄像头扫码识别率(从 70% 提升至 99%+,毫秒级快速对焦) */ function generateQrChunks(record, jsonStr) { const CHUNK_SIZE = 350; // 每个分片约 350 字符,生成 Version 5~7 低密度稀疏二维码,毫秒级瞬时识别 const totalChunks = Math.max(1, Math.ceil(jsonStr.length / CHUNK_SIZE)); const chunkId = "chk_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 6); const chunks = []; for (let i = 0; i < totalChunks; i++) { const slice = jsonStr.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); const payload = { type: "LSM_CHUNK", id: chunkId, idx: i, total: totalChunks, data: slice, name: record ? record.name : "快照" }; chunks.push(JSON.stringify(payload)); } return chunks; } function renderCurrentChunk() { if (!activeChunks || activeChunks.length === 0) return; const text = activeChunks[currentChunkIndex]; try { renderQrCodeToCanvas(qrCanvas, text, { size: 260, margin: 2, errorCorrectionLevel: "L", colorDark: "#0f172a", colorLight: "#ffffff" }); } catch (err) { console.warn("渲染分片二维码失败:", err); } if (qrChunkIdxText) { qrChunkIdxText.textContent = `分片 ${currentChunkIndex + 1} / ${activeChunks.length}`; } if (qrChunkBarFill) { const pct = Math.round(((currentChunkIndex + 1) / activeChunks.length) * 100); qrChunkBarFill.style.width = `${pct}%`; } } function startChunkTimer() { if (chunkCarouselTimer) clearInterval(chunkCarouselTimer); chunkCarouselTimer = setInterval(() => { if (!activeChunks || activeChunks.length <= 1) return; currentChunkIndex = (currentChunkIndex + 1) % activeChunks.length; renderCurrentChunk(); }, 500); } function startChunkCarousel() { if (!currentQrRecord || !currentQrJson) return; activeChunks = generateQrChunks(currentQrRecord, currentQrJson); currentChunkIndex = 0; isChunkPlaying = true; if (qrOverflowBox) qrOverflowBox.style.display = "none"; if (qrCanvasWrap) qrCanvasWrap.style.display = "flex"; if (qrChunkPlayer) qrChunkPlayer.style.display = "flex"; if (qrTip) { qrTip.style.display = "block"; qrTip.textContent = `共生成 ${activeChunks.length} 张分片二维码,正在以 500ms/帧 循环轮播`; } if (chunkPlayIcon) chunkPlayIcon.textContent = "⏸ 暂停"; renderCurrentChunk(); startChunkTimer(); } function stopChunkCarousel() { if (chunkCarouselTimer) { clearInterval(chunkCarouselTimer); chunkCarouselTimer = null; } activeChunks = []; currentChunkIndex = 0; isChunkPlaying = false; if (qrChunkPlayer) qrChunkPlayer.style.display = "none"; } function toggleChunkPlay() { if (isChunkPlaying) { isChunkPlaying = false; if (chunkCarouselTimer) { clearInterval(chunkCarouselTimer); chunkCarouselTimer = null; } if (chunkPlayIcon) chunkPlayIcon.textContent = "▶ 继续"; } else { isChunkPlaying = true; if (chunkPlayIcon) chunkPlayIcon.textContent = "⏸ 暂停"; startChunkTimer(); } } function prevChunk() { if (!activeChunks || activeChunks.length === 0) return; currentChunkIndex = (currentChunkIndex - 1 + activeChunks.length) % activeChunks.length; renderCurrentChunk(); } function nextChunk() { if (!activeChunks || activeChunks.length === 0) return; currentChunkIndex = (currentChunkIndex + 1) % activeChunks.length; renderCurrentChunk(); } function exitChunkMode() { stopChunkCarousel(); if (qrCanvasWrap) qrCanvasWrap.style.display = "none"; if (qrOverflowBox) qrOverflowBox.style.display = "flex"; if (qrTip) qrTip.style.display = "none"; } function openQrCodeDialog(record) { if (!record) return; stopChunkCarousel(); currentQrRecord = record; qrRecName.textContent = record.name || "未命名快照"; const timeVal = record.createdAt || record.createTime || record.updatedAt || Date.now(); const dateStr = formatTime(timeVal); const cookieCount = record.summary ? record.summary.cookieCount : 0; const localCount = record.summary ? record.summary.localCount : 0; const sessionCount = record.summary ? record.summary.sessionCount : 0; const approxBytes = record.summary && record.summary.approxBytes ? record.summary.approxBytes : 0; const sizeKb = approxBytes ? (approxBytes / 1024).toFixed(1) : "-"; qrRecMeta.innerHTML = ` 创建时间: ${dateStr} · 域名: ${escapeHtml(record.domain || location.hostname)}
凭据概览: Cookie ${cookieCount} 项, Local ${localCount} 项, Session ${sessionCount} 项 (${sizeKb} KB) `; const exportData = { type: "LSM_SINGLE_EXPORT", version: "1.2.0", domain: record.domain || location.hostname, exportTime: Date.now(), record: record }; currentQrJson = JSON.stringify(exportData); const byteLength = new Blob([currentQrJson]).size; const actualKb = (byteLength / 1024).toFixed(1); // 标准 QR Code Level M 最大容量约 2,331 字节 (~2.2 KB) // 如果超过 2,200 字节,直接判定为超限,显示超限提示及分片轮播生成按钮 const QR_MAX_SAFE_BYTES = 2200; if (byteLength > QR_MAX_SAFE_BYTES) { if (qrCanvasWrap) qrCanvasWrap.style.display = "none"; if (qrOverflowBox) qrOverflowBox.style.display = "flex"; if (qrOverflowDesc) { qrOverflowDesc.innerHTML = `当前快照数据体积为 ${actualKb} KB (${byteLength} 字节),已超出标准单张二维码容纳极限(约 2.2 KB)。`; } if (qrTip) qrTip.style.display = "none"; if (btnDownloadQr) { btnDownloadQr.disabled = true; btnDownloadQr.style.opacity = "0.45"; btnDownloadQr.style.cursor = "not-allowed"; btnDownloadQr.title = "快照数据过大,无法生成单张二维码图片"; } } else { let renderSuccess = false; try { renderQrCodeToCanvas(qrCanvas, currentQrJson, { size: 260, margin: 2, errorCorrectionLevel: "L", colorDark: "#0f172a", colorLight: "#ffffff" }); renderSuccess = true; } catch (err) { console.warn("二维码生成失败:", err); } if (renderSuccess) { if (qrCanvasWrap) qrCanvasWrap.style.display = "flex"; if (qrOverflowBox) qrOverflowBox.style.display = "none"; if (qrTip) { qrTip.style.display = "block"; qrTip.textContent = "使用另一台设备或快照助手的「扫码」功能即可一键导入与恢复"; } if (btnDownloadQr) { btnDownloadQr.disabled = false; btnDownloadQr.style.opacity = "1"; btnDownloadQr.style.cursor = "pointer"; btnDownloadQr.title = "下载二维码 PNG 图片"; } } else { if (qrCanvasWrap) qrCanvasWrap.style.display = "none"; if (qrOverflowBox) qrOverflowBox.style.display = "flex"; if (qrOverflowDesc) { qrOverflowDesc.innerHTML = `当前快照数据体积为 ${actualKb} KB,超出二维码容量限制。`; } if (qrTip) qrTip.style.display = "none"; if (btnDownloadQr) { btnDownloadQr.disabled = true; btnDownloadQr.style.opacity = "0.45"; btnDownloadQr.style.cursor = "not-allowed"; btnDownloadQr.title = "快照数据过大,无法生成二维码图片"; } } } qrDialog.classList.add("open"); } function closeQrCodeDialog() { stopChunkCarousel(); qrDialog.classList.remove("open"); currentQrRecord = null; currentQrJson = ""; if (btnDownloadQr) { btnDownloadQr.disabled = false; btnDownloadQr.style.opacity = "1"; btnDownloadQr.style.cursor = "pointer"; } } // ----------------------------------------------------------------------- // 扫码与综合导入抽屉逻辑 (摄像头 / 图片二维码 / JSON 文件 / 乱序分片接收 / 多线程识别) // ----------------------------------------------------------------------- let cameraStream = null; let cameraAnimId = null; let currentScannedSnapshot = null; const chunkScanPool = new Map(); // 多线程扫码识别引擎 let nativeBarcodeDetector = null; let isDetectorBusy = false; let qrWorker = null; let isWorkerBusy = false; let lastDecodedCode = null; let lastDecodedTime = 0; let lastScanFrameTime = 0; const SCAN_FRAME_INTERVAL = 60; // 采样周期约 60ms (~16 FPS),从根本上消除 60 FPS 密集采样导致的 GC 内存堆积与周期性停顿 function getNativeBarcodeDetector() { if (nativeBarcodeDetector !== null) return nativeBarcodeDetector; if (typeof window !== "undefined" && "BarcodeDetector" in window) { try { nativeBarcodeDetector = new window.BarcodeDetector({ formats: ["qr_code"] }); } catch (e) { nativeBarcodeDetector = false; } } else { nativeBarcodeDetector = false; } return nativeBarcodeDetector; } function getQrWorker() { if (qrWorker) return qrWorker; try { const workerScript = ` let hasZXing = false; let hasJsQR = false; try { importScripts('https://cdn.jsdelivr.net/npm/zxing-wasm@1.2.14/dist/iife/reader/index.js'); hasZXing = typeof ZXingWASM !== 'undefined' && typeof ZXingWASM.readBarcodes === 'function'; } catch (e) {} try { importScripts('https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'); hasJsQR = typeof jsQR === 'function'; } catch (e) {} self.onmessage = async function(e) { const { buffer, width, height } = e.data; if (!buffer || !width || !height) { self.postMessage({ result: null }); return; } const clamped = new Uint8ClampedArray(buffer); // 1. 默认优先使用 WASM 解码引擎 (ZXing C++ WebAssembly) const zxingInst = (typeof ZXingWASM !== 'undefined' && typeof ZXingWASM.readBarcodes === 'function') ? ZXingWASM : (self.ZXingWASM || null); if (zxingInst) { try { const barcodes = await zxingInst.readBarcodes( { data: clamped, width: width, height: height }, { formats: ['QRCode'], tryHarder: false } ); if (barcodes && barcodes.length > 0 && barcodes[0].text) { self.postMessage({ result: barcodes[0].text, engine: 'wasm' }); return; } } catch (err) { // WASM 解码异常时平滑降级 } } // 2. 兼容降级方案:jsQR 解码引擎 const jsqrFn = (typeof jsQR === 'function') ? jsQR : (self.jsQR || null); if (jsqrFn) { try { const code = jsqrFn(clamped, width, height, { inversionAttempts: 'dontInvert' }); if (code && code.data) { self.postMessage({ result: code.data, engine: 'jsqr' }); return; } } catch (err) {} } self.postMessage({ result: null }); }; `; const blob = new Blob([workerScript], { type: "application/javascript" }); const workerUrl = URL.createObjectURL(blob); qrWorker = new Worker(workerUrl); qrWorker.onmessage = (e) => { isWorkerBusy = false; const { result } = e.data; if (result) { processDecodedCode(result); } }; qrWorker.onerror = (err) => { console.warn("QR Web Worker 运行异常:", err); isWorkerBusy = false; }; } catch (e) { console.warn("创建 QR Web Worker 失败 (可能受 CSP 限制):", e); qrWorker = null; } return qrWorker; } function processDecodedCode(rawStr) { if (!rawStr || typeof rawStr !== "string") return; const now = Date.now(); // 同一数据 200ms 内防重触发 if (rawStr === lastDecodedCode && now - lastDecodedTime < 200) { return; } lastDecodedCode = rawStr; lastDecodedTime = now; let chunkObj = null; try { const parsed = JSON.parse(rawStr.trim()); if (parsed && parsed.type === "LSM_CHUNK" && parsed.id && typeof parsed.idx === "number" && parsed.total && typeof parsed.data === "string") { chunkObj = parsed; } } catch (e) {} if (chunkObj) { handleIncomingChunk(chunkObj); // 分片模式:不停止相机,持续在后台线程扫码直到全部集齐 } else { // 普通完整二维码:直接停止扫描并解析 stopCameraScan(); handleQrDecodedString(rawStr); } } function updateScanChunkHud(entry) { if (!scanChunkProgressText || !scanChunkBarFill || !scanChunkChips) return; const pct = Math.round((entry.receivedCount / entry.total) * 100); scanChunkProgressText.textContent = `${entry.receivedCount} / ${entry.total} (${pct}%)`; scanChunkBarFill.style.width = `${pct}%`; let dotsHtml = ""; for (let i = 0; i < entry.total; i++) { const isReceived = entry.chunks[i] !== null; dotsHtml += `${i + 1}`; } scanChunkChips.innerHTML = dotsHtml; } function handleIncomingChunk(chunkObj) { const { id, idx, total, data, name } = chunkObj; if (!id || typeof idx !== "number" || !total || typeof data !== "string") return; let entry = chunkScanPool.get(id); if (!entry) { entry = { id: id, total: total, name: name || "分片快照", chunks: new Array(total).fill(null), receivedCount: 0, createdAt: Date.now() }; chunkScanPool.set(id, entry); } if (scanChunkHud) scanChunkHud.style.display = "flex"; if (entry.chunks[idx] === null) { entry.chunks[idx] = data; entry.receivedCount++; updateScanChunkHud(entry); } if (entry.receivedCount === entry.total) { stopCameraScan(); if (scanChunkHud) scanChunkHud.style.display = "none"; const fullJsonStr = entry.chunks.join(""); chunkScanPool.delete(id); showToast(`所有分片 (${total}/${total}) 已完整接收,正在解析快照...`, "success"); handleQrDecodedString(fullJsonStr); } } function stopCameraScan() { if (cameraAnimId) { cancelAnimationFrame(cameraAnimId); cameraAnimId = null; } if (cameraStream) { try { cameraStream.getTracks().forEach((track) => track.stop()); } catch (e) {} cameraStream = null; } if (cameraVideo) { cameraVideo.srcObject = null; } isDetectorBusy = false; isWorkerBusy = false; lastDecodedCode = null; lastDecodedTime = 0; lastScanFrameTime = 0; if (scanFrame) scanFrame.style.display = "none"; if (cameraPlaceholder) cameraPlaceholder.style.display = "flex"; if (btnStartCamera) btnStartCamera.style.display = "inline-flex"; if (cameraStatusText) cameraStatusText.textContent = "未开启摄像头实时扫码"; } async function startCameraScan() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { showToast("当前环境或浏览器不支持访问摄像头", "error"); return; } try { cameraStatusText.textContent = "正在请求摄像头权限..."; btnStartCamera.style.display = "none"; const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } } }); cameraStream = stream; cameraVideo.srcObject = stream; cameraVideo.setAttribute("playsinline", "true"); await cameraVideo.play(); cameraPlaceholder.style.display = "none"; scanFrame.style.display = "block"; cameraStatusText.textContent = "正在实时扫码中,请对准二维码..."; scanCameraLoop(); } catch (err) { console.error("启动摄像头失败:", err); stopCameraScan(); cameraStatusText.textContent = "无法访问摄像头: " + (err.message || "用户拒绝或设备不可用"); showToast("无法访问摄像头: " + (err.message || "权限被拒绝"), "error"); } } function scanCameraLoop() { if (!cameraStream) return; const now = performance.now(); // 节流采样控制:限制每秒 16~20 FPS (约 60ms 采样一次),从根本上消除 60 FPS 频繁调用 getImageData 造成的 V8 Major GC 停顿与卡顿 if (cameraVideo.readyState === cameraVideo.HAVE_ENOUGH_DATA && (now - lastScanFrameTime >= SCAN_FRAME_INTERVAL)) { lastScanFrameTime = now; // 优先路径 1:独立 Web Worker 后台异步解码线程 (WASM 优先 -> jsQR 降级,Transferable ArrayBuffer 零拷贝) const worker = getQrWorker(); if (worker) { if (!isWorkerBusy) { isWorkerBusy = true; const vw = cameraVideo.videoWidth || 640; const vh = cameraVideo.videoHeight || 480; // 优化处理分辨率(480px 对 Version 5~7 稀疏二维码具备 99.9% 识别率,且像素数据体积降低 60%) const targetWidth = Math.min(480, vw); const targetHeight = Math.round((vh / vw) * targetWidth); const canvas = scanHiddenCanvas || document.createElement("canvas"); if (canvas.width !== targetWidth || canvas.height !== targetHeight) { canvas.width = targetWidth; canvas.height = targetHeight; } const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(cameraVideo, 0, 0, targetWidth, targetHeight); const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); worker.postMessage( { buffer: imageData.data.buffer, width: targetWidth, height: targetHeight }, [imageData.data.buffer] ); } } else { // 降级路径 2:主线程直接解码(默认 WASM 优先 -> 不兼容再降级 jsQR -> 兜底 BarcodeDetector) if (!isWorkerBusy) { isWorkerBusy = true; (async () => { try { if (cameraStream && cameraVideo.readyState === cameraVideo.HAVE_ENOUGH_DATA) { const vw = cameraVideo.videoWidth || 640; const vh = cameraVideo.videoHeight || 480; const targetWidth = Math.min(480, vw); const targetHeight = Math.round((vh / vw) * targetWidth); const canvas = scanHiddenCanvas || document.createElement("canvas"); if (canvas.width !== targetWidth || canvas.height !== targetHeight) { canvas.width = targetWidth; canvas.height = targetHeight; } const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(cameraVideo, 0, 0, targetWidth, targetHeight); const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); let decodedText = null; // 1. 默认 WASM 优先 const zxing = getZXingWASMDecoder(); if (zxing && typeof zxing.readBarcodes === "function") { try { const barcodes = await zxing.readBarcodes(imageData, { formats: ["QRCode"], tryHarder: false }); if (barcodes && barcodes.length > 0 && barcodes[0].text) { decodedText = barcodes[0].text; } } catch (e) {} } // 2. 不兼容再使用 jsQR if (!decodedText) { const jsqr = getJsQRDecoder(); if (jsqr) { try { const code = jsqr(imageData.data, imageData.width, imageData.height, { inversionAttempts: "dontInvert" }); if (code && code.data) { decodedText = code.data; } } catch (e) {} } } // 3. 原生 BarcodeDetector 兜底 if (!decodedText) { const detector = getNativeBarcodeDetector(); if (detector) { try { const barcodes = await detector.detect(canvas); if (barcodes && barcodes.length > 0 && barcodes[0].rawValue) { decodedText = barcodes[0].rawValue; } } catch (e) {} } } if (decodedText) { processDecodedCode(decodedText); } } } finally { isWorkerBusy = false; } })(); } } } cameraAnimId = requestAnimationFrame(scanCameraLoop); } function resetScanModal() { stopCameraScan(); chunkScanPool.clear(); if (scanChunkHud) scanChunkHud.style.display = "none"; if (scanChunkProgressText) scanChunkProgressText.textContent = "0 / 0 (0%)"; if (scanChunkBarFill) scanChunkBarFill.style.width = "0%"; if (scanChunkChips) scanChunkChips.innerHTML = ""; currentScannedSnapshot = null; scanViewMain.style.display = "flex"; scanViewResult.style.display = "none"; if (fileScanImage) fileScanImage.value = ""; if (fileScanJson) fileScanJson.value = ""; } function openScanImportModal() { resetScanModal(); scanDialog.classList.add("open"); } function closeScanDialog() { stopCameraScan(); chunkScanPool.clear(); if (scanChunkHud) scanChunkHud.style.display = "none"; scanDialog.classList.remove("open"); currentScannedSnapshot = null; } function handleQrDecodedString(rawStr) { if (!rawStr || typeof rawStr !== "string") { showToast("识别到的内容为空", "error"); return; } let json; try { json = JSON.parse(rawStr.trim()); } catch { showToast("二维码解析成功,但内容不是合法的 JSON 快照数据", "error"); return; } if (json && json.type === "LSM_CHUNK" && json.id && typeof json.idx === "number" && json.total && typeof json.data === "string") { handleIncomingChunk(json); const entry = chunkScanPool.get(json.id); if (entry && entry.receivedCount < entry.total) { showToast(`已暂存分片 ${json.idx + 1}/${json.total},请继续扫描或导入剩余分片`, "info"); } return; } handleParsedSnapshot(json, "扫码导入快照"); } function handleImageQrFile(file) { if (!file) return; showToast("正在识别图片中的二维码...", "info"); const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = async () => { const canvas = scanHiddenCanvas || document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); let decodedText = null; // 1. 默认优先使用 WASM 解码引擎 (开启 tryHarder: true 提升离线图片检出率) const zxing = getZXingWASMDecoder(); if (zxing && typeof zxing.readBarcodes === "function") { try { const barcodes = await zxing.readBarcodes(imageData, { formats: ["QRCode"], tryHarder: true }); if (barcodes && barcodes.length > 0 && barcodes[0].text) { decodedText = barcodes[0].text; } } catch (err) { console.warn("WASM 图片解码异常,准备降级至 jsQR:", err); } } // 2. 降级方案:使用 jsQR 引擎 if (!decodedText) { const decoder = getJsQRDecoder(); if (decoder) { try { const code = decoder(imageData.data, imageData.width, imageData.height, { inversionAttempts: "attemptBoth" }); if (code && code.data) { decodedText = code.data; } } catch (err) { console.warn("jsQR 图片解码异常:", err); } } } // 3. 原生 BarcodeDetector 兜底 if (!decodedText) { const detector = getNativeBarcodeDetector(); if (detector) { try { const barcodes = await detector.detect(canvas); if (barcodes && barcodes.length > 0 && barcodes[0].rawValue) { decodedText = barcodes[0].rawValue; } } catch (e) {} } } if (decodedText) { handleQrDecodedString(decodedText); } else { showToast("未能从该图片中识别到有效二维码,请确认图片清晰", "error"); } }; img.onerror = () => showToast("图片加载失败", "error"); img.src = e.target.result; }; reader.onerror = () => showToast("读取文件失败", "error"); reader.readAsDataURL(file); } function handleParsedSnapshot(json, defaultName) { let targetRecord = null; let recordsToImport = []; let sourceDomain = json.domain || ""; let name = defaultName || "导入快照"; if (json.type === "LSM_SINGLE_EXPORT" && json.record) { targetRecord = json.record; recordsToImport = [json.record]; sourceDomain = json.record.domain || sourceDomain; name = json.record.name || name; } else if (json.type === "LSM_BATCH_EXPORT" && Array.isArray(json.records) && json.records.length > 0) { targetRecord = json.records[0]; recordsToImport = json.records; sourceDomain = json.domain || targetRecord.domain || sourceDomain; name = targetRecord.name ? `${targetRecord.name} (等共 ${json.records.length} 条)` : name; } else if (json.name && json.cipherData) { targetRecord = json; recordsToImport = [json]; sourceDomain = json.domain || sourceDomain; name = json.name; } else if (Array.isArray(json) && json.length > 0 && json[0].cipherData) { targetRecord = json[0]; recordsToImport = json; sourceDomain = targetRecord.domain || sourceDomain; name = targetRecord.name ? `${targetRecord.name} (共 ${json.length} 条)` : name; } else if (json.cookies || json.localStorage) { targetRecord = { id: "rec_" + Date.now(), name: name, domain: json.domain || location.hostname, url: json.url || location.href, createdAt: Date.now(), createTime: Date.now(), summary: json.summary || { cookieCount: Array.isArray(json.cookies) ? json.cookies.length : 0, localCount: json.localStorage ? Object.keys(json.localStorage).length : 0, sessionCount: json.sessionStorage ? Object.keys(json.sessionStorage).length : 0 }, cipherData: { encrypted: false, payload: JSON.stringify(json) } }; recordsToImport = [targetRecord]; sourceDomain = targetRecord.domain; } else { showToast("无法识别的快照数据格式", "error"); return; } currentScannedSnapshot = { json: json, targetRecord: targetRecord, recordsToImport: recordsToImport, sourceDomain: sourceDomain, name: name }; resName.textContent = name; const summary = targetRecord.summary || {}; const cookieCount = typeof summary.cookieCount === "number" ? summary.cookieCount : "-"; const localCount = typeof summary.localCount === "number" ? summary.localCount : "-"; const sessionCount = typeof summary.sessionCount === "number" ? summary.sessionCount : "-"; const isEnc = targetRecord.cipherData ? (targetRecord.cipherData.encrypted ? "AES-GCM" : "明文") : "未知"; resChips.innerHTML = ` 🍪 Cookie: ${cookieCount} 💾 Local: ${localCount} 📦 Session: ${sessionCount} 🔒 ${isEnc} `; const createTimeVal = targetRecord.createdAt || targetRecord.createTime || (json && (json.exportTime || json.createdAt || json.createTime)); const createTimeStr = createTimeVal ? formatTime(createTimeVal) : "未知时间"; resMeta.textContent = `来源域名: ${sourceDomain || location.hostname} · 创建于 ${createTimeStr}`; if (sourceDomain && sourceDomain !== location.hostname) { resDomainWarning.style.display = "block"; resDomainWarning.innerHTML = `⚠️ 域名不匹配提醒:该快照保存自 ${escapeHtml(sourceDomain)},而当前所在网页为 ${escapeHtml(location.hostname)}。直接恢复可能会因域名隔离导致部分 Cookie 无法生效。`; } else { resDomainWarning.style.display = "none"; } scanViewMain.style.display = "none"; scanViewResult.style.display = "flex"; showToast("快照解析成功,请选择操作", "success"); } function openWindow() { if (ball) { ball.style.display = "none"; ball.classList.add("hidden"); } if (win) { win.style.display = "flex"; win.classList.remove("hidden"); refreshList(); if (typeof updateGistStatus === "function") { updateGistStatus(); } if ( typeof GistSyncManager !== "undefined" && GistSyncManager.isEnabled() && GistSyncManager.getToken() && Date.now() - (GistSyncManager.lastSyncTime || 0) > 60000 ) { GistSyncManager.sync({ silent: true }).catch(() => {}); } } } function closeWindow() { if (win) { win.style.display = "none"; win.classList.add("hidden"); } if (ball) { ball.style.display = "flex"; ball.classList.remove("hidden"); } closeSaveDialog(); closeQrCodeDialog(); closeScanDialog(); } // ----------------------------------------------------------------------- // 事件绑定 // ----------------------------------------------------------------------- makeDraggable(ball, ball, () => openWindow()); makeDraggable(win, header); // 阻止管理窗口与抽屉弹窗内滚动穿透到宿主网页(PC 滚轮 + 移动端触摸双重拦截) bindScrollLock(win, `.${uid}-content, .${uid}-save-dialog, .${uid}-qr-dialog, .${uid}-scan-dialog`); bindScrollLock(menuMask, null); // 悬浮球右上角菜单 shadow.querySelector(`.${uid}-ball-close`).addEventListener("click", (e) => { e.stopPropagation(); menuMask.classList.remove("hidden"); }); menuMask.addEventListener("click", (e) => { if (e.target === menuMask) menuMask.classList.add("hidden"); }); menuMask.querySelectorAll("button").forEach((btn) => { btn.addEventListener("click", () => { menuMask.classList.add("hidden"); const action = btn.getAttribute("data-a"); if (action === "open") { openWindow(); } else if (action === "save") { openWindow(); openSaveDialog(); } else if (action === "temp") { ball.style.display = "none"; win.style.display = "none"; win.classList.add("hidden"); } else if (action === "forever") { removeHostFromShowList(); ball.style.display = "none"; win.style.display = "none"; win.classList.add("hidden"); } }); }); // 窗口顶部操作 shadow.getElementById(`${uid}-btn-close`).addEventListener("click", () => closeWindow()); // 工具栏操作 shadow.getElementById(`${uid}-btn-save-current`).addEventListener("click", () => openSaveDialog()); shadow.getElementById(`${uid}-btn-clear-current`).addEventListener("click", async () => { if (confirm(`确定要清空当前网站 (${location.hostname}) 的所有快照数据(Cookie、LocalStorage、SessionStorage)吗?\n清空后将处于未快照。`)) { showToast("正在清空当前网站数据...", "info"); try { const res = await SessionManager.clearAllData(); setActiveRecordId(""); refreshList(); showToast(`已清空 Cookie ${res.cookieCount}个, Storage ${res.storageCount}项`, "success"); setTimeout(() => { if (confirm("当前网站快照数据已彻底清空!是否立即刷新网页以生效?")) { location.reload(); } }, 300); } catch (e) { showToast(`清空失败: ${e.message}`, "error"); } } }); shadow.getElementById(`${uid}-btn-reload`).addEventListener("click", () => location.reload()); // 搜索过滤框事件绑定 const searchInput = shadow.getElementById(`${uid}-search-input`); const searchClearBtn = shadow.getElementById(`${uid}-search-clear`); searchInput.addEventListener("input", () => { const val = searchInput.value; searchClearBtn.classList.toggle("hidden", !val); refreshList(val); }); searchClearBtn.addEventListener("click", () => { searchInput.value = ""; searchClearBtn.classList.add("hidden"); searchInput.focus(); refreshList(""); }); // 更多操作下拉菜单 const btnMore = shadow.getElementById(`${uid}-btn-more`); const dropdownMenu = shadow.getElementById(`${uid}-dropdown-menu`); btnMore.addEventListener("click", (e) => { e.stopPropagation(); dropdownMenu.classList.toggle("hidden"); }); // 点击外部收起下拉菜单 wrapper.addEventListener("click", (e) => { if (!dropdownMenu.classList.contains("hidden") && !btnMore.contains(e.target)) { dropdownMenu.classList.add("hidden"); } }); // Gist 云同步状态更新 function updateGistStatus(status) { const gistDot = shadow.getElementById(`${uid}-dot-gist`); const gistText = shadow.getElementById(`${uid}-text-gist`); const gistIcon = shadow.getElementById(`${uid}-gist-sync-icon`); if (!gistDot || !gistText) return; const isEn = GistSyncManager.isEnabled(); const token = GistSyncManager.getToken(); if (!isEn || !token) { gistDot.className = `${uid}-dot ${uid}-dot-amber`; gistText.textContent = "Gist: 未配置 (点击设置)"; if (gistIcon) gistIcon.classList.remove("spinning"); return; } if (status === "syncing") { gistDot.className = `${uid}-dot ${uid}-dot-blue`; gistText.textContent = "Gist: 正在同步..."; if (gistIcon) gistIcon.classList.add("spinning"); } else if (status === "error") { gistDot.className = `${uid}-dot ${uid}-dot-red`; gistText.textContent = "Gist: 同步失败 (点击重试)"; if (gistIcon) gistIcon.classList.remove("spinning"); } else { gistDot.className = `${uid}-dot ${uid}-dot-green`; const timeStr = GistSyncManager.lastSyncTime ? formatTime(GistSyncManager.lastSyncTime).slice(11, 19) : "就绪"; gistText.textContent = `Gist: 已同步 (${timeStr})`; if (gistIcon) gistIcon.classList.remove("spinning"); } } // 1. 标题栏右上角按钮:点击直接打开「Gist 云同步设置」弹窗 const btnCloudSync = shadow.getElementById(`${uid}-btn-cloud-sync`); if (btnCloudSync) { btnCloudSync.addEventListener("click", () => { showGistSyncDialog(); }); } // 2. 状态条 Gist 项:点击触发「立即手动同步」 const statusGist = shadow.getElementById(`${uid}-status-gist`); if (statusGist) { statusGist.addEventListener("click", () => { if (!GistSyncManager.isEnabled() || !GistSyncManager.getToken()) { showGistSyncDialog(); } else { GistSyncManager.sync({ silent: false }); } }); } // 下拉菜单中的 Gist 操作 const btnSyncNow = shadow.getElementById(`${uid}-btn-sync-now`); if (btnSyncNow) { btnSyncNow.addEventListener("click", () => { dropdownMenu.classList.add("hidden"); if (!GistSyncManager.isEnabled() || !GistSyncManager.getToken()) { showGistSyncDialog(); } else { GistSyncManager.sync({ silent: false }); } }); } const btnSyncSettings = shadow.getElementById(`${uid}-btn-sync-settings`); if (btnSyncSettings) { btnSyncSettings.addEventListener("click", () => { dropdownMenu.classList.add("hidden"); showGistSyncDialog(); }); } // 批量导出 shadow.getElementById(`${uid}-btn-export-all`).addEventListener("click", () => { dropdownMenu.classList.add("hidden"); const records = DB.getRecords(); if (!records.length) { showToast("当前网站暂无可导出的记录", "info"); return; } const exportData = { type: "LSM_BATCH_EXPORT", version: "1.0", domain: location.hostname, exportTime: Date.now(), count: records.length, records: records }; downloadJsonFile(`${location.hostname}_all_sessions.json`, exportData); showToast(`成功导出 ${records.length} 条记录`, "success"); }); // 批量导入 const fileImportInput = shadow.getElementById(`${uid}-file-import`); shadow.getElementById(`${uid}-btn-import-all`).addEventListener("click", () => { dropdownMenu.classList.add("hidden"); fileImportInput.value = ""; fileImportInput.click(); }); fileImportInput.addEventListener("change", async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; try { showToast("正在读取导入文件...", "info"); const json = await readFileAsJson(file); let toImport = []; let fileDomain = json.domain || ""; if (json.type === "LSM_BATCH_EXPORT" && Array.isArray(json.records)) { toImport = json.records; } else if (json.type === "LSM_SINGLE_EXPORT" && json.record) { toImport = [json.record]; fileDomain = json.record.domain || fileDomain; } else if (Array.isArray(json)) { toImport = json; } else if (json.name && json.cipherData) { toImport = [json]; fileDomain = json.domain || fileDomain; } else { throw new Error("无法识别的备份文件结构"); } // 检测跨域名 if (fileDomain && fileDomain !== location.hostname) { const proceed = confirm( `⚠️ 域名不匹配提示:\n\n该备份文件来源网站为:[${fileDomain}]\n而当前所在网站为:[${location.hostname}]\n\n跨网站导入可能导致快照无法生效或无法直接解密。是否仍要继续导入到当前网站?` ); if (!proceed) { showToast("已取消导入", "info"); return; } } const { count, skipped } = DB.importRecords(toImport); refreshList(); if (count === 0 && skipped > 0) { showToast(`检测到 ${skipped} 条快照数据已存在,已全部自动跳过`, "info"); } else if (skipped > 0) { showToast(`成功导入 ${count} 条快照,已自动跳过 ${skipped} 条重复记录`, "success"); } else { showToast(`成功导入 ${count} 条快照记录!`, "success"); } } catch (err) { showToast(`导入失败: ${err.message}`, "error"); } }); // ----------------------------------------------------------------------- // 快照通用直接恢复引擎(支持文件直接恢复、剪贴板恢复、Ctrl+V 粘贴触发) // ----------------------------------------------------------------------- async function restoreSnapshotData(json, defaultName) { let targetCipher = null; let targetUrl = ""; let targetName = defaultName || ""; let fileDomain = json.domain || ""; if (json.type === "LSM_SINGLE_EXPORT" && json.record) { targetCipher = json.record.cipherData; targetUrl = json.record.url || ""; targetName = json.record.name || targetName; fileDomain = json.record.domain || fileDomain; } else if (json.type === "LSM_BATCH_EXPORT" && Array.isArray(json.records) && json.records.length > 0) { targetCipher = json.records[0].cipherData; targetUrl = json.records[0].url || ""; targetName = json.records[0].name || targetName; fileDomain = json.records[0].domain || fileDomain; } else if (json.name && json.cipherData) { targetCipher = json.cipherData; targetUrl = json.url || ""; targetName = json.name || targetName; fileDomain = json.domain || fileDomain; } else if (json.cookies || json.localStorage) { targetCipher = { encrypted: false, payload: JSON.stringify(json) }; targetUrl = json.url || ""; fileDomain = json.domain || fileDomain; } else { throw new Error("无法识别的快照数据格式"); } if (!targetCipher) throw new Error("快照数据中缺少有效凭据"); // 检测跨域名 if (fileDomain && fileDomain !== location.hostname) { const proceed = confirm( `⚠️ 域名不匹配提示:\n\n该快照来源网站为:[${fileDomain}]\n而当前所在网站为:[${location.hostname}]\n\n跨网站恢复可能导致当前网站无法识别该快照。是否仍要继续恢复?` ); if (!proceed) { showToast("已取消恢复", "info"); return; } } showToast("正在解密并恢复快照...", "info"); const recDomain = fileDomain || location.hostname; const sessionData = await CryptoEngine.decrypt(targetCipher, recDomain); const res = await SessionManager.restoreSession(sessionData); CryptoEngine.wipeMemory(sessionData); setActiveRecordId(""); refreshList(); showToast(`恢复成功: Cookie ${res.cookieSuccessCount}个, Storage ${res.localCount + res.sessionCount}项`, "success"); setTimeout(() => { const hasSpecificUrl = targetUrl && targetUrl.startsWith("http") && targetUrl !== location.href; const targetJumpUrl = hasSpecificUrl ? targetUrl : location.href; if (isAutoReloadEnabled()) { if (hasSpecificUrl) { location.href = targetJumpUrl; } else { location.reload(); } return; } const confirmMsg = hasSpecificUrl ? `快照 [${targetName || "已选择"}] 已恢复!\n检测到该记录保存自页面:\n${targetUrl}\n\n是否立即跳转/刷新至该页面以应用快照?` : `快照 [${targetName || "已选择"}] 已恢复!是否立即刷新网页以应用快照?`; if (confirm(confirmMsg)) { if (hasSpecificUrl) { location.href = targetUrl; } else { location.reload(); } } }, 300); } // 从文件恢复(不导入) const fileRestoreDirectInput = shadow.getElementById(`${uid}-file-restore-direct`); shadow.getElementById(`${uid}-btn-restore-file`).addEventListener("click", () => { dropdownMenu.classList.add("hidden"); fileRestoreDirectInput.value = ""; fileRestoreDirectInput.click(); }); fileRestoreDirectInput.addEventListener("change", async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; try { showToast("正在解析备份文件...", "info"); const json = await readFileAsJson(file); await restoreSnapshotData(json, file.name); } catch (err) { showToast(`恢复失败: ${err.message}`, "error"); } }); // 从剪贴板恢复(免文件) shadow.getElementById(`${uid}-btn-restore-clipboard`).addEventListener("click", async () => { dropdownMenu.classList.add("hidden"); try { let text = ""; try { text = await navigator.clipboard.readText(); } catch { text = prompt("请在此粘贴快照 JSON 数据:") || ""; } if (!text || !text.trim()) { showToast("未获取到剪贴板文本,请先复制快照数据", "info"); return; } let json; try { json = JSON.parse(text.trim()); } catch { throw new Error("剪贴板内容不是合法的 JSON 快照数据"); } await restoreSnapshotData(json, "剪贴板快照"); } catch (err) { showToast(`剪贴板恢复失败: ${err.message}`, "error"); } }); // 监听全局 Ctrl+V / Paste 事件,仅在管理窗口打开时响应,检测到快照 JSON 直接触发恢复 window.addEventListener( "paste", async (e) => { // 只有在管理窗口处于激活显示状态时才响应快捷快照粘贴,避免干扰正常浏览网页时的日常粘贴 if (win.classList.contains("hidden") || win.style.display === "none") return; const activeEl = shadow.activeElement || document.activeElement; // 如果焦点在快照重命名/自定义命名的 input 输入框内,且粘贴的不是包含快照特征的 JSON,则正常粘贴 const isRenameInput = activeEl && activeEl.id === `${uid}-input-name`; let text = ""; if (e.clipboardData) { text = e.clipboardData.getData("text"); } if (!text || typeof text !== "string") return; const trimmed = text.trim(); if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return; try { const json = JSON.parse(trimmed); const isSnapshot = (json.type === "LSM_SINGLE_EXPORT" && json.record) || (json.type === "LSM_BATCH_EXPORT" && Array.isArray(json.records) && json.records.length > 0) || (json.name && json.cipherData) || (json.cipherData && json.cipherData.ciphertext) || (json.summary && (json.cookies || json.localStorage)); if (!isSnapshot) return; // 如果是在输入框中,且用户粘贴的是合法的快照,才拦截默认粘贴 e.preventDefault(); e.stopPropagation(); const targetName = (json.record && json.record.name) || json.name || (json.records && json.records[0] && json.records[0].name) || "剪贴板快照"; const originDomain = json.domain || (json.record && json.record.domain) || ""; const domainTip = originDomain && originDomain !== location.hostname ? `\n(⚠️ 来源域名: ${originDomain},当前域名: ${location.hostname})` : ""; const confirmRestore = confirm( `📋 检测到您在管理面板中粘贴了快照数据 [${targetName}]!${domainTip}\n\n是否立即解密并恢复此快照到当前网站?` ); if (confirmRestore) { await restoreSnapshotData(json, targetName); } } catch (err) { // 忽略非快照数据解析错误 } }, true ); // 保存弹窗操作 const btnConfirmSave = shadow.getElementById(`${uid}-btn-confirm-save`); shadow.getElementById(`${uid}-btn-cancel-save`).addEventListener("click", () => closeSaveDialog()); btnConfirmSave.addEventListener("click", async () => { const name = inputName.value.trim() || getDefaultName(); if (!tempCapturedData) { showToast("未检测到有效数据,请重新打开", "error"); return; } try { const newRec = await DB.addRecord(name, tempCapturedData); tempCapturedData = null; if (newRec && newRec.id) { setActiveRecordId(newRec.id); } closeSaveDialog(); refreshList(); showToast("快照信息已安全加密保存!", "success"); } catch (e) { showToast(`保存失败: ${e.message}`, "error"); } }); // 保存抽屉按键与全局弹窗 Escape 事件 window.addEventListener( "keydown", (e) => { // 仅在主管理窗口可见时响应 if (win.classList.contains("hidden") || win.style.display === "none") return; if (e.key === "Escape") { if (qrDialog && qrDialog.classList.contains("open")) { e.preventDefault(); e.stopPropagation(); closeQrCodeDialog(); return; } if (scanDialog && scanDialog.classList.contains("open")) { e.preventDefault(); e.stopPropagation(); closeScanDialog(); return; } if (saveDialog && saveDialog.classList.contains("open")) { e.preventDefault(); e.stopPropagation(); closeSaveDialog(); return; } } else if (e.key === "Enter") { if (saveDialog && saveDialog.classList.contains("open")) { e.preventDefault(); e.stopPropagation(); btnConfirmSave.click(); return; } } }, true ); // ----------------------------------------------------------------------- // 二维码与扫码导入抽屉操作事件绑定 // ----------------------------------------------------------------------- const btnOpenScan = shadow.getElementById(`${uid}-btn-open-scan`); if (btnOpenScan) { btnOpenScan.addEventListener("click", () => openScanImportModal()); } const btnMenuScan = shadow.getElementById(`${uid}-btn-menu-scan`); if (btnMenuScan) { btnMenuScan.addEventListener("click", () => { dropdownMenu.classList.add("hidden"); openScanImportModal(); }); } // 二维码抽屉按钮 if (btnCloseQr) btnCloseQr.addEventListener("click", () => closeQrCodeDialog()); if (btnCloseQrBottom) btnCloseQrBottom.addEventListener("click", () => closeQrCodeDialog()); // 分片轮播播放器事件绑定 if (btnStartChunkQr) btnStartChunkQr.addEventListener("click", () => startChunkCarousel()); if (btnChunkPlayToggle) btnChunkPlayToggle.addEventListener("click", () => toggleChunkPlay()); if (btnChunkPrev) btnChunkPrev.addEventListener("click", () => prevChunk()); if (btnChunkNext) btnChunkNext.addEventListener("click", () => nextChunk()); if (btnChunkExit) btnChunkExit.addEventListener("click", () => exitChunkMode()); if (btnDownloadQr) { btnDownloadQr.addEventListener("click", () => { if (btnDownloadQr.disabled) { showToast("快照数据过大无法生成二维码图片,请使用「复制数据」或「导出文件」", "error"); return; } if (!qrCanvas) return; try { const url = qrCanvas.toDataURL("image/png"); const a = document.createElement("a"); const fileName = (currentQrRecord && currentQrRecord.name ? currentQrRecord.name : "snapshot").replace(/[\\/:*?"<>|]/g, "_"); a.href = url; a.download = `${location.hostname}_${fileName}_qr.png`; a.click(); showToast("二维码图片已开始下载", "success"); } catch (err) { showToast(`二维码图片下载失败: ${err.message}`, "error"); } }); } if (btnCopyQrData) { btnCopyQrData.addEventListener("click", () => { if (!currentQrJson) { showToast("暂无可复制的二维码数据", "info"); return; } try { if (typeof GM_setClipboard === "function") { GM_setClipboard(currentQrJson, "text"); } else if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(currentQrJson).catch(() => {}); } showToast("快照 JSON 数据已复制到剪贴板!", "success"); } catch (err) { showToast(`复制失败: ${err.message}`, "error"); } }); } // 扫码与导入抽屉按钮 if (btnCloseScan) btnCloseScan.addEventListener("click", () => closeScanDialog()); if (btnStartCamera) btnStartCamera.addEventListener("click", () => startCameraScan()); if (btnChooseImg && fileScanImage) { btnChooseImg.addEventListener("click", () => { fileScanImage.value = ""; fileScanImage.click(); }); } if (fileScanImage) { fileScanImage.addEventListener("change", (e) => { const file = e.target.files && e.target.files[0]; if (file) handleImageQrFile(file); }); } if (btnChooseJson && fileScanJson) { btnChooseJson.addEventListener("click", () => { fileScanJson.value = ""; fileScanJson.click(); }); } if (fileScanJson) { fileScanJson.addEventListener("change", async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; try { showToast("正在读取 JSON 快照文件...", "info"); const json = await readFileAsJson(file); handleParsedSnapshot(json, file.name); } catch (err) { showToast(`读取失败: ${err.message}`, "error"); } }); } // 拖拽解析支持 if (scanViewMain) { scanViewMain.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); }); scanViewMain.addEventListener("drop", async (e) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (!file) return; if (file.type.startsWith("image/")) { handleImageQrFile(file); } else if (file.name.endsWith(".json") || file.type.includes("json")) { try { showToast("正在读取 JSON 快照文件...", "info"); const json = await readFileAsJson(file); handleParsedSnapshot(json, file.name); } catch (err) { showToast(`读取失败: ${err.message}`, "error"); } } else { showToast("请拖入图片二维码截图或 .json 快照文件", "error"); } }); } // 扫码结果视图操作 if (btnScanRestoreNow) { btnScanRestoreNow.addEventListener("click", async () => { if (!currentScannedSnapshot || !currentScannedSnapshot.json) { showToast("无可恢复的快照数据", "error"); return; } const targetJson = currentScannedSnapshot.json; const targetName = currentScannedSnapshot.name; try { closeScanDialog(); await restoreSnapshotData(targetJson, targetName); } catch (err) { showToast(`恢复失败: ${err.message}`, "error"); } }); } if (btnScanSaveDb) { btnScanSaveDb.addEventListener("click", () => { if (!currentScannedSnapshot || !currentScannedSnapshot.recordsToImport) { showToast("无可保存的快照数据", "error"); return; } const recordsToImport = currentScannedSnapshot.recordsToImport; try { const { count, skipped } = DB.importRecords(recordsToImport); refreshList(); closeScanDialog(); if (count === 0 && skipped > 0) { showToast(`检测到 ${skipped} 条快照数据已存在,已全部跳过`, "info"); } else if (skipped > 0) { showToast(`成功导入 ${count} 条快照,跳过 ${skipped} 条重复记录`, "success"); } else { showToast(`成功导入并保存 ${count} 条快照记录!`, "success"); } } catch (err) { showToast(`保存失败: ${err.message}`, "error"); } }); } if (btnScanRetry) { btnScanRetry.addEventListener("click", () => { resetScanModal(); }); } // 初始化位置与列表 restoreUIPos(); refreshList(); // 视口 Resize 时防止悬浮球和管理窗口溢出屏幕 window.addEventListener("resize", () => { try { if (ball && ball.style.display !== "none" && !ball.classList.contains("hidden")) { const br = ball.getBoundingClientRect(); if (br.left + br.width > window.innerWidth || br.top + br.height > window.innerHeight) { const maxLeft = Math.max(10, window.innerWidth - (ball.offsetWidth || 50) - 12); const maxTop = Math.max(10, window.innerHeight - (ball.offsetHeight || 50) - 12); ball.style.left = Math.min(br.left, maxLeft) + "px"; ball.style.top = Math.min(br.top, maxTop) + "px"; } } if (win && win.style.display !== "none" && !win.classList.contains("hidden")) { const wr = win.getBoundingClientRect(); if (wr.left + wr.width > window.innerWidth || wr.top + wr.height > window.innerHeight) { const maxLeft = Math.max(10, window.innerWidth - (win.offsetWidth || 480) - 20); const maxTop = Math.max(10, window.innerHeight - (win.offsetHeight || 520) - 20); win.style.left = Math.min(wr.left, maxLeft) + "px"; win.style.top = Math.min(wr.top, maxTop) + "px"; } } } catch {} }); // SPA 单页应用路由切换感知 (History API / Hash) const handleRouteChange = () => { // 如果窗口正在展示且保存抽屉处于开启状态,动态刷新默认命名 if (saveDialog && saveDialog.classList.contains("open") && inputName) { const currentVal = inputName.value; if (currentVal.startsWith("快照_") || !currentVal) { inputName.value = getDefaultName(); } } }; window.addEventListener("popstate", handleRouteChange); window.addEventListener("hashchange", handleRouteChange); if (!window.__LSM_HISTORY_HOOKED__) { window.__LSM_HISTORY_HOOKED__ = true; const rawPushState = history.pushState; if (typeof rawPushState === "function") { history.pushState = function (...args) { const ret = rawPushState.apply(this, args); handleRouteChange(); return ret; }; } const rawReplaceState = history.replaceState; if (typeof rawReplaceState === "function") { history.replaceState = function (...args) { const ret = rawReplaceState.apply(this, args); handleRouteChange(); return ret; }; } } // 暴露给全局控制器 LSM_UI = { ball, win, openWindow, closeWindow, refreshList, showToast, updateGistStatus }; }