// ==UserScript==
// @name 网站快照存储与恢复助手
// @namespace https://github.com/moyefu/BrowserScript/WebSnapshotManager
// @version 1.1.7
// @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
// @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: true
auto_reload_after_restore:
title: 恢复后直接刷新/跳转
description: 恢复快照成功后直接刷新或跳转至来源页面(不再弹窗确认)
type: checkbox
default: false
==/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();
});
}
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);
}
// =========================================================================
// 主应用逻辑初始化
// =========================================================================
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);
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);
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);
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);
}
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);
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 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: 500px;
height: 560px;
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 10px;
border-radius: 9999px;
font-weight: 500;
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${uid}-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.${uid}-header-actions button {
border: none;
background: rgba(255, 255, 255, 0.12);
color: #f8fafc;
border-radius: 8px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
}
.${uid}-header-actions button:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
/* 状态条 */
.${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}-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}-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;
}
/* 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 = `