// ==UserScript==
// @name 网站快照存储与恢复助手
// @namespace https://github.com/moyefu/BrowserScript/WebSnapshotManager
// @version 1.2.0
// @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
// @require https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.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
==/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 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: 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;
}
/* 快照二维码展示抽屉弹窗 */
.${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}-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: 220px;
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-frame {
position: absolute;
width: 170px;
height: 170px;
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 = `
${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();
}
}
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");
}
});
// 批量导出
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 (btnDownloadQr) {
btnDownloadQr.addEventListener("click", () => {
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
};
}