// ==UserScript==
// @name 网站快照存储与恢复助手
// @namespace https://github.com/moyefu/BrowserScript/WebSnapshotManager
// @version 1.4.1
// @description 针对指定网站实现快照(Cookie、LocalStorage、SessionStorage)的一键存储、命名、加密备份、GitHub Gist云同步、二维码生成/扫码与一键恢复
// @author MOYEFU
// @icon https://pic1.imgdb.cn/i/034D4F8VwYLLoU73kkQs3l.gif
// @homepage https://scriptcat.org/zh-CN/script-show-page/7633
// @supportURL https://scriptcat.org/zh-CN/script-show-page/7633/issue
// @license MIT
// @match http*://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_cookie
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect api.github.com
// @require https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js
// @require https://cdn.jsdelivr.net/npm/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
sync_auto:
title: 快照变更时自动同步
description: 本地快照新增、改名、删除时,防抖 2 秒自动同步至 GitHub Gist
type: checkbox
default: false
sync_gist_token:
title: GitHub Gist Token
description: 用于云同步的 GitHub Personal Access Token(需勾选 gist 权限)
type: text
default: ""
sync_gist_id:
title: GitHub Gist ID
description: 存储快照数据的 Gist ID(留空可在面板中点击一键自动创建)
type: text
default: ""
==/UserConfig== */
// 全局暴露的 UI 实例,供菜单命令与外部调度使用
let LSM_UI = null;
// =========================================================================
// 主题系统引擎 (ThemeEngine)
// 支持结构化 JSON 封装、导入/导出、实时 CSS 变量无刷新换肤与自定义定制
// =========================================================================
const ThemeEngine = {
SCHEMA_TYPE: "LSM_THEME",
SCHEMA_VERSION: "1.0.0",
DEFAULT_THEME_ID: "yohaku",
_shadow: null,
_uid: null,
// 内置官方预设主题库
BUILTIN_THEMES: [
{
type: "LSM_THEME",
version: "1.0.0",
id: "yohaku",
name: "Yohaku (余白)",
description: "基于 Innei Yohaku 设计体系的米白纸张与梅红质感主题",
isBuiltin: true,
tokens: {
accent: "#c56473",
accentBg: "rgba(197, 100, 115, 0.08)",
accentBorder: "rgba(197, 100, 115, 0.3)",
accentHoverBg: "rgba(197, 100, 115, 0.14)",
accentGlow: "rgba(197, 100, 115, 0.12)",
bgPaper: "#faf9f5",
bgHeader: "#f0efeb",
bgCard: "#ffffff",
bgList: "#f9f8f5",
bgHover: "#f0efeb",
bgActiveCard: "linear-gradient(180deg, rgba(94, 159, 126, 0.06) 0%, #ffffff 60%)",
borderLight: "#e3e1db",
borderHover: "#d0cec6",
textPrimary: "#24231f",
textSecondary: "#5c5a55",
textMuted: "#787670",
textPlaceholder: "#a8a69f",
colorSuccess: "#5e9f7e",
bgSuccess: "rgba(94, 159, 126, 0.08)",
borderSuccess: "rgba(94, 159, 126, 0.25)",
colorWarning: "#a87a3d",
bgWarning: "rgba(168, 122, 61, 0.08)",
borderWarning: "rgba(168, 122, 61, 0.2)",
colorInfo: "#3d6896",
bgInfo: "rgba(61, 104, 150, 0.08)",
borderInfo: "rgba(61, 104, 150, 0.2)",
colorDanger: "#a64953",
bgDanger: "rgba(166, 73, 83, 0.08)",
borderDanger: "rgba(166, 73, 83, 0.25)",
fontFamily: "system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', sans-serif",
radiusWindow: "16px",
radiusCard: "12px",
radiusBtn: "8px"
}
},
{
type: "LSM_THEME",
version: "1.0.0",
id: "classic_blue",
name: "Classic Blue (经典科技蓝)",
description: "清晰现代的科技蓝调搭配极简冷灰界面",
isBuiltin: true,
tokens: {
accent: "#2563eb",
accentBg: "rgba(37, 99, 235, 0.08)",
accentBorder: "rgba(37, 99, 235, 0.3)",
accentHoverBg: "rgba(37, 99, 235, 0.14)",
accentGlow: "rgba(37, 99, 235, 0.12)",
bgPaper: "#f8fafc",
bgHeader: "#f1f5f9",
bgCard: "#ffffff",
bgList: "#f8fafc",
bgHover: "#f1f5f9",
bgActiveCard: "linear-gradient(180deg, rgba(22, 163, 74, 0.06) 0%, #ffffff 60%)",
borderLight: "#e2e8f0",
borderHover: "#cbd5e1",
textPrimary: "#0f172a",
textSecondary: "#334155",
textMuted: "#64748b",
textPlaceholder: "#94a3b8",
colorSuccess: "#16a34a",
bgSuccess: "rgba(22, 163, 74, 0.08)",
borderSuccess: "rgba(22, 163, 74, 0.25)",
colorWarning: "#d97706",
bgWarning: "rgba(217, 119, 6, 0.08)",
borderWarning: "rgba(217, 119, 6, 0.2)",
colorInfo: "#0284c7",
bgInfo: "rgba(2, 132, 199, 0.08)",
borderInfo: "rgba(2, 132, 199, 0.2)",
colorDanger: "#dc2626",
bgDanger: "rgba(220, 38, 38, 0.08)",
borderDanger: "rgba(220, 38, 38, 0.25)",
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
radiusWindow: "16px",
radiusCard: "12px",
radiusBtn: "8px"
}
},
{
type: "LSM_THEME",
version: "1.0.0",
id: "obsidian_dark",
name: "Obsidian Dark (曜石暗夜)",
description: "深色沉浸护眼主题,曜石黑灰与高亮翡翠绿点缀",
isBuiltin: true,
tokens: {
accent: "#10b981",
accentBg: "rgba(16, 185, 129, 0.12)",
accentBorder: "rgba(16, 185, 129, 0.35)",
accentHoverBg: "rgba(16, 185, 129, 0.2)",
accentGlow: "rgba(16, 185, 129, 0.15)",
bgPaper: "#141312",
bgHeader: "#1f1e1c",
bgCard: "#1f1e1c",
bgList: "#141312",
bgHover: "#2a2926",
bgActiveCard: "linear-gradient(180deg, rgba(16, 185, 129, 0.12) 0%, #1f1e1c 60%)",
borderLight: "#2f2d29",
borderHover: "#474540",
textPrimary: "#f5f4f0",
textSecondary: "#d0cec6",
textMuted: "#a8a69f",
textPlaceholder: "#787670",
colorSuccess: "#10b981",
bgSuccess: "rgba(16, 185, 129, 0.12)",
borderSuccess: "rgba(16, 185, 129, 0.3)",
colorWarning: "#f59e0b",
bgWarning: "rgba(245, 158, 11, 0.12)",
borderWarning: "rgba(245, 158, 11, 0.3)",
colorInfo: "#38bdf8",
bgInfo: "rgba(56, 189, 248, 0.12)",
borderInfo: "rgba(56, 189, 248, 0.3)",
colorDanger: "#f43f5e",
bgDanger: "rgba(244, 63, 94, 0.12)",
borderDanger: "rgba(244, 63, 94, 0.3)",
fontFamily: "system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
radiusWindow: "16px",
radiusCard: "12px",
radiusBtn: "8px"
}
},
{
type: "LSM_THEME",
version: "1.0.0",
id: "sakura_pink",
name: "Sakura Pink (春櫻粉紫)",
description: "温柔优雅的春日樱花色调,粉白相间与柔润光泽",
isBuiltin: true,
tokens: {
accent: "#e11d48",
accentBg: "rgba(225, 29, 72, 0.08)",
accentBorder: "rgba(225, 29, 72, 0.28)",
accentHoverBg: "rgba(225, 29, 72, 0.14)",
accentGlow: "rgba(225, 29, 72, 0.1)",
bgPaper: "#fff5f6",
bgHeader: "#ffe4e6",
bgCard: "#ffffff",
bgList: "#fff1f3",
bgHover: "#ffe4e6",
bgActiveCard: "linear-gradient(180deg, rgba(225, 29, 72, 0.06) 0%, #ffffff 60%)",
borderLight: "#fecdd3",
borderHover: "#fda4af",
textPrimary: "#2b1216",
textSecondary: "#612933",
textMuted: "#9f4a59",
textPlaceholder: "#be7683",
colorSuccess: "#059669",
bgSuccess: "rgba(5, 150, 105, 0.08)",
borderSuccess: "rgba(5, 150, 105, 0.2)",
colorWarning: "#d97706",
bgWarning: "rgba(217, 119, 6, 0.08)",
borderWarning: "rgba(217, 119, 6, 0.2)",
colorInfo: "#7c3aed",
bgInfo: "rgba(124, 58, 237, 0.08)",
borderInfo: "rgba(124, 58, 237, 0.2)",
colorDanger: "#e11d48",
bgDanger: "rgba(225, 29, 72, 0.08)",
borderDanger: "rgba(225, 29, 72, 0.25)",
fontFamily: "system-ui, -apple-system, 'PingFang SC', sans-serif",
radiusWindow: "18px",
radiusCard: "14px",
radiusBtn: "10px"
}
},
{
type: "LSM_THEME",
version: "1.0.0",
id: "matcha_green",
name: "Matcha Green (宇治抹茶)",
description: "清幽宁静的和风抹茶绿,淡雅米绿与墨茶文字",
isBuiltin: true,
tokens: {
accent: "#15803d",
accentBg: "rgba(21, 128, 61, 0.08)",
accentBorder: "rgba(21, 128, 61, 0.28)",
accentHoverBg: "rgba(21, 128, 61, 0.14)",
accentGlow: "rgba(21, 128, 61, 0.1)",
bgPaper: "#f9fcf8",
bgHeader: "#edf7eb",
bgCard: "#ffffff",
bgList: "#f4faf2",
bgHover: "#e8f5e5",
bgActiveCard: "linear-gradient(180deg, rgba(21, 128, 61, 0.06) 0%, #ffffff 60%)",
borderLight: "#d6ebd3",
borderHover: "#b6deb0",
textPrimary: "#19281a",
textSecondary: "#3d543f",
textMuted: "#678469",
textPlaceholder: "#93ac95",
colorSuccess: "#15803d",
bgSuccess: "rgba(21, 128, 61, 0.08)",
borderSuccess: "rgba(21, 128, 61, 0.2)",
colorWarning: "#b45309",
bgWarning: "rgba(180, 83, 9, 0.08)",
borderWarning: "rgba(180, 83, 9, 0.2)",
colorInfo: "#0369a1",
bgInfo: "rgba(3, 105, 161, 0.08)",
borderInfo: "rgba(3, 105, 161, 0.2)",
colorDanger: "#b91c1c",
bgDanger: "rgba(185, 28, 28, 0.08)",
borderDanger: "rgba(185, 28, 28, 0.25)",
fontFamily: "system-ui, -apple-system, 'PingFang SC', sans-serif",
radiusWindow: "16px",
radiusCard: "12px",
radiusBtn: "8px"
}
}
],
// 获取用户存储的所有自定义主题
getCustomThemes() {
try {
const raw = GM_getValue("Config.custom_themes", {});
return typeof raw === "object" && raw !== null ? raw : {};
} catch (e) {
return {};
}
},
// 保存自定义主题字典
saveCustomThemesMap(map) {
try {
GM_setValue("Config.custom_themes", map || {});
} catch (e) {}
},
// 获取当前全部可用主题列表(内置 + 自定义)
getAllThemes() {
const customs = Object.values(this.getCustomThemes());
return [...this.BUILTIN_THEMES, ...customs];
},
// 获取指定 ID 的主题对象
getThemeById(id) {
if (!id) return this.BUILTIN_THEMES[0];
const found = this.getAllThemes().find((t) => t.id === id);
return found || this.BUILTIN_THEMES[0];
},
// 获取当前生效的主题对象
getActiveTheme() {
const activeId = GM_getValue("Config.active_theme_id", this.DEFAULT_THEME_ID);
return this.getThemeById(activeId);
},
// 设置当前生效的主题 ID 并持久化
setActiveTheme(id) {
const theme = this.getThemeById(id);
GM_setValue("Config.active_theme_id", theme.id);
this.applyTheme(theme);
return theme;
},
// 一键重置为默认 Yohaku 主题
resetToDefault() {
return this.setActiveTheme(this.DEFAULT_THEME_ID);
},
// 绑定当前活动 UI 的 Shadow Root 与 UID
bindShadow(shadowRoot, uid) {
this._shadow = shadowRoot;
this._uid = uid;
this.applyTheme();
},
// 校验与规范化主题数据
validateAndNormalizeTheme(input) {
if (!input || typeof input !== "object") {
throw new Error("主题数据必须为有效的 JSON 对象");
}
if (input.type && input.type !== this.SCHEMA_TYPE) {
throw new Error("非法的主题数据格式类型: " + input.type + ",期望为 " + this.SCHEMA_TYPE);
}
const defaultTokens = this.BUILTIN_THEMES[0].tokens;
const inputTokens = input.tokens || {};
const cleanTokens = {};
for (const [key, defVal] of Object.entries(defaultTokens)) {
cleanTokens[key] = typeof inputTokens[key] === "string" && inputTokens[key].trim() ? inputTokens[key].trim() : defVal;
}
const name = String(input.name || "自定义主题").trim();
const id = String(input.id || ("custom_" + Date.now().toString(36))).trim().replace(/[^a-zA-Z0-9_-]/g, "_");
const description = String(input.description || "用户自定义导入的主题样式").trim();
return {
type: this.SCHEMA_TYPE,
version: this.SCHEMA_VERSION,
id,
name,
description,
isBuiltin: false,
tokens: cleanTokens,
createdAt: input.createdAt || Date.now(),
updatedAt: Date.now()
};
},
// 导入自定义主题 JSON
importTheme(jsonStringOrObj) {
let parsed = jsonStringOrObj;
if (typeof jsonStringOrObj === "string") {
try {
parsed = JSON.parse(jsonStringOrObj);
} catch (err) {
throw new Error("JSON 解析失败: " + err.message);
}
}
const theme = this.validateAndNormalizeTheme(parsed);
const customs = this.getCustomThemes();
// 如果 ID 与内置主题冲突,则分配新 ID
if (this.BUILTIN_THEMES.some((b) => b.id === theme.id)) {
theme.id = "custom_" + theme.id + "_" + Date.now().toString(36).slice(-4);
}
customs[theme.id] = theme;
this.saveCustomThemesMap(customs);
this.setActiveTheme(theme.id);
return theme;
},
// 导出指定主题为结构化 JSON 字符串
exportTheme(id) {
const theme = this.getThemeById(id);
const exportObj = {
type: this.SCHEMA_TYPE,
version: this.SCHEMA_VERSION,
id: theme.id,
name: theme.name,
description: theme.description,
tokens: theme.tokens,
exportedAt: Date.now()
};
return JSON.stringify(exportObj, null, 2);
},
// 保存/更新自定义主题
saveCustomTheme(themeObj) {
const normalized = this.validateAndNormalizeTheme(themeObj);
const customs = this.getCustomThemes();
customs[normalized.id] = normalized;
this.saveCustomThemesMap(customs);
this.setActiveTheme(normalized.id);
return normalized;
},
// 删除自定义主题(内置主题禁止删除)
deleteCustomTheme(id) {
if (this.BUILTIN_THEMES.some((b) => b.id === id)) {
throw new Error("内置官方主题受保护,不可删除");
}
const customs = this.getCustomThemes();
if (customs[id]) {
delete customs[id];
this.saveCustomThemesMap(customs);
}
if (GM_getValue("Config.active_theme_id", "") === id) {
this.resetToDefault();
}
},
// 生成 CSS 变量字符串
generateCssVariables(tokens, targetUid) {
const rootSel = targetUid ? `#${targetUid}-root` : `:host, [id$="-root"]`;
return `
${rootSel} {
--lsm-accent: ${tokens.accent};
--lsm-accent-bg: ${tokens.accentBg};
--lsm-accent-border: ${tokens.accentBorder};
--lsm-accent-hover-bg: ${tokens.accentHoverBg};
--lsm-accent-glow: ${tokens.accentGlow};
--lsm-bg-paper: ${tokens.bgPaper};
--lsm-bg-header: ${tokens.bgHeader};
--lsm-bg-card: ${tokens.bgCard};
--lsm-bg-list: ${tokens.bgList};
--lsm-bg-hover: ${tokens.bgHover};
--lsm-bg-active-card: ${tokens.bgActiveCard};
--lsm-border: ${tokens.borderLight};
--lsm-border-hover: ${tokens.borderHover};
--lsm-text-primary: ${tokens.textPrimary};
--lsm-text-secondary: ${tokens.textSecondary};
--lsm-text-muted: ${tokens.textMuted};
--lsm-text-placeholder: ${tokens.textPlaceholder};
--lsm-color-success: ${tokens.colorSuccess};
--lsm-bg-success: ${tokens.bgSuccess};
--lsm-border-success: ${tokens.borderSuccess};
--lsm-color-warning: ${tokens.colorWarning};
--lsm-bg-warning: ${tokens.bgWarning};
--lsm-border-warning: ${tokens.borderWarning};
--lsm-color-info: ${tokens.colorInfo};
--lsm-bg-info: ${tokens.bgInfo};
--lsm-border-info: ${tokens.borderInfo};
--lsm-color-danger: ${tokens.colorDanger};
--lsm-bg-danger: ${tokens.bgDanger};
--lsm-border-danger: ${tokens.borderDanger};
--lsm-font-family: ${tokens.fontFamily};
--lsm-radius-window: ${tokens.radiusWindow || "16px"};
--lsm-radius-card: ${tokens.radiusCard || "12px"};
--lsm-radius-btn: ${tokens.radiusBtn || "8px"};
}
`;
},
// 动态应用主题到当前 UI 实例(Shadow DOM 与 Host Dialogs)
applyTheme(theme) {
const active = theme || this.getActiveTheme();
const cssVars = this.generateCssVariables(active.tokens, this._uid);
// 1. 注入 Shadow Root 变量样式表
if (this._shadow) {
const varStyleId = this._uid ? `${this._uid}-theme-vars` : "lsm-theme-vars";
let varStyle = this._shadow.getElementById(varStyleId);
if (!varStyle) {
varStyle = document.createElement("style");
varStyle.id = varStyleId;
this._shadow.insertBefore(varStyle, this._shadow.firstChild);
}
varStyle.textContent = cssVars;
}
// 2. 注入全局宿主环境弹窗主题变量
if (typeof document !== "undefined" && document) {
let hostVarStyle = document.getElementById("lsm-host-theme-vars");
if (!hostVarStyle) {
hostVarStyle = document.createElement("style");
hostVarStyle.id = "lsm-host-theme-vars";
(document.head || document.documentElement).appendChild(hostVarStyle);
}
hostVarStyle.textContent = `
:root {
--lsm-host-accent: ${active.tokens.accent};
--lsm-host-accent-bg: ${active.tokens.accentBg};
--lsm-host-accent-border: ${active.tokens.accentBorder};
--lsm-host-bg-paper: ${active.tokens.bgPaper};
--lsm-host-border: ${active.tokens.borderLight};
--lsm-host-border-hover: ${active.tokens.borderHover || "#d0cec6"};
--lsm-host-text-primary: ${active.tokens.textPrimary};
--lsm-host-text-secondary: ${active.tokens.textSecondary};
--lsm-host-text-muted: ${active.tokens.textMuted};
--lsm-host-font: ${active.tokens.fontFamily};
}
/* 宿主环境全部相关弹窗、文本域与列表滚动条美化 */
div[id*="lsm"] * {
scrollbar-width: thin;
scrollbar-color: ${active.tokens.borderHover || "#d0cec6"} transparent;
}
div[id*="lsm"] *::-webkit-scrollbar {
width: 6px;
height: 6px;
}
div[id*="lsm"] *::-webkit-scrollbar-track {
background: transparent;
border-radius: 9999px;
}
div[id*="lsm"] *::-webkit-scrollbar-thumb {
background-color: ${active.tokens.borderHover || "#d0cec6"};
border-radius: 9999px;
border: 1px solid transparent;
background-clip: padding-box;
transition: background-color 0.2s ease;
}
div[id*="lsm"] *::-webkit-scrollbar-thumb:hover {
background-color: ${active.tokens.accent};
}
div[id*="lsm"] *::-webkit-scrollbar-corner {
background: transparent;
}
`;
}
}
};
// 获取域名过滤模式,自动提取前面英文关键词(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";
}
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;
}
}
(async () => {
"use strict";
// =========================================================================
// 菜单命令注册(Tampermonkey / ScriptCat 菜单)
// 1. 🔑 快照管理助手
// 2. 🛡️ 过滤模式切换(白名单 / 黑名单)
// 3. 📝 编辑域名名单列表
// 4. 🔒 本地数据加密状态切换
// 5. 🔄 恢复后刷新跳转状态切换
// =========================================================================
function registerAllMenuCommands() {
// 主题风格与个性化设置
GM_registerMenuCommand("🎨 主题设置 (切换/导出/导入)", async () => {
if (!LSM_UI) {
await initApp();
}
if (LSM_UI) {
if (typeof LSM_UI.openWindow === "function") LSM_UI.openWindow();
if (typeof LSM_UI.openThemeDialog === "function") LSM_UI.openThemeDialog();
}
});
// 1. 主入口
GM_registerMenuCommand("🔑 快照管理助手", () => {
if (hostBlocked()) {
showBlockedDialog();
} else {
showMainDialog();
}
});
// 2. 切换黑/白名单模式
const currentMode = getFilterMode();
const modeText = currentMode === "blacklist" ? "🛡️ 当前为【黑名单】模式 (点击切换为白名单)" : "🛡️ 当前为【白名单】模式 (点击切换为黑名单)";
GM_registerMenuCommand(modeText, () => {
showSwitchFilterModeDialog();
});
// 3. 编辑黑/白名单列表
GM_registerMenuCommand("📝 编辑域名规则列表 (黑/白名单)", () => {
showEditHostListDialog();
});
// 4. 本地数据加密状态
const isEnc = GM_getValue("Config.enable_encryption", true);
const encText = isEnc ? "🔒 本地数据【已加密】 (点击切换/关闭)" : "🔓 本地数据【未加密】 (点击切换/开启)";
GM_registerMenuCommand(encText, () => {
showToggleEncryptionDialog();
});
// 5. 恢复后刷新状态
const isAutoReload = GM_getValue("Config.auto_reload_after_restore", false);
const reloadText = isAutoReload ? "🔄 恢复后【自动刷新/跳转】 (点击切换为不刷新)" : "⏸️ 恢复后【不默认刷新】 (点击切换为自动刷新)";
GM_registerMenuCommand(reloadText, () => {
showToggleAutoReloadDialog();
});
// 6. 云同步设置
GM_registerMenuCommand("☁️ 云同步设置与状态 (GitHub Gist)", async () => {
if (hostBlocked()) {
showBlockedDialog();
} else {
if (!LSM_UI) {
await initApp();
}
if (LSM_UI && typeof LSM_UI.openWindow === "function") {
LSM_UI.openWindow();
if (typeof LSM_UI.openCloudSyncDialog === "function") {
LSM_UI.openCloudSyncDialog();
}
}
}
});
}
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;
function findScrollable(target) {
let el = target;
while (el && el !== mask && el !== document.documentElement && el !== document.body) {
if (el.scrollHeight > el.clientHeight) {
const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
if (style) {
const overflowY = style.overflowY;
if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
return el;
}
}
}
if (scrollableSelector && el.matches && el.matches(scrollableSelector) && el.scrollHeight > el.clientHeight) {
return el;
}
el = el.parentElement || (el.getRootNode ? el.getRootNode().host : null);
}
return scrollableSelector && target.closest ? target.closest(scrollableSelector) : null;
}
// 1. PC 端鼠标滚轮事件精确拦截
mask.addEventListener(
"wheel",
(e) => {
e.stopPropagation();
const scrollable = findScrollable(e.target);
if (!scrollable) {
e.preventDefault();
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollable;
const deltaY = e.deltaY;
if (scrollHeight <= clientHeight) {
e.preventDefault();
return;
}
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 = findScrollable(e.target);
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(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, null);
const box = document.createElement("div");
box.style.cssText =
"width:360px;max-width:calc(100vw - 32px);background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:24px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
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:#5c5a55;line-height:1.6;margin-bottom:18px;";
const tempBtn = document.createElement("button");
tempBtn.textContent = "临时显示(仅本次生效)";
tempBtn.style.cssText =
"display:block;width:100%;padding:9px 0;margin-bottom:9px;border:1px solid rgba(197,100,115,0.3);border-radius:10px;" +
"background:rgba(197,100,115,0.08);color:#c56473;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
tempBtn.addEventListener("mouseenter", () => {
tempBtn.style.background = "rgba(197,100,115,0.14)";
tempBtn.style.borderColor = "rgba(197,100,115,0.45)";
});
tempBtn.addEventListener("mouseleave", () => {
tempBtn.style.background = "rgba(197,100,115,0.08)";
tempBtn.style.borderColor = "rgba(197,100,115,0.3)";
});
const permBtn = document.createElement("button");
permBtn.textContent = mode === "blacklist" ? "永久开启(移出黑名单)" : "永久开启(加入白名单)";
permBtn.style.cssText =
"display:block;width:100%;padding:9px 0;border:1px solid #e3e1db;border-radius:10px;" +
"background:transparent;color:#403f3a;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
permBtn.addEventListener("mouseenter", () => {
permBtn.style.background = "#f0efeb";
permBtn.style.borderColor = "#d0cec6";
});
permBtn.addEventListener("mouseleave", () => {
permBtn.style.background = "transparent";
permBtn.style.borderColor = "#e3e1db";
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "取消";
cancelBtn.style.cssText =
"display:block;width:100%;padding:8px 0;margin-top:6px;border:none;background:none;" +
"color:#787670;font-size:12px;cursor:pointer;transition:color .15s;";
cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "#24231f");
cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "#787670");
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(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, null);
const box = document.createElement("div");
box.style.cssText =
"width:360px;max-width:calc(100vw - 32px);background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:24px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
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:#5c5a55;line-height:1.6;margin-bottom:18px;";
const openBtn = document.createElement("button");
openBtn.textContent = "打开管理窗口";
openBtn.style.cssText =
"display:block;width:100%;padding:9px 0;margin-bottom:9px;border:1px solid rgba(197,100,115,0.3);border-radius:10px;" +
"background:rgba(197,100,115,0.08);color:#c56473;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
openBtn.addEventListener("mouseenter", () => {
openBtn.style.background = "rgba(197,100,115,0.14)";
openBtn.style.borderColor = "rgba(197,100,115,0.45)";
});
openBtn.addEventListener("mouseleave", () => {
openBtn.style.background = "rgba(197,100,115,0.08)";
openBtn.style.borderColor = "rgba(197,100,115,0.3)";
});
const tmpBtn = document.createElement("button");
tmpBtn.textContent = "临时隐藏悬浮球(刷新后恢复)";
tmpBtn.style.cssText =
"display:block;width:100%;padding:9px 0;margin-bottom:9px;border:1px solid #e3e1db;border-radius:10px;" +
"background:transparent;color:#403f3a;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
tmpBtn.addEventListener("mouseenter", () => {
tmpBtn.style.background = "#f0efeb";
tmpBtn.style.borderColor = "#d0cec6";
});
tmpBtn.addEventListener("mouseleave", () => {
tmpBtn.style.background = "transparent";
tmpBtn.style.borderColor = "#e3e1db";
});
const permBtn = document.createElement("button");
permBtn.textContent = mode === "blacklist" ? "永久关闭(加入黑名单)" : "永久关闭(从白名单移除)";
permBtn.style.cssText =
"display:block;width:100%;padding:9px 0;border:1px solid rgba(166,73,83,0.25);border-radius:10px;" +
"background:rgba(166,73,83,0.06);color:#a64953;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
permBtn.addEventListener("mouseenter", () => {
permBtn.style.background = "rgba(166,73,83,0.12)";
permBtn.style.borderColor = "rgba(166,73,83,0.4)";
});
permBtn.addEventListener("mouseleave", () => {
permBtn.style.background = "rgba(166,73,83,0.06)";
permBtn.style.borderColor = "rgba(166,73,83,0.25)";
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "取消";
cancelBtn.style.cssText =
"display:block;width:100%;padding:8px 0;margin-top:6px;border:none;background:none;" +
"color:#787670;font-size:12px;cursor:pointer;transition:color .15s;";
cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "#24231f");
cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "#787670");
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(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, null);
const box = document.createElement("div");
box.style.cssText =
"width:380px;max-width:calc(100vw - 32px);background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:24px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
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:#5c5a55;line-height:1.6;margin-bottom:18px;";
const confirmBtn = document.createElement("button");
confirmBtn.textContent = `确认切换为「${targetModeLabel}」`;
confirmBtn.style.cssText =
"display:block;width:100%;padding:9px 0;margin-bottom:9px;border:1px solid rgba(197,100,115,0.3);border-radius:10px;" +
"background:rgba(197,100,115,0.08);color:#c56473;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
confirmBtn.addEventListener("mouseenter", () => {
confirmBtn.style.background = "rgba(197,100,115,0.14)";
confirmBtn.style.borderColor = "rgba(197,100,115,0.45)";
});
confirmBtn.addEventListener("mouseleave", () => {
confirmBtn.style.background = "rgba(197,100,115,0.08)";
confirmBtn.style.borderColor = "rgba(197,100,115,0.3)";
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "取消";
cancelBtn.style.cssText =
"display:block;width:100%;padding:8px 0;margin-top:4px;border:none;background:none;" +
"color:#787670;font-size:12px;cursor:pointer;transition:color .15s;";
cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "#24231f");
cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "#787670");
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(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, "textarea");
const box = document.createElement("div");
box.style.cssText =
"width:460px;max-width:calc(100vw - 32px);background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:24px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
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:#5c5a55;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 #e3e1db;border-radius:10px;" +
"padding:10px 12px;font-size:13px;line-height:1.5;font-family:ui-monospace,Consolas,Monaco,monospace;color:#24231f;resize:vertical;outline:none;" +
"background:#ffffff;background-image:linear-gradient(180deg,rgba(197,100,115,0.03) 0%,transparent 26%);transition:border-color .2s,box-shadow .2s;margin-bottom:16px;";
textarea.addEventListener("focus", () => {
textarea.style.borderColor = "rgba(197,100,115,0.35)";
textarea.style.boxShadow = "0 0 0 3px rgba(197,100,115,0.1)";
});
textarea.addEventListener("blur", () => {
textarea.style.borderColor = "#e3e1db";
textarea.style.boxShadow = "none";
});
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:7px 12px;border:1px solid #e3e1db;border-radius:8px;background:transparent;color:#403f3a;font-size:12px;cursor:pointer;font-weight:500;transition:all .15s;";
addCurrBtn.addEventListener("mouseenter", () => {
addCurrBtn.style.background = "#f0efeb";
addCurrBtn.style.borderColor = "#d0cec6";
});
addCurrBtn.addEventListener("mouseleave", () => {
addCurrBtn.style.background = "transparent";
addCurrBtn.style.borderColor = "#e3e1db";
});
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:7px 16px;border:1px solid rgba(197,100,115,0.3);border-radius:8px;background:rgba(197,100,115,0.08);color:#c56473;font-size:12.5px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
saveBtn.addEventListener("mouseenter", () => {
saveBtn.style.background = "rgba(197,100,115,0.14)";
saveBtn.style.borderColor = "rgba(197,100,115,0.45)";
});
saveBtn.addEventListener("mouseleave", () => {
saveBtn.style.background = "rgba(197,100,115,0.08)";
saveBtn.style.borderColor = "rgba(197,100,115,0.3)";
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "取消";
cancelBtn.style.cssText =
"padding:7px 12px;border:none;background:none;color:#787670;font-size:12px;cursor:pointer;transition:color .15s;";
cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "#24231f");
cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "#787670");
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(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, null);
const box = document.createElement("div");
box.style.cssText =
"width:380px;max-width:calc(100vw - 32px);background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:24px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
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:#5c5a55;line-height:1.6;margin-bottom:18px;";
const confirmBtn = document.createElement("button");
confirmBtn.textContent = targetEnc ? "确认开启加密" : "确认关闭加密";
confirmBtn.style.cssText =
`display:block;width:100%;padding:9px 0;margin-bottom:9px;border:1px solid ${targetEnc ? "rgba(197,100,115,0.3)" : "rgba(166,73,83,0.25)"};border-radius:10px;` +
`background:${targetEnc ? "rgba(197,100,115,0.08)" : "rgba(166,73,83,0.06)"};color:${targetEnc ? "#c56473" : "#a64953"};font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);`;
confirmBtn.addEventListener("mouseenter", () => {
confirmBtn.style.background = targetEnc ? "rgba(197,100,115,0.14)" : "rgba(166,73,83,0.12)";
});
confirmBtn.addEventListener("mouseleave", () => {
confirmBtn.style.background = targetEnc ? "rgba(197,100,115,0.08)" : "rgba(166,73,83,0.06)";
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "取消";
cancelBtn.style.cssText =
"display:block;width:100%;padding:8px 0;margin-top:4px;border:none;background:none;" +
"color:#787670;font-size:12px;cursor:pointer;transition:color .15s;";
cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "#24231f");
cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "#787670");
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(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, null);
const box = document.createElement("div");
box.style.cssText =
"width:380px;max-width:calc(100vw - 32px);background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:24px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
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:#5c5a55;line-height:1.6;margin-bottom:18px;";
const confirmBtn = document.createElement("button");
confirmBtn.textContent = targetState ? "确认切换为「自动刷新/跳转」" : "确认切换为「不默认刷新」";
confirmBtn.style.cssText =
"display:block;width:100%;padding:9px 0;margin-bottom:9px;border:1px solid rgba(197,100,115,0.3);border-radius:10px;" +
"background:rgba(197,100,115,0.08);color:#c56473;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s cubic-bezier(0.22,1,0.36,1);";
confirmBtn.addEventListener("mouseenter", () => {
confirmBtn.style.background = "rgba(197,100,115,0.14)";
confirmBtn.style.borderColor = "rgba(197,100,115,0.45)";
});
confirmBtn.addEventListener("mouseleave", () => {
confirmBtn.style.background = "rgba(197,100,115,0.08)";
confirmBtn.style.borderColor = "rgba(197,100,115,0.3)";
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "取消";
cancelBtn.style.cssText =
"display:block;width:100%;padding:8px 0;margin-top:4px;border:none;background:none;" +
"color:#787670;font-size:12px;cursor:pointer;transition:color .15s;";
cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "#24231f");
cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "#787670");
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);
},
// --- 墓碑 (Tombstones) 机制 ---
// 用于记录已删除快照的 ID 及删除时间戳,彻底杜绝两端同步时被旧云端数据反向复活
getTombstones(domain) {
const raw = GM_getValue("LSM_TOMBSTONES", {});
const tombstones = (raw && typeof raw === "object") ? raw : {};
// 30 天自动过期垃圾回收 (30 days in ms = 2592000000)
const expireThreshold = Date.now() - 30 * 24 * 3600 * 1000;
let changed = false;
for (const d of Object.keys(tombstones)) {
if (Array.isArray(tombstones[d])) {
const filtered = tombstones[d].filter((t) => t && t.deletedAt && t.deletedAt > expireThreshold);
if (filtered.length !== tombstones[d].length) {
tombstones[d] = filtered;
changed = true;
}
}
}
if (changed) {
GM_setValue("LSM_TOMBSTONES", tombstones);
}
if (domain) {
return Array.isArray(tombstones[domain]) ? tombstones[domain] : [];
}
return tombstones;
},
saveTombstones(tombstoneMap) {
if (tombstoneMap && typeof tombstoneMap === "object") {
GM_setValue("LSM_TOMBSTONES", tombstoneMap);
}
},
recordTombstone(id, domain) {
const d = domain || location.hostname;
const allTombstones = this.getTombstones();
if (!Array.isArray(allTombstones[d])) {
allTombstones[d] = [];
}
const existing = allTombstones[d].find((t) => t.id === id);
if (existing) {
existing.deletedAt = Date.now();
} else {
allTombstones[d].push({ id, deletedAt: Date.now() });
}
this.saveTombstones(allTombstones);
},
clearTombstone(id, domain) {
const d = domain || location.hostname;
const allTombstones = this.getTombstones();
if (Array.isArray(allTombstones[d])) {
allTombstones[d] = allTombstones[d].filter((t) => t.id !== id);
this.saveTombstones(allTombstones);
}
},
async addRecord(name, rawSessionData) {
const domain = location.hostname;
const records = this.getRecords(domain);
const cipherObject = await CryptoEngine.encrypt(rawSessionData);
const now = Date.now();
const newRecord = {
id: "sess_" + now + "_" + Math.random().toString(36).slice(2, 7),
name: name.trim(),
domain: domain,
url: location.href,
createdAt: now,
updatedAt: now,
summary: rawSessionData.summary,
cipherData: cipherObject
};
// 清除可能存在的同 ID 墓碑
this.clearTombstone(newRecord.id, domain);
records.unshift(newRecord);
this.saveRecords(records, domain);
CryptoEngine.wipeMemory(rawSessionData);
// 触发自动同步调度
if (typeof GistSyncEngine !== "undefined" && GistSyncEngine.scheduleAutoSync) {
GistSyncEngine.scheduleAutoSync();
}
return newRecord;
},
updateRecordName(id, newName, domain) {
const d = domain || location.hostname;
const records = this.getRecords(d);
const target = records.find((r) => r.id === id);
if (target) {
target.name = newName.trim();
target.updatedAt = Date.now();
this.saveRecords(records, d);
// 触发自动同步调度
if (typeof GistSyncEngine !== "undefined" && GistSyncEngine.scheduleAutoSync) {
GistSyncEngine.scheduleAutoSync();
}
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);
// 关键:持久化记录墓碑,保证云端在后续同步中同步清除该记录!
this.recordTombstone(id, d);
// 触发自动同步调度
if (typeof GistSyncEngine !== "undefined" && GistSyncEngine.scheduleAutoSync) {
GistSyncEngine.scheduleAutoSync();
}
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;
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;
}
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()
};
this.clearTombstone(record.id, d);
existing.unshift(record);
count++;
}
if (count > 0) {
this.saveRecords(existing, d);
if (typeof GistSyncEngine !== "undefined" && GistSyncEngine.scheduleAutoSync) {
GistSyncEngine.scheduleAutoSync();
}
}
return { count, skipped };
},
getAllDomains() {
const domains = new Set();
try {
if (typeof GM_listValues === "function") {
const keys = GM_listValues();
for (const key of keys) {
if (key.startsWith("SESSION_DATA_")) {
const dom = key.replace("SESSION_DATA_", "");
if (dom) domains.add(dom);
}
}
}
} catch (e) {}
domains.add(location.hostname);
const hostRules = getHostRules();
for (const h of hostRules) {
try {
const hostOnly = h.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").replace(/^\*\./, "");
if (hostOnly && !hostOnly.includes("*")) {
domains.add(hostOnly);
}
} catch (e) {}
}
return Array.from(domains);
},
getAllLocalData() {
const domains = this.getAllDomains();
const recordsMap = {};
let totalCount = 0;
for (const d of domains) {
const recs = this.getRecords(d);
if (recs && recs.length > 0) {
recordsMap[d] = recs;
totalCount += recs.length;
}
}
return {
format: "LSM_GIST_SYNC",
version: "1.3.0",
lastSyncTime: GM_getValue("Config.sync_last_time", 0),
totalSnapshots: totalCount,
records: recordsMap,
tombstones: this.getTombstones()
};
},
applyMergedData(mergedRecordsMap, mergedTombstonesMap) {
if (mergedRecordsMap && typeof mergedRecordsMap === "object") {
for (const [domain, recs] of Object.entries(mergedRecordsMap)) {
this.saveRecords(Array.isArray(recs) ? recs : [], domain);
}
}
if (mergedTombstonesMap && typeof mergedTombstonesMap === "object") {
this.saveTombstones(mergedTombstonesMap);
}
},
overwriteAllLocalData(remoteRecordsMap, remoteTombstonesMap) {
const localDomains = this.getAllDomains();
for (const d of localDomains) {
this.saveRecords([], d);
}
this.applyMergedData(remoteRecordsMap, remoteTombstonesMap);
},
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);
}
};
// -----------------------------------------------------------------------
// GitHub Gist 云同步引擎 (双向增量合并 / 墓碑判定 / 首次连接冲突协商)
// -----------------------------------------------------------------------
const GistSyncEngine = {
GIST_FILENAME: "web_snapshot_manager_sync.json",
syncLock: false,
autoSyncTimer: null,
httpRequest(options) {
return new Promise((resolve, reject) => {
const { method = "GET", url, headers = {}, data } = options;
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest({
method,
url,
headers: {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "WebSnapshotManager-Tampermonkey-Script",
...headers
},
data: data ? (typeof data === "string" ? data : JSON.stringify(data)) : undefined,
timeout: 20000,
onload: (res) => {
try {
let parsed = null;
if (res.responseText) {
try {
parsed = JSON.parse(res.responseText);
} catch (e) {
parsed = res.responseText;
}
}
if (res.status >= 200 && res.status < 300) {
resolve({ status: res.status, data: parsed, headers: res.responseHeaders });
} else {
const errorMsg = (parsed && parsed.message) || `HTTP ${res.status}: ${res.statusText || "请求失败"}`;
reject(new Error(errorMsg));
}
} catch (err) {
reject(err);
}
},
onerror: () => reject(new Error("网络请求失败,请检查网络或代理设置")),
ontimeout: () => reject(new Error("网络请求超时 (20秒)"))
});
} else {
fetch(url, {
method,
headers: {
"Accept": "application/vnd.github.v3+json",
...headers
},
body: data ? (typeof data === "string" ? data : JSON.stringify(data)) : undefined
})
.then(async (res) => {
const text = await res.text();
let parsed = null;
try {
parsed = JSON.parse(text);
} catch (e) {
parsed = text;
}
if (res.ok) {
resolve({ status: res.status, data: parsed });
} else {
reject(new Error((parsed && parsed.message) || `HTTP ${res.status}`));
}
})
.catch((e) => reject(new Error("Fetch 网络异常: " + e.message)));
}
});
},
getToken() {
return (GM_getValue("Config.sync_gist_token", "") || "").trim();
},
getGistId() {
return (GM_getValue("Config.sync_gist_id", "") || "").trim();
},
isAutoSyncEnabled() {
return Boolean(GM_getValue("Config.sync_auto", false));
},
getLastSyncTime() {
return GM_getValue("Config.sync_last_time", 0);
},
setSyncConfig(config) {
if (typeof config.token === "string") GM_setValue("Config.sync_gist_token", config.token.trim());
if (typeof config.gistId === "string") GM_setValue("Config.sync_gist_id", config.gistId.trim());
if (typeof config.autoSync === "boolean") GM_setValue("Config.sync_auto", config.autoSync);
if (typeof config.lastSyncTime === "number") GM_setValue("Config.sync_last_time", config.lastSyncTime);
},
async testConnection(customToken) {
const token = (customToken !== undefined ? customToken : this.getToken()).trim();
if (!token) throw new Error("请输入 GitHub Personal Access Token");
const res = await this.httpRequest({
method: "GET",
url: "https://api.github.com/user",
headers: { "Authorization": `Bearer ${token}` }
});
return {
success: true,
login: res.data.login,
name: res.data.name || res.data.login,
avatar_url: res.data.avatar_url,
public_gists: res.data.public_gists,
private_gists: res.data.total_private_gists
};
},
async listUserGists(token) {
const t = (token || this.getToken()).trim();
if (!t) throw new Error("请先输入有效的 GitHub Token");
const res = await this.httpRequest({
method: "GET",
url: "https://api.github.com/gists?per_page=100",
headers: { "Authorization": `Bearer ${t}` }
});
const gists = Array.isArray(res.data) ? res.data : [];
return gists.map((g) => {
const files = g.files ? Object.keys(g.files) : [];
const isSnapshotGist = files.includes(this.GIST_FILENAME);
return {
id: g.id,
description: g.description || "(未命名 Gist)",
isPublic: g.public,
files: files,
isSnapshotGist: isSnapshotGist,
createdAt: g.created_at,
updatedAt: g.updated_at
};
});
},
async fetchRemoteGist(token, gistId) {
const t = (token || this.getToken()).trim();
const gid = (gistId || this.getGistId()).trim();
if (!t) throw new Error("未配置 GitHub Token");
if (!gid) throw new Error("未配置 Gist ID");
const res = await this.httpRequest({
method: "GET",
url: `https://api.github.com/gists/${gid}`,
headers: { "Authorization": `Bearer ${t}` }
});
const files = res.data.files || {};
const targetFile = files[this.GIST_FILENAME];
if (!targetFile) {
return {
exists: true,
isEmpty: true,
records: {},
tombstones: {},
lastSyncTime: 0,
rawGist: res.data
};
}
let contentObj = {};
try {
contentObj = JSON.parse(targetFile.content);
} catch (e) {
throw new Error("Gist 内快照数据 JSON 解析失败,格式可能已损坏");
}
return {
exists: true,
isEmpty: false,
records: (contentObj && typeof contentObj.records === "object") ? contentObj.records : {},
tombstones: (contentObj && typeof contentObj.tombstones === "object") ? contentObj.tombstones : {},
lastSyncTime: contentObj.lastSyncTime || 0,
version: contentObj.version || "1.0",
rawGist: res.data
};
},
async createGist(token, initialData) {
const t = (token || this.getToken()).trim();
if (!t) throw new Error("请先输入有效的 GitHub Token");
const dataToSave = initialData || DB.getAllLocalData();
dataToSave.lastSyncTime = Date.now();
const res = await this.httpRequest({
method: "POST",
url: "https://api.github.com/gists",
headers: {
"Authorization": `Bearer ${t}`,
"Content-Type": "application/json"
},
data: {
description: "网站快照存储与恢复助手 - 云同步备份数据 (WebSnapshotManager)",
public: false,
files: {
[this.GIST_FILENAME]: {
content: JSON.stringify(dataToSave, null, 2)
}
}
}
});
const newGistId = res.data.id;
if (!newGistId) throw new Error("创建 Gist 失败,GitHub 未返回 Gist ID");
this.setSyncConfig({ gistId: newGistId, lastSyncTime: Date.now() });
return { gistId: newGistId, htmlUrl: res.data.html_url };
},
async updateGist(token, gistId, dataToSave) {
const t = (token || this.getToken()).trim();
const gid = (gistId || this.getGistId()).trim();
if (!t) throw new Error("未配置 GitHub Token");
if (!gid) throw new Error("未配置 Gist ID");
const payload = {
...dataToSave,
format: "LSM_GIST_SYNC",
version: "1.3.0",
lastSyncTime: Date.now()
};
await this.httpRequest({
method: "PATCH",
url: `https://api.github.com/gists/${gid}`,
headers: {
"Authorization": `Bearer ${t}`,
"Content-Type": "application/json"
},
data: {
description: "网站快照存储与恢复助手 - 云同步备份数据 (WebSnapshotManager)",
files: {
[this.GIST_FILENAME]: {
content: JSON.stringify(payload, null, 2)
}
}
}
});
this.setSyncConfig({ lastSyncTime: payload.lastSyncTime });
return payload;
},
mergeSnapshotData(localData, remoteData) {
const localRecs = localData.records || {};
const remoteRecs = remoteData.records || {};
const localTombstones = localData.tombstones || {};
const remoteTombstones = remoteData.tombstones || {};
const allDomains = new Set([
...Object.keys(localRecs),
...Object.keys(remoteRecs),
...Object.keys(localTombstones),
...Object.keys(remoteTombstones)
]);
const mergedTombstones = {};
const expireThreshold = Date.now() - 30 * 24 * 3600 * 1000;
for (const d of allDomains) {
const lT = Array.isArray(localTombstones[d]) ? localTombstones[d] : [];
const rT = Array.isArray(remoteTombstones[d]) ? remoteTombstones[d] : [];
const tombMap = new Map();
for (const t of [...lT, ...rT]) {
if (!t || !t.id || !t.deletedAt) continue;
if (t.deletedAt <= expireThreshold) continue;
if (!tombMap.has(t.id) || tombMap.get(t.id).deletedAt < t.deletedAt) {
tombMap.set(t.id, { id: t.id, deletedAt: t.deletedAt });
}
}
if (tombMap.size > 0) {
mergedTombstones[d] = Array.from(tombMap.values());
}
}
const mergedRecords = {};
let totalMergedCount = 0;
let purgedByTombstoneCount = 0;
let addedFromRemoteCount = 0;
let updatedCount = 0;
for (const d of allDomains) {
const lList = Array.isArray(localRecs[d]) ? localRecs[d] : [];
const rList = Array.isArray(remoteRecs[d]) ? remoteRecs[d] : [];
const dTombs = mergedTombstones[d] || [];
const tombIdMap = new Map(dTombs.map((t) => [t.id, t.deletedAt]));
const recordMap = new Map();
const isDeleted = (rec) => {
if (!rec || !rec.id) return true;
if (tombIdMap.has(rec.id)) {
const delTime = tombIdMap.get(rec.id);
const recTime = rec.updatedAt || rec.createdAt || 0;
if (delTime >= recTime) {
return true;
}
}
return false;
};
for (const rec of lList) {
if (isDeleted(rec)) {
purgedByTombstoneCount++;
continue;
}
recordMap.set(rec.id, rec);
}
for (const rRec of rList) {
if (isDeleted(rRec)) {
purgedByTombstoneCount++;
continue;
}
if (!recordMap.has(rRec.id)) {
recordMap.set(rRec.id, rRec);
addedFromRemoteCount++;
} else {
const lRec = recordMap.get(rRec.id);
const lTime = lRec.updatedAt || lRec.createdAt || 0;
const rTime = rRec.updatedAt || rRec.createdAt || 0;
if (rTime > lTime) {
recordMap.set(rRec.id, rRec);
updatedCount++;
}
}
}
const finalDomainRecs = Array.from(recordMap.values()).sort(
(a, b) => (b.createdAt || b.updatedAt || 0) - (a.createdAt || a.updatedAt || 0)
);
if (finalDomainRecs.length > 0) {
mergedRecords[d] = finalDomainRecs;
totalMergedCount += finalDomainRecs.length;
}
}
return {
records: mergedRecords,
tombstones: mergedTombstones,
totalSnapshots: totalMergedCount,
stats: {
totalMergedCount,
purgedByTombstoneCount,
addedFromRemoteCount,
updatedCount
}
};
},
async twoWaySync(options = {}) {
const { silent = false } = options;
if (this.syncLock) {
if (!silent && typeof showToast === "function") {
showToast("已有同步任务正在进行中,请稍候...", "info");
}
return { success: false, reason: "locked" };
}
const token = this.getToken();
const gistId = this.getGistId();
if (!token) {
if (!silent && typeof showToast === "function") {
showToast("请先在云同步设置中配置 GitHub Token", "error");
}
return { success: false, reason: "no_token" };
}
if (!gistId) {
if (!silent && typeof showToast === "function") {
showToast("请先配置 Gist ID 或点击一键自动创建", "error");
}
return { success: false, reason: "no_gist_id" };
}
this.syncLock = true;
this.notifySyncStatus("syncing");
try {
if (!silent && typeof showToast === "function") {
showToast("正在与 GitHub Gist 双向同步...", "info");
}
const localData = DB.getAllLocalData();
const remoteData = await this.fetchRemoteGist(token, gistId);
const mergedResult = this.mergeSnapshotData(localData, remoteData);
DB.applyMergedData(mergedResult.records, mergedResult.tombstones);
await this.updateGist(token, gistId, {
records: mergedResult.records,
tombstones: mergedResult.tombstones,
totalSnapshots: mergedResult.totalSnapshots
});
this.notifySyncStatus("success");
if (typeof refreshList === "function") {
refreshList();
}
if (!silent && typeof showToast === "function") {
const s = mergedResult.stats;
let msg = `同步成功!当前全域共 ${mergedResult.totalSnapshots} 条快照`;
if (s.addedFromRemoteCount > 0 || s.purgedByTombstoneCount > 0) {
msg += ` (拉取 +${s.addedFromRemoteCount},清理已删 -${s.purgedByTombstoneCount})`;
}
showToast(msg, "success");
}
return { success: true, result: mergedResult };
} catch (err) {
this.notifySyncStatus("error", err.message);
if (!silent && typeof showToast === "function") {
showToast(`同步失败: ${err.message}`, "error");
}
return { success: false, error: err };
} finally {
this.syncLock = false;
}
},
scheduleAutoSync() {
if (!this.isAutoSyncEnabled()) return;
const token = this.getToken();
const gistId = this.getGistId();
if (!token || !gistId) return;
if (this.autoSyncTimer) {
clearTimeout(this.autoSyncTimer);
}
this.autoSyncTimer = setTimeout(() => {
this.twoWaySync({ silent: true, checkConflict: false });
}, 2000);
},
checkSyncOnOpen() {
if (!this.isAutoSyncEnabled()) return;
const token = this.getToken();
const gistId = this.getGistId();
if (!token || !gistId) return;
const last = this.getLastSyncTime();
const now = Date.now();
if (!last || now - last > 5 * 60 * 1000) {
this.twoWaySync({ silent: true, checkConflict: false });
}
},
notifySyncStatus(status, errorMsg) {
if (typeof updateCloudStatusUI === "function") {
updateCloudStatusUI(status, errorMsg);
}
}
};
// -----------------------------------------------------------------------
// 辅助工具
// -----------------------------------------------------------------------
function formatTime(timestamp) {
if (!timestamp) return "-";
const d = new Date(timestamp);
if (isNaN(d.getTime())) return "-";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function getDefaultName() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${location.hostname}_${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function downloadJsonFile(filename, contentObj) {
try {
const jsonStr = JSON.stringify(contentObj, null, 2);
const blob = new Blob([jsonStr], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} catch (e) {
alert("下载文件失败: " + e.message);
}
}
function downloadCanvasAsImage(canvas, filename) {
try {
const url = canvas.toDataURL("image/png");
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
}, 100);
} catch (e) {
alert("下载图片失败: " + e.message);
}
}
function readFileAsJson(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
resolve(json);
} catch (err) {
reject(new Error("文件解析失败,请确认选择的是正确的 JSON 格式文件"));
}
};
reader.onerror = () => reject(new Error("读取文件出错"));
reader.readAsText(file);
});
}
// -----------------------------------------------------------------------
// UI 结构与样式
// -----------------------------------------------------------------------
const uid = "lsm-" + Math.random().toString(36).slice(2, 8);
const container = document.createElement("div");
container.id = "lsm-session-manager-root";
const shadow = container.attachShadow({ mode: "open" });
ThemeEngine.bindShadow(shadow, uid);
document.documentElement.appendChild(container);
const style = document.createElement("style");
style.textContent = `
#${uid}-root {
all: initial;
display: block;
box-sizing: border-box;
font-family: var(--lsm-font-family, system-ui, -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Segoe UI", Roboto, Helvetica, Arial, sans-serif);
color: var(--lsm-text-primary, #24231f);
font-size: 13px;
line-height: 1.5;
text-align: left;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: 0.01em;
}
#${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;
}
/* 全局滚动条通用美化 (Shadow DOM 内部全部容器与列表) */
#${uid}-root * {
scrollbar-width: thin;
scrollbar-color: var(--lsm-border-hover, #d0cec6) transparent;
}
#${uid}-root *::-webkit-scrollbar {
width: 6px;
height: 6px;
}
#${uid}-root *::-webkit-scrollbar-track {
background: transparent;
border-radius: 9999px;
}
#${uid}-root *::-webkit-scrollbar-thumb {
background-color: var(--lsm-border-hover, #d0cec6);
border-radius: 9999px;
border: 1px solid transparent;
background-clip: padding-box;
transition: background-color 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
#${uid}-root *::-webkit-scrollbar-thumb:hover {
background-color: var(--lsm-accent, #c56473);
}
#${uid}-root *::-webkit-scrollbar-corner {
background: transparent;
}
/* 悬浮球 (动态主题变量驱动) */
#${uid}-ball {
position: fixed;
left: auto;
top: auto;
right: 25px;
bottom: 80px;
z-index: 2147483646;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--lsm-bg-paper, #faf9f5);
border: 1px solid var(--lsm-accent-border, rgba(197, 100, 115, 0.28));
color: var(--lsm-accent, #c56473);
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px -2px var(--lsm-accent-glow, rgba(197, 100, 115, 0.2)), 0 2px 6px rgba(0, 0, 0, 0.05);
cursor: grab;
user-select: none;
opacity: 0.75;
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), border-color 0.2s ease;
}
#${uid}-ball:hover {
opacity: 1;
transform: scale(1.05);
box-shadow: 0 10px 28px -4px var(--lsm-accent-glow, rgba(197, 100, 115, 0.32)), 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--lsm-accent, rgba(197, 100, 115, 0.55));
}
#${uid}-ball.dragging {
opacity: 1;
cursor: grabbing;
transform: scale(0.96);
transition: none;
}
#${uid}-ball svg {
width: 22px;
height: 22px;
fill: currentColor;
pointer-events: none;
filter: drop-shadow(0 1px 2px var(--lsm-accent-glow, rgba(197, 100, 115, 0.15)));
}
/* 悬浮球右上角微型关闭/菜单按钮 */
.${uid}-ball-close {
position: absolute;
top: -3px;
right: -3px;
width: 17px;
height: 17px;
border-radius: 50%;
line-height: 15px;
background: var(--lsm-text-primary, #24231f);
color: var(--lsm-bg-paper, #faf9f5);
font-size: 11px;
text-align: center;
cursor: pointer;
display: none;
z-index: 3;
border: 1.5px solid var(--lsm-bg-paper, #faf9f5);
box-shadow: 0 2px 5px rgba(0,0,0,0.15);
transition: background 0.15s ease, transform 0.15s ease;
}
#${uid}-ball:hover .${uid}-ball-close {
display: block;
}
.${uid}-ball-close:hover {
background: var(--lsm-color-danger, #a64953);
transform: scale(1.15);
}
/* 徽标 */
.${uid}-badge {
position: absolute;
top: -3px;
left: -3px;
background: var(--lsm-accent, #c56473);
color: #ffffff;
font-size: 10px;
font-weight: 600;
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
font-variant-numeric: tabular-nums;
min-width: 18px;
height: 18px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border: 1.5px solid var(--lsm-bg-paper, #faf9f5);
box-shadow: 0 2px 6px var(--lsm-accent-glow, rgba(197, 100, 115, 0.35));
}
/* 悬浮球快捷菜单遮罩与弹窗 */
.${uid}-menu-mask {
position: fixed;
inset: 0;
z-index: 2147483646;
background: rgba(20, 19, 18, 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: var(--lsm-bg-paper, #faf9f5);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-window, 16px);
box-shadow: 0 20px 45px -10px rgba(0, 0, 0, 0.18), 0 1px 3px rgba(0,0,0,0.04);
padding: 18px;
width: 280px;
animation: lsmFadeIn .2s cubic-bezier(0.16, 1, 0.3, 1);
}
.${uid}-ball-menu-title {
font-weight: 600;
margin: 0 0 12px;
font-size: 14px;
color: var(--lsm-text-primary, #24231f);
display: flex;
align-items: center;
gap: 6px;
}
.${uid}-ball-menu button {
display: flex;
align-items: center;
width: 100%;
margin-top: 8px;
padding: 8px 12px;
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-btn, 8px);
background: var(--lsm-bg-card, #ffffff);
cursor: pointer;
font-size: 12px;
font-weight: 500;
text-align: left;
color: var(--lsm-text-primary, #403f3a);
transition: all 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
.${uid}-ball-menu button:hover {
background: var(--lsm-bg-hover, #f0efeb);
border-color: var(--lsm-border-hover, #d0cec6);
color: var(--lsm-text-primary, #24231f);
}
.${uid}-ball-menu button[data-a="forever"] {
border-color: var(--lsm-border-danger, rgba(166, 73, 83, 0.2));
background: var(--lsm-bg-danger, rgba(166, 73, 83, 0.06));
color: var(--lsm-color-danger, #a64953);
}
.${uid}-ball-menu button[data-a="forever"]:hover {
background: var(--lsm-bg-danger, rgba(166, 73, 83, 0.12));
border-color: var(--lsm-color-danger, rgba(166, 73, 83, 0.35));
}
/* 主管理窗口 (Paper Card 架构) */
#${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: var(--lsm-bg-paper, #faf9f5);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-window, 16px);
overflow: hidden;
overflow: clip;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.18), 0 1px 3px rgba(0, 0, 0, 0.04);
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: 12px 18px;
background: var(--lsm-bg-header, #f0efeb);
color: var(--lsm-text-primary, #24231f);
font-size: 13.5px;
font-weight: 600;
cursor: grab;
user-select: none;
border-bottom: 1px solid var(--lsm-border, #e3e1db);
position: relative;
}
#${uid}-header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1.5px;
background: linear-gradient(90deg, transparent 0%, var(--lsm-accent, #c56473) 20%, var(--lsm-accent, #c56473) 80%, transparent 100%);
opacity: 0.7;
}
#${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.02em;
}
.${uid}-domain-tag {
background: var(--lsm-accent-bg, rgba(197, 100, 115, 0.08));
border: 1px solid var(--lsm-accent-border, rgba(197, 100, 115, 0.2));
color: var(--lsm-accent, #c56473);
font-size: 11px;
padding: 2px 9px;
border-radius: 6px;
font-weight: 500;
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
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: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
color: var(--lsm-text-muted, #787670);
border-radius: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
}
.${uid}-header-actions button:hover {
background: var(--lsm-border, #e3e1db);
color: var(--lsm-text-primary, #24231f);
}
/* 状态条 */
.${uid}-status-bar {
padding: 7px 18px;
background: var(--lsm-bg-paper, #faf9f5);
border-bottom: 1px solid var(--lsm-border, #e3e1db);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--lsm-text-muted, #787670);
flex: none;
}
.${uid}-status-item {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.${uid}-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.${uid}-dot-green {
background: var(--lsm-color-success, #5e9f7e);
box-shadow: 0 0 0 2px var(--lsm-border-success, rgba(94, 159, 126, 0.2));
}
.${uid}-dot-amber {
background: var(--lsm-color-warning, #a87a3d);
box-shadow: 0 0 0 2px var(--lsm-border-warning, rgba(168, 122, 61, 0.2));
}
/* 操作工具栏 */
.${uid}-toolbar {
padding: 10px 18px;
background: var(--lsm-bg-paper, #faf9f5);
border-bottom: 1px solid var(--lsm-border, #e3e1db);
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: 5px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border-radius: var(--lsm-radius-btn, 8px);
border: 1px solid transparent;
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.${uid}-btn:active {
transform: translateY(1px);
}
.${uid}-btn-primary {
background: var(--lsm-accent-bg, rgba(197, 100, 115, 0.08)) !important;
color: var(--lsm-accent, #c56473) !important;
border: 1px solid var(--lsm-accent-border, rgba(197, 100, 115, 0.3)) !important;
}
.${uid}-btn-primary:hover {
background: var(--lsm-accent-hover-bg, rgba(197, 100, 115, 0.14)) !important;
border-color: var(--lsm-accent, #c56473) !important;
color: var(--lsm-accent, #c56473) !important;
}
.${uid}-btn-secondary {
background: transparent;
color: var(--lsm-text-primary, #403f3a);
border: 1px solid var(--lsm-border, #e3e1db);
}
.${uid}-btn-secondary:hover {
background: var(--lsm-bg-hover, #f0efeb);
border-color: var(--lsm-border-hover, #d0cec6);
color: var(--lsm-text-primary, #24231f);
}
.${uid}-btn-danger {
background: var(--lsm-bg-danger, rgba(166, 73, 83, 0.08));
color: var(--lsm-color-danger, #a64953);
border: 1px solid var(--lsm-border-danger, rgba(166, 73, 83, 0.25));
}
.${uid}-btn-danger:hover {
background: var(--lsm-bg-danger, rgba(166, 73, 83, 0.14));
border-color: var(--lsm-color-danger, rgba(166, 73, 83, 0.4));
}
.${uid}-btn-restore-pill {
background: var(--lsm-bg-success, rgba(94, 159, 126, 0.08)) !important;
color: var(--lsm-color-success, #5e9f7e) !important;
border: 1px solid var(--lsm-border-success, rgba(94, 159, 126, 0.28)) !important;
font-weight: 500;
}
.${uid}-btn-restore-pill:hover {
background: var(--lsm-bg-success, rgba(94, 159, 126, 0.16)) !important;
border-color: var(--lsm-color-success, rgba(94, 159, 126, 0.45)) !important;
}
.${uid}-btn-sm {
padding: 4px 9px;
font-size: 11px;
border-radius: 6px;
}
.${uid}-btn-icon {
padding: 6px 8px !important;
min-width: 28px;
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: 180px;
background: var(--lsm-bg-paper, #faf9f5);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 12px);
padding: 5px;
box-shadow: 0 12px 30px -4px rgba(0, 0, 0, 0.16), 0 1px 3px rgba(0,0,0,0.04);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
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: 7px 10px;
font-size: 12px;
font-weight: 500;
color: var(--lsm-text-primary, #403f3a);
border-radius: 7px;
cursor: pointer;
user-select: none;
transition: all 0.12s ease;
white-space: nowrap;
}
.${uid}-dropdown-item:hover {
background: var(--lsm-bg-hover, #f0efeb);
color: var(--lsm-text-primary, #24231f);
}
.${uid}-dropdown-divider {
height: 1px;
background: var(--lsm-border, #e3e1db);
margin: 3px 0;
}
.${uid}-item-accent {
color: var(--lsm-accent, #c56473) !important;
font-weight: 500;
}
.${uid}-item-accent:hover {
background: var(--lsm-accent-bg, rgba(197, 100, 115, 0.08)) !important;
color: var(--lsm-accent, #c56473) !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: 5px 8px;
font-size: 11px;
gap: 4px;
}
.${uid}-content {
padding: 10px 12px;
gap: 8px;
}
.${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: var(--lsm-text-placeholder, #a8a69f);
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 var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-btn, 8px);
background: var(--lsm-bg-card, #ffffff);
background-image: linear-gradient(180deg, var(--lsm-accent-glow, rgba(197, 100, 115, 0.03)) 0%, transparent 26%);
font-size: 12px;
color: var(--lsm-text-primary, #24231f);
outline: none;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.${uid}-search-input::placeholder {
color: var(--lsm-text-placeholder, #a8a69f);
font-size: 11px;
}
.${uid}-search-input:focus {
background: var(--lsm-bg-card, #ffffff);
border-color: var(--lsm-accent-border, rgba(197, 100, 115, 0.35));
box-shadow: 0 0 0 3px var(--lsm-accent-glow, rgba(197, 100, 115, 0.1));
}
.${uid}-search-wrap:focus-within .${uid}-search-icon {
color: var(--lsm-accent, #c56473);
}
.${uid}-search-clear {
position: absolute;
right: 7px;
width: 17px;
height: 17px;
border-radius: 50%;
border: none;
background: var(--lsm-border, #e3e1db);
color: var(--lsm-text-muted, #787670);
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: var(--lsm-border-hover, #d0cec6);
color: var(--lsm-text-primary, #24231f);
transform: scale(1.08);
}
.${uid}-search-clear.hidden {
display: none !important;
}
/* 记录列表区域 */
.${uid}-content {
padding: 12px 18px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
background: var(--lsm-bg-list, #f9f8f5);
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.${uid}-card {
background: var(--lsm-bg-card, #ffffff);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 12px);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.${uid}-card:hover {
border-color: var(--lsm-border-hover, #d0cec6);
transform: translateY(-1px);
box-shadow: 0 6px 20px -2px rgba(0, 0, 0, 0.06);
}
.${uid}-card.${uid}-card-active {
border-color: var(--lsm-border-success, rgba(94, 159, 126, 0.38));
background: var(--lsm-bg-active-card, linear-gradient(180deg, rgba(94, 159, 126, 0.05) 0%, #ffffff 60%));
box-shadow: 0 4px 14px -2px var(--lsm-border-success, rgba(94, 159, 126, 0.12));
}
.${uid}-badge-active {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
background: var(--lsm-bg-success, rgba(94, 159, 126, 0.12));
color: var(--lsm-color-success, #5e9f7e);
border: 1px solid var(--lsm-border-success, rgba(94, 159, 126, 0.25));
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: 600;
font-size: 13px;
color: var(--lsm-text-primary, #24231f);
word-break: break-all;
display: flex;
align-items: center;
gap: 6px;
}
.${uid}-card-time {
font-size: 11px;
color: var(--lsm-text-muted, #787670);
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
font-variant-numeric: tabular-nums;
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 7px;
border-radius: 6px;
line-height: 1.35;
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
font-variant-numeric: tabular-nums;
}
.${uid}-chip-cookie {
background: var(--lsm-bg-warning, rgba(168, 122, 61, 0.08));
color: var(--lsm-color-warning, #a87a3d);
border: 1px solid var(--lsm-border-warning, rgba(168, 122, 61, 0.2));
}
.${uid}-chip-local {
background: var(--lsm-bg-success, rgba(94, 159, 126, 0.08));
color: var(--lsm-color-success, #5e9f7e);
border: 1px solid var(--lsm-border-success, rgba(94, 159, 126, 0.2));
}
.${uid}-chip-session {
background: var(--lsm-bg-info, rgba(61, 104, 150, 0.08));
color: var(--lsm-color-info, #3d6896);
border: 1px solid var(--lsm-border-info, rgba(61, 104, 150, 0.2));
}
.${uid}-chip-encrypted {
background: var(--lsm-accent-bg, rgba(197, 100, 115, 0.08));
color: var(--lsm-accent, #c56473);
border: 1px solid var(--lsm-accent-border, rgba(197, 100, 115, 0.2));
}
/* 来源链接小标签 */
.${uid}-card-origin {
display: flex;
align-items: center;
gap: 6px;
background: var(--lsm-bg-header, #f0efeb);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: 6px;
padding: 3px 8px;
margin-top: 2px;
font-size: 11px;
color: var(--lsm-text-muted, #787670);
}
.${uid}-card-url {
color: var(--lsm-accent, #c56473);
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;
}
/* 卡片操作栏 */
.${uid}-card-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
border-top: 1px solid var(--lsm-border, #f0efeb);
padding-top: 8px;
margin-top: 2px;
}
.${uid}-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 0;
color: var(--lsm-text-placeholder, #a8a69f);
text-align: center;
gap: 8px;
}
/* 抽屉基础样式 (未打开时强制 display: none,彻底杜绝外层窗口滚动穿透或焦点跳转导致的错位) */
.${uid}-save-dialog,
.${uid}-qr-dialog,
.${uid}-scan-dialog,
.${uid}-sync-dialog,
.${uid}-theme-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--lsm-bg-paper, #faf9f5);
display: none;
flex-direction: column;
padding: 20px;
gap: 14px;
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;
border-top: 1px solid var(--lsm-border, #e3e1db);
box-sizing: border-box;
pointer-events: none;
}
.${uid}-save-dialog.open,
.${uid}-qr-dialog.open,
.${uid}-scan-dialog.open,
.${uid}-sync-dialog.open,
.${uid}-theme-dialog.open {
display: flex !important;
transform: translateY(0);
pointer-events: auto;
}
.${uid}-dialog-header,
.${uid}-save-dialog-title {
font-size: 14.5px;
font-weight: 600;
color: var(--lsm-text-primary, #24231f);
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
border-bottom: 1px solid var(--lsm-border, #e3e1db);
padding-bottom: 10px;
}
.${uid}-dialog-title {
font-size: 14.5px;
font-weight: 600;
color: var(--lsm-text-primary, #24231f);
display: flex;
align-items: center;
gap: 6px;
}
.${uid}-dialog-close {
background: none;
border: none;
font-size: 16px;
line-height: 1;
color: var(--lsm-text-muted, #787670);
cursor: pointer;
padding: 4px;
border-radius: 6px;
transition: color 0.15s ease, background 0.15s ease;
}
.${uid}-dialog-close:hover {
color: var(--lsm-text-primary, #24231f);
background: var(--lsm-bg-hover, #f0efeb);
}
.${uid}-input-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.${uid}-input-label {
font-size: 12px;
font-weight: 500;
color: var(--lsm-text-secondary, #5c5a55);
}
.${uid}-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-btn, 8px);
font-size: 12.5px;
outline: none;
background: var(--lsm-bg-card, #ffffff);
background-image: linear-gradient(180deg, var(--lsm-accent-glow, rgba(197, 100, 115, 0.03)) 0%, transparent 26%);
color: var(--lsm-text-primary, #24231f);
transition: border-color 0.2s, box-shadow 0.2s;
}
.${uid}-input:focus {
border-color: var(--lsm-accent-border, rgba(197, 100, 115, 0.35));
box-shadow: 0 0 0 3px var(--lsm-accent-glow, rgba(197, 100, 115, 0.1));
}
.${uid}-grid-preview {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 4px;
}
.${uid}-stat-box {
background: var(--lsm-bg-card, #ffffff);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 10px);
padding: 10px;
text-align: center;
}
.${uid}-stat-num {
font-size: 18px;
font-weight: 600;
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
font-variant-numeric: tabular-nums;
color: var(--lsm-text-primary, #24231f);
margin-top: 2px;
}
.${uid}-stat-label {
font-size: 11px;
color: var(--lsm-text-muted, #787670);
}
/* 快照二维码展示抽屉 */
.${uid}-qr-box {
background: var(--lsm-bg-header, #f0efeb);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 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: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 12px);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
}
.${uid}-qr-canvas-wrap canvas {
max-width: 100%;
height: auto !important;
display: block;
border-radius: 6px;
}
.${uid}-qr-overflow-box {
background: var(--lsm-bg-warning, rgba(168, 122, 61, 0.06));
border: 1px solid var(--lsm-border-warning, rgba(168, 122, 61, 0.25));
border-radius: var(--lsm-radius-card, 12px);
padding: 16px 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
max-width: 100%;
}
.${uid}-qr-chunk-player {
display: none;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
background: var(--lsm-bg-card, #ffffff);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 12px);
padding: 12px;
}
.${uid}-qr-chunk-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-size: 12px;
color: var(--lsm-text-primary, #403f3a);
font-weight: 500;
}
.${uid}-qr-chunk-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--lsm-accent-bg, rgba(197, 100, 115, 0.08));
color: var(--lsm-accent, #c56473);
border: 1px solid var(--lsm-accent-border, rgba(197, 100, 115, 0.2));
padding: 3px 8px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
}
.${uid}-qr-chunk-bar-wrap {
width: 100%;
height: 5px;
background: var(--lsm-border, #e3e1db);
border-radius: 9999px;
overflow: hidden;
}
.${uid}-qr-chunk-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--lsm-accent, #c56473), var(--lsm-accent-glow, #e08c99));
border-radius: 9999px;
transition: width 0.15s ease;
}
.${uid}-qr-chunk-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
}
/* 摄像头扫码 */
.${uid}-camera-viewport {
position: relative;
width: 100%;
height: 220px;
background: #141312;
border-radius: var(--lsm-radius-card, 12px);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(0,0,0,0.25) 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: #a8a69f;
gap: 8px;
padding: 20px;
text-align: center;
font-size: 12px;
background: rgba(20, 19, 18, 0.92);
z-index: 2;
}
.${uid}-scan-chunk-hud {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
background: rgba(20, 19, 18, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--lsm-accent-border, rgba(197, 100, 115, 0.4));
border-radius: 10px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 6;
color: #faf9f5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.${uid}-scan-chunk-title {
font-size: 11px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
color: #faf9f5;
}
.${uid}-scan-chunk-chips {
display: flex;
flex-wrap: wrap;
gap: 3px;
max-height: 48px;
overflow-y: auto;
padding: 2px 0;
}
.${uid}-scan-chunk-dot {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 600;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
color: #a8a69f;
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: ui-monospace, "Cascadia Code PL", Consolas, monospace;
transition: all 0.15s ease;
}
.${uid}-scan-chunk-dot.received {
background: var(--lsm-color-success, #5e9f7e);
color: #ffffff;
border-color: var(--lsm-border-success, #8cbea3);
box-shadow: 0 0 5px var(--lsm-color-success, rgba(94, 159, 126, 0.7));
transform: scale(1.05);
}
.${uid}-scan-frame {
position: absolute;
width: 170px;
height: 170px;
border: 2px solid var(--lsm-accent, rgba(197, 100, 115, 0.7));
border-radius: 12px;
box-shadow: 0 0 0 9999px rgba(20, 19, 18, 0.5);
z-index: 3;
pointer-events: none;
}
.${uid}-scan-corner {
position: absolute;
width: 16px;
height: 16px;
border-color: var(--lsm-accent, #c56473);
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, transparent 0%, var(--lsm-accent, #c56473) 50%, transparent 100%);
box-shadow: 0 0 10px var(--lsm-accent, #c56473), 0 0 4px var(--lsm-color-danger, #a64953);
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: var(--lsm-bg-card, #ffffff);
border: 1px dashed var(--lsm-border-hover, #d0cec6);
border-radius: var(--lsm-radius-card, 10px);
cursor: pointer;
transition: all 0.15s ease;
color: var(--lsm-text-primary, #403f3a);
gap: 6px;
text-align: center;
}
.${uid}-dropzone-btn:hover {
background: var(--lsm-bg-hover, #f0efeb);
border-color: var(--lsm-accent, #c56473);
color: var(--lsm-accent, #c56473);
}
.${uid}-dropzone-label {
font-size: 12px;
font-weight: 500;
}
.${uid}-dropzone-hint {
font-size: 10px;
color: var(--lsm-text-muted, #787670);
}
/* 识别成功结果展示卡片 */
.${uid}-result-card {
background: var(--lsm-bg-success, rgba(94, 159, 126, 0.06));
border: 1px solid var(--lsm-border-success, rgba(94, 159, 126, 0.25));
border-radius: var(--lsm-radius-card, 12px);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.${uid}-result-title {
font-size: 13px;
font-weight: 600;
color: var(--lsm-color-success, #5e9f7e);
display: flex;
align-items: center;
gap: 6px;
}
.${uid}-result-domain-warn {
background: var(--lsm-bg-warning, rgba(168, 122, 61, 0.08));
border: 1px solid var(--lsm-border-warning, rgba(168, 122, 61, 0.25));
border-radius: 8px;
padding: 8px 10px;
font-size: 11px;
color: var(--lsm-color-warning, #a87a3d);
line-height: 1.5;
}
/* 云同步状态指示点与动效 */
.${uid}-dot-cloud {
background: var(--lsm-text-placeholder, #a8a69f);
}
.${uid}-dot-cloud.ok {
background: var(--lsm-color-success, #5e9f7e);
box-shadow: 0 0 0 2px var(--lsm-border-success, rgba(94, 159, 126, 0.2));
}
.${uid}-dot-cloud.syncing {
background: var(--lsm-accent, #c56473);
box-shadow: 0 0 0 2px var(--lsm-accent-glow, rgba(197, 100, 115, 0.2));
animation: ${uid}Pulse 1.2s infinite;
}
.${uid}-dot-cloud.warn {
background: var(--lsm-color-warning, #a87a3d);
}
.${uid}-dot-cloud.err {
background: var(--lsm-color-danger, #a64953);
}
@keyframes ${uid}Pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
@keyframes ${uid}Spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.${uid}-icon-sync.spinning {
animation: ${uid}Spin 0.85s linear infinite;
}
/* 云同步专属抽屉 */
.${uid}-sync-body,
.${uid}-theme-body {
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
flex: 1;
min-height: 0;
padding-right: 0;
box-sizing: border-box;
}
.${uid}-sync-status-card,
.${uid}-theme-current-card {
background: var(--lsm-bg-card, #ffffff);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 12px);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.${uid}-sync-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--lsm-bg-card, #ffffff);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: 10px;
}
/* Switch 切换开关 */
.${uid}-switch {
position: relative;
display: inline-block;
width: 38px;
height: 22px;
flex-shrink: 0;
}
.${uid}-switch input {
opacity: 0;
width: 0;
height: 0;
}
.${uid}-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--lsm-border-hover, #d0cec6);
transition: .2s;
border-radius: 22px;
}
.${uid}-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s cubic-bezier(0.22, 1, 0.36, 1);
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.${uid}-switch input:checked + .${uid}-slider {
background-color: var(--lsm-accent, #c56473);
}
.${uid}-switch input:checked + .${uid}-slider:before {
transform: translateX(16px);
}
/* 主题管理网格 (Theme Grid) */
.${uid}-theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 2px;
}
.${uid}-theme-item {
background: var(--lsm-bg-card, #ffffff);
border: 1px solid var(--lsm-border, #e3e1db);
border-radius: var(--lsm-radius-card, 10px);
padding: 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.${uid}-theme-item:hover {
border-color: var(--lsm-accent, #c56473);
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--lsm-accent-glow, rgba(0,0,0,0.06));
}
.${uid}-theme-item.${uid}-theme-item-active {
border-color: var(--lsm-accent, #c56473);
background: var(--lsm-accent-bg, rgba(197, 100, 115, 0.04));
box-shadow: 0 0 0 1.5px var(--lsm-accent, #c56473);
}
.${uid}-theme-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.${uid}-theme-item-title {
font-weight: 600;
font-size: 12px;
color: var(--lsm-text-primary, #24231f);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${uid}-theme-item-palette {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
.${uid}-theme-swatch-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.12);
flex-shrink: 0;
}
/* Toast 提示 */
.${uid}-toast {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%) translateY(-10px);
background: var(--lsm-text-primary, #24231f);
color: var(--lsm-bg-paper, #faf9f5);
padding: 8px 16px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25);
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: var(--lsm-color-success, #5e9f7e);
color: #ffffff;
}
.${uid}-toast.error {
background: var(--lsm-color-danger, #a64953);
color: #ffffff;
}
.${uid}-toast.info {
background: var(--lsm-text-primary, #24231f);
color: var(--lsm-bg-paper, #faf9f5);
}
`;
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");
}
// -----------------------------------------------------------------------
// 云同步状态 UI 刷新与抽屉交互
// -----------------------------------------------------------------------
function updateCloudStatusUI(status, errorMsg) {
const token = GistSyncEngine.getToken();
const gistId = GistSyncEngine.getGistId();
const lastTime = GistSyncEngine.getLastSyncTime();
const lastTimeStr = lastTime ? formatTime(lastTime) : "从未同步";
if (status === "syncing") {
if (dotCloud) dotCloud.className = `${uid}-dot ${uid}-dot-cloud syncing`;
if (textCloud) textCloud.textContent = "☁️ 云同步: 同步中...";
if (syncToolbarIcon) syncToolbarIcon.classList.add("spinning");
if (syncDrawerIcon) syncDrawerIcon.classList.add("spinning");
if (syncCardStatusTitle) syncCardStatusTitle.textContent = "☁️ 正在与 GitHub Gist 同步...";
if (syncCardBadge) {
syncCardBadge.textContent = "同步中";
syncCardBadge.style.cssText = "background:#dbeafe;color:#2563eb;";
}
if (syncCardDesc) syncCardDesc.textContent = "正在双向传输与合并全域快照数据,请稍候...";
if (syncCardMeta) syncCardMeta.textContent = `上次同步: ${lastTimeStr}`;
return;
}
if (syncToolbarIcon) syncToolbarIcon.classList.remove("spinning");
if (syncDrawerIcon) syncDrawerIcon.classList.remove("spinning");
if (status === "error") {
if (dotCloud) dotCloud.className = `${uid}-dot ${uid}-dot-cloud err`;
if (textCloud) textCloud.textContent = "☁️ 云同步: 异常";
if (syncCardStatusTitle) syncCardStatusTitle.textContent = "☁️ 云同步异常";
if (syncCardBadge) {
syncCardBadge.textContent = "连接失败";
syncCardBadge.style.cssText = "background:#fee2e2;color:#991b1b;";
}
if (syncCardDesc) syncCardDesc.textContent = `错误原因: ${errorMsg || "网络连接超时或 Token 无效"}`;
if (syncCardMeta) syncCardMeta.textContent = `上次同步: ${lastTimeStr}`;
return;
}
if (token && gistId) {
if (dotCloud) dotCloud.className = `${uid}-dot ${uid}-dot-cloud ok`;
if (textCloud) textCloud.textContent = "☁️ 云同步: 已连接";
if (syncCardStatusTitle) syncCardStatusTitle.textContent = "☁️ GitHub Gist 云同步就绪";
if (syncCardBadge) {
syncCardBadge.textContent = "已连接";
syncCardBadge.style.cssText = "background:#dcfce7;color:#166534;";
}
if (syncCardDesc) syncCardDesc.textContent = "已与 GitHub Gist 建立连接,支持双向智能增量合并与防抖自动同步。";
if (syncCardMeta) syncCardMeta.textContent = `上次同步: ${lastTimeStr}`;
} else if (token && !gistId) {
if (dotCloud) dotCloud.className = `${uid}-dot ${uid}-dot-cloud warn`;
if (textCloud) textCloud.textContent = "☁️ 云同步: 待绑定Gist";
if (syncCardStatusTitle) syncCardStatusTitle.textContent = "☁️ Token 已就绪,请绑定 Gist";
if (syncCardBadge) {
syncCardBadge.textContent = "待绑定";
syncCardBadge.style.cssText = "background:#fef3c7;color:#b45309;";
}
if (syncCardDesc) syncCardDesc.textContent = "已输入 Token,请点击下方「🚀 自动创建 Gist」或手动填入已有 Gist ID。";
if (syncCardMeta) syncCardMeta.textContent = `上次同步: ${lastTimeStr}`;
} else {
if (dotCloud) dotCloud.className = `${uid}-dot ${uid}-dot-cloud`;
if (textCloud) textCloud.textContent = "☁️ 云同步: 未配置";
if (syncCardStatusTitle) syncCardStatusTitle.textContent = "☁️ 未配置云同步";
if (syncCardBadge) {
syncCardBadge.textContent = "未连接";
syncCardBadge.style.cssText = "background:#f1f5f9;color:#64748b;";
}
if (syncCardDesc) syncCardDesc.textContent = "配置 GitHub Token 和 Gist ID 后即可实现跨浏览器/多设备快照自动增量同步与安全备份。";
if (syncCardMeta) syncCardMeta.textContent = `上次同步: ${lastTimeStr}`;
}
}
function openCloudSyncDialog() {
if (!syncDialog) return;
closeAllDrawers();
if (win) win.scrollTop = 0;
if (syncDialog) syncDialog.scrollTop = 0;
if (syncInputToken) syncInputToken.value = GistSyncEngine.getToken();
if (syncInputGistId) syncInputGistId.value = GistSyncEngine.getGistId();
if (syncSwitchAuto) syncSwitchAuto.checked = GistSyncEngine.isAutoSyncEnabled();
updateCloudStatusUI();
syncDialog.classList.add("open");
}
function closeCloudSyncDialog() {
if (syncDialog) {
syncDialog.classList.remove("open");
}
}
// 搜索并选择已有 Gist 模态框
async function showGistPickerModal(token) {
if (document.querySelector(".lsm-gist-picker-mask")) return;
ensureHostAnimationStyle();
const t = (token || (syncInputToken ? syncInputToken.value.trim() : "") || GistSyncEngine.getToken()).trim();
if (!t) {
showToast("请先在上方输入有效的 GitHub Token", "error");
if (syncInputToken) syncInputToken.focus();
return;
}
showToast("正在从 GitHub 获取 Gist 列表...", "info");
let gists = [];
try {
gists = await GistSyncEngine.listUserGists(t);
} catch (err) {
showToast(`获取 Gist 列表失败: ${err.message}`, "error");
return;
}
if (!gists.length) {
showToast("当前 GitHub 账号下未找到任何 Gist,请点击「🚀 自动创建 Gist」", "info");
return;
}
const mask = document.createElement("div");
mask.className = "lsm-dlg-mask lsm-gist-picker-mask";
mask.style.cssText =
"position:fixed;inset:0;z-index:2147483647;background:rgba(20,19,18,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"display:flex;align-items:center;justify-content:center;overscroll-behavior:contain;font-family:system-ui,-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;";
bindScrollLock(mask, null);
const box = document.createElement("div");
box.style.cssText =
"width:480px;max-width:calc(100vw - 32px);max-height:85vh;background:#faf9f5;border:1px solid #e3e1db;border-radius:16px;" +
"padding:20px 22px;box-shadow:0 20px 45px -10px rgba(36,35,31,0.18),0 1px 3px rgba(0,0,0,0.04);box-sizing:border-box;" +
"display:flex;flex-direction:column;gap:12px;animation:lsmFadeIn .2s cubic-bezier(0.16,1,0.3,1);";
const headerRow = document.createElement("div");
headerRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;";
headerRow.innerHTML = `
${escapeHtml(filesSummary || "无")}
更新于: ${updateDate}