NGS-划词数据库

带详细日志的版本:仅在 task 页面生效,划词结束后弹出菜单,含中文时不弹窗,方便调试打印日志。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         NGS-划词数据库
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  带详细日志的版本:仅在 task 页面生效,划词结束后弹出菜单,含中文时不弹窗,方便调试打印日志。
// @author       QXY
// @match        http://ngs-report.mtttt.cn/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = "[NGS Gan]";
    function log(...args) { console.log(LOG_PREFIX, ...args); }
    function warn(...args) { console.warn(LOG_PREFIX, ...args); }
    function error(...args) { console.error(LOG_PREFIX, ...args); }

    log("脚本开始装载...");

    const TARGET_HASH_PREFIX = "#/ngsReport-taskMake?taskId=";

    let active = false;
    let cleanupFn = null;

    // 主入口,捕获异常并打印
    try {
        window.addEventListener("hashchange", () => {
            log("hashchange 事件触发,当前 hash:", location.hash);
            checkAndActivate();
        });

        // 首次检查(页面首次加载时)
        log("首次检测当前 URL 和 hash:", location.href, location.hash);
        checkAndActivate();
    } catch (e) {
        error("初始化时发生异常:", e);
    }

    function checkAndActivate() {
        try {
            if (location.hash && location.hash.startsWith(TARGET_HASH_PREFIX)) {
                log("检测到目标 hash 前缀 -> 目标页面。");
                if (!active) {
                    log("准备激活脚本...");
                    active = true;
                    try {
                        cleanupFn = initScript();
                        log("脚本已激活,cleanupFn 存在:", typeof cleanupFn === "function");
                    } catch (e) {
                        error("initScript 执行错误:", e);
                    }
                } else {
                    log("脚本已激活,跳过重复激活。");
                }
            } else {
                log("当前不是目标 hash 页面(或 hash 为空)。hash:", location.hash);
                if (active) {
                    log("正在停用脚本(离开目标页面)...");
                    active = false;
                    try {
                        if (cleanupFn) cleanupFn();
                        cleanupFn = null;
                        log("脚本已停用并清理完毕。");
                    } catch (e) {
                        error("清理过程中发生错误:", e);
                    }
                } else {
                    log("脚本当前未激活,无需停用。");
                }
            }
        } catch (e) {
            error("checkAndActivate 内部异常:", e);
        }
    }

    function initScript() {
        log("initScript 开始。读取配置...");
        const defaultConfig = {
            links: {
                "OncoKB": "https://www.oncokb.org/gene/{gene}",
                "Genecards": "https://www.genecards.org/cgi-bin/carddisp.pl?gene={gene}",
                "PubMed": "https://pubmed.ncbi.nlm.nih.gov/?term={gene}",
                "ClinVar": "https://www.ncbi.nlm.nih.gov/clinvar/?term={gene}"
            }
        };

        let config;
        try {
            config = GM_getValue("geneLinkConfig", defaultConfig);
            log("配置读取完成:", config);
        } catch (e) {
            warn("读取 GM_getValue 时出错,使用默认配置:", e);
            config = defaultConfig;
        }

        try {
            GM_registerMenuCommand("📝 编辑 Gene Link 配置", () => {
                const input = prompt("请输入 JSON 配置:", JSON.stringify(config, null, 2));
                if (!input) return;
                try {
                    const parsed = JSON.parse(input);
                    config = parsed;
                    GM_setValue("geneLinkConfig", config);
                    alert("配置已保存 ✅");
                    log("用户通过菜单编辑并保存了配置:", config);
                } catch (err) {
                    alert("JSON 格式错误: " + err.message);
                    warn("用户输入 JSON 格式错误:", err);
                }
            });

            GM_registerMenuCommand("🔄 重置为默认配置", () => {
                config = defaultConfig;
                GM_setValue("geneLinkConfig", defaultConfig);
                alert("已重置为默认配置 ✅");
                log("配置已重置为默认:", defaultConfig);
            });
        } catch (e) {
            warn("注册菜单命令时出错(可能在某些环境不支持):", e);
        }

        // ======= 创建 UI =======
        const menu = document.createElement("div");
        Object.assign(menu.style, {
            position: "absolute",
            display: "none",
            background: "linear-gradient(180deg,#ffffff,#fbfdff)",
            border: "1px solid rgba(0,0,0,0.08)",
            borderRadius: "12px",
            boxShadow: "0 12px 36px rgba(18,35,60,0.14)",
            padding: "8px",
            fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial",
            fontSize: "15px",
            minWidth: "240px",
            zIndex: "2147483647",
            userSelect: "none",
            transformOrigin: "center top",
            transition: "opacity 0.12s ease, transform 0.12s ease",
            opacity: "0",
        });

        const header = document.createElement("div");
        Object.assign(header.style, {
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            padding: "8px 10px",
            borderBottom: "1px solid rgba(0,0,0,0.04)",
            marginBottom: "8px"
        });

        const headerText = document.createElement("div");
        headerText.style.fontWeight = "700";
        headerText.style.color = "#0b2545";
        headerText.textContent = "🔗";

        const gearBtn = document.createElement("button");
        gearBtn.textContent = "⚙";
        Object.assign(gearBtn.style, {
            background: "transparent",
            border: "none",
            cursor: "pointer",
            fontSize: "16px",
            padding: "4px"
        });
        gearBtn.title = "编辑配置";

        header.appendChild(headerText);
        header.appendChild(gearBtn);
        menu.appendChild(header);

        const list = document.createElement("div");
        list.style.display = "flex";
        list.style.flexDirection = "column";
        list.style.gap = "8px";
        menu.appendChild(list);
        document.body.appendChild(menu);

        gearBtn.addEventListener("click", (e) => {
            e.stopPropagation();
            const input = prompt("编辑 JSON 配置:", JSON.stringify(config, null, 2));
            if (!input) return;
            try {
                const parsed = JSON.parse(input);
                config = parsed;
                GM_setValue("geneLinkConfig", config);
                alert("配置已保存 ✅");
                log("用户通过 UI Gear 保存配置:", config);
            } catch (err) {
                alert("JSON 格式错误: " + err.message);
                warn("UI 编辑配置 JSON 错误:", err);
            }
        });

        // ======= 仅在鼠标松开后触发(避免闪烁)=======
        const mouseupHandler = (ev) => {
            try {
                log("mouseup 触发,准备检查选区。 mouse event:", ev.type, "target:", ev.target && ev.target.tagName);
                setTimeout(() => {
                    const sel = window.getSelection();
                    if (!sel) { log("window.getSelection() 返回 null/undefined"); hideMenu(); return; }
                    if (sel.rangeCount === 0) { log("selection rangeCount 为 0"); hideMenu(); return; }
                    if (sel.isCollapsed) { log("selection isCollapsed"); hideMenu(); return; }

                    const raw = sel.toString();
                    log("选中原始文本(未 trim):", JSON.stringify(raw));
                    const text = raw.trim();
                    log("选中文本(trim 后):", JSON.stringify(text));
                    if (!text) { log("trim 后为空,不显示"); hideMenu(); return; }

                    // 检测中文(如果包含中文则不弹出)
                    if (/[\u4e00-\u9fa5]/.test(text)) {
                        log("选中文本包含中文,取消弹窗。文本:", text);
                        hideMenu(); return;
                    }

                    // 检查是否过长或包含换行(可视需要调整)
                    if (text.length > 200) {
                        log("选中文本过长,已忽略(长度):", text.length);
                        hideMenu(); return;
                    }

                    const range = sel.getRangeAt(0);
                    let rect = range.getBoundingClientRect();
                    if (!rect || (rect.width === 0 && rect.height === 0)) {
                        rect = getRectByTemporaryNode(range);
                        log("通过临时节点获取 rect:", rect);
                    } else {
                        log("直接获取到 rect:", rect);
                    }
                    showMenuAt(text, rect);
                }, 120); // 稍微延迟等待稳定
            } catch (e) {
                error("mouseupHandler 内部异常:", e);
            }
        };

        const mousedownHandler = (e) => {
            try {
                if (!menu.contains(e.target)) {
                    log("mousedown 在菜单外,隐藏菜单。 target:", e.target && e.target.tagName);
                    hideMenu();
                } else {
                    log("mousedown 在菜单上,保留菜单。");
                }
            } catch (e) {
                warn("mousedownHandler 异常:", e);
            }
        };

        const keyupHandler = (e) => {
            if (e.key === "Escape") {
                log("检测到 Escape,隐藏菜单");
                hideMenu();
            }
        };

        document.addEventListener("mouseup", mouseupHandler);
        document.addEventListener("mousedown", mousedownHandler, true);
        document.addEventListener("keyup", keyupHandler, true);

        function getRectByTemporaryNode(range) {
            try {
                const zb = document.createElement("span");
                zb.textContent = "\u200b";
                range.insertNode(zb);
                const r = zb.getBoundingClientRect();
                zb.remove();
                return r;
            } catch (e) {
                warn("getRectByTemporaryNode 异常:", e);
                return { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 };
            }
        }

        function showMenuAt(rawText, rect) {
            try {
                log("准备显示菜单,rawText:", rawText, "rect:", rect);
                headerText.textContent = `🔗 ${rawText}`;
                list.innerHTML = "";

                const entries = Object.entries((config && config.links) ? config.links : defaultConfig.links);
                if (entries.length === 0) {
                    const none = document.createElement("div");
                    none.textContent = "未配置任何链接(通过脚本菜单编辑)";
                    none.style.padding = "8px";
                    none.style.color = "#666";
                    list.appendChild(none);
                } else {
                    entries.forEach(([name, template]) => {
                        const btn = document.createElement("button");
                        btn.textContent = name;
                        Object.assign(btn.style, {
                            padding: "10px",
                            borderRadius: "10px",
                            border: "none",
                            background: "linear-gradient(180deg,#f7f9ff,#eef4ff)",
                            cursor: "pointer",
                            fontWeight: "600",
                            color: "#034ea2",
                            boxShadow: "0 6px 16px rgba(3,78,162,0.06)",
                            transition: "transform .08s ease, box-shadow .08s ease"
                        });

                        btn.addEventListener("mouseenter", () => {
                            btn.style.transform = "translateY(-3px)";
                            btn.style.boxShadow = "0 12px 26px rgba(3,78,162,0.12)";
                        });
                        btn.addEventListener("mouseleave", () => {
                            btn.style.transform = "none";
                            btn.style.boxShadow = "0 6px 16px rgba(3,78,162,0.06)";
                        });

                        btn.addEventListener("click", (e) => {
                            e.stopPropagation();
                            try {
                                let gene = cleanText(rawText);
                                log("点击链接,原始 gene:", rawText, "cleaned:", gene);

                                if (/clinvar/i.test(name)) {
                                    const geneName = gene.split(":")[0];
                                    const cMatch = gene.match(/c\.[^:\s]+/);
                                    if (cMatch) gene = `${geneName} ${cMatch[0]}`;
                                    log("ClinVar 特殊处理后 gene:", gene);
                                }

                                let href;
                                if (/oncokb/i.test(name)) {
                                    href = template.replace(/{\s*gene\s*}/gi, gene);
                                } else {
                                    const encoded = encodeURIComponent(gene);
                                    href = template.replace(/{\s*gene\s*}/gi, encoded);
                                }

                                log("打开链接:", href);
                                openInNewTab(href);
                                hideMenu();
                            } catch (e) {
                                error("点击链接处理异常:", e);
                            }
                        });

                        list.appendChild(btn);
                    });
                }

                const padding = 12;
                const popupW = 320;
                menu.style.width = popupW + "px";
                const scrollY = window.scrollY || window.pageYOffset;
                const scrollX = window.scrollX || window.pageXOffset;
                let top = scrollY + rect.bottom + 10;
                let left = scrollX + rect.left + (rect.width / 2) - (popupW / 2);
                left = Math.max(padding + scrollX, Math.min(left, (document.documentElement.clientWidth - popupW - padding) + scrollX));

                menu.style.left = `${left}px`;
                menu.style.top = `${top}px`;
                menu.style.display = "block";
                requestAnimationFrame(() => {
                    menu.style.opacity = "1";
                    menu.style.transform = "scale(1)";
                });
                log("菜单已显示(DOM 更新完毕)");
            } catch (e) {
                error("showMenuAt 异常:", e);
            }
        }

        function hideMenu() {
            try {
                if (menu.style.display === "none") {
                    log("hideMenu 调用:菜单已隐藏状态,跳过。");
                    return;
                }
                log("正在隐藏菜单...");
                menu.style.opacity = "0";
                menu.style.transform = "scale(0.98)";
                setTimeout(() => {
                    if (menu.style.opacity === "0") {
                        menu.style.display = "none";
                        log("菜单已完全隐藏(display none)。");
                    }
                }, 140);
            } catch (e) {
                warn("hideMenu 异常:", e);
            }
        }

        function openInNewTab(href) {
            try {
                const a = document.createElement("a");
                a.href = href;
                a.target = "_blank";
                a.rel = "noopener";
                document.body.appendChild(a);
                a.click();
                a.remove();
                log("openInNewTab 已执行。");
            } catch (e) {
                warn("openInNewTab 出错:", e);
            }
        }

        function cleanText(str) {
            try {
                const s = str
                    .replace(/%20/gi, " ")
                    .replace(/[\n\t\r\s]+/g, " ")
                    .trim()
                    .replace(/\s+/g, "/");
                return s;
            } catch (e) {
                warn("cleanText 出错,返回原始:", e);
                return str;
            }
        }

        // 返回清理函数(用于停用时移除事件和 DOM)
        return () => {
            try {
                log("执行 cleanup:隐藏菜单,移除事件,销毁 DOM。");
                hideMenu();
                document.removeEventListener("mouseup", mouseupHandler);
                document.removeEventListener("mousedown", mousedownHandler, true);
                document.removeEventListener("keyup", keyupHandler, true);
                if (menu && menu.parentNode) menu.remove();
                log("cleanup 完成。");
            } catch (e) {
                warn("cleanup 异常:", e);
            }
        };
    }

})();