Destiny2_Term_replace

替换网页中出现的命运2术语

目前為 2025-02-16 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Destiny2_Term_replace
// @namespace    your-namespace
// @version      2.3
// @description  替换网页中出现的命运2术语
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      20xiji.github.io
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const ITEM_LIST_URL = 'https://20xiji.github.io/Destiny-item-list/Destiny2_term.json';
    let replacementHistory = [];
    let termMap = new Map();
    let currentMode = 1;
    let dialogVisible = false;
    let dialogXOffset = 0;
    let dialogYOffset = 0;
    let isDragging = false;
    let posObjs = [];
    let hintDialogVisible = false; // 新增提示对话框显示状态

    GM_addStyle(`
        #textReplacerDialog {
            position: fixed;
            top: 20px;
            right: 20px;
            background: #1a1a1a;
            padding: 15px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.25);
            z-index: 9999;
            width: 260px;
            font-family: Arial, sans-serif;
            color: #fff;
            display: none;
            overflow: visible;
        }
        #textReplacerDialog.dragging {
            cursor: grabbing;
        }
        #dialogHeader {
            cursor: grab;
            margin-bottom: 10px;
        }
        #modeButtons {
            display: grid;
            gap: 8px;
            margin: 12px 0;
        }
        .mode-btn {
            padding: 8px;
            border: none;
            border-radius: 4px;
            background: #333;
            color: #888;
            cursor: pointer;
            transition: all 0.2s;
        }
        .mode-btn.active {
            background: #4CAF50;
            color: #fff;
            box-shadow: 0 2px 4px rgba(0,0,0,0.2);
        }
        #actionButtons {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin-top: 12px;
        }
        #actionButtons button {
            flex: 1;
            padding: 8px;
            border: none;
            border-radius: 4px;
            background: #4CAF50;
            color: white;
            cursor: pointer;
            min-width: 80px;
        }
        #actionButtons button:disabled {
            background: #666;
            cursor: not-allowed;
        }
        #termCount {
            font-size: 12px;
            color: #888;
            margin-left: 8px;
        }
        #btnClearCache {
            background: #f44336 !important;
        }
        .dialogButton { /* 统一关闭和提示按钮样式 */
            position: absolute;
            top: 8px;
            width: 12px;
            height: 12px;
            border-radius: 50%;
            background-color: #ff6058;
            border: 1px solid #e0443e;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            box-shadow: 0 1px 0 rgba(0,0,0,.1);
            padding: 0;
            z-index: 10000;
        }
        .dialogButton:hover {
            background-color: #f0413a;
            border-color: #d02828;
        }
        .dialogButton::before {
            content: '';
            display: block;
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background-color: #fff;
            transform: scale(0.5); /* 调整小白点初始大小 */
            opacity: 0;
            transition: opacity 0.2s ease, transform 0.2s ease; /* 添加transform过渡 */
        }
        .dialogButton:hover::before {
            opacity: 1;
            transform: scale(1);
        }
        #dialogCloseButton {
            right: 8px;
        }
        #dialogHintButton {
            right: 30px; /* 提示按钮位置在关闭按钮左侧 */
            background-color: #ffc107; /* 提示按钮颜色 */
            border-color: #e0a300;
        }
        #dialogHintButton:hover {
            background-color: #f0b200;
            border-color: #d09500;
        }
        #dialogHintButton:hover::before {
            background-color: #333; /* 提示按钮悬停小白点颜色 */
        }
        #hintDialog {
            position: fixed;
            top: 60px; /* 调整提示框的垂直位置 */
            right: 20px;
            background: #333;
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.25);
            z-index: 10001; /* 确保提示框在最上层 */
            width: 300px; /* 调整宽度 */
            font-size: 14px;
            line-height: 1.6;
            display: none; /* 初始隐藏 */
        }
        #hintDialog p {
            margin-bottom: 10px;
        }
        #hintDialog p:last-child {
            margin-bottom: 0;
        }
    `);

    const dialog = document.createElement('div');
    dialog.id = 'textReplacerDialog';

    const dialogHeader = document.createElement('div');
    dialogHeader.id = 'dialogHeader';
    dialogHeader.style.margin = '0 0 10px 0';
    dialogHeader.style.fontSize = '16px';
    dialogHeader.textContent = '文本替换工具 ';
    dialog.appendChild(dialogHeader);

    const termCountSpan = document.createElement('span');
    termCountSpan.id = 'termCount';
    termCountSpan.textContent = '(加载中...)';
    dialogHeader.appendChild(termCountSpan);

    const modeButtonsDiv = document.createElement('div');
    modeButtonsDiv.id = 'modeButtons';

    const modeButton1 = document.createElement('button');
    modeButton1.className = 'mode-btn';
    modeButton1.dataset.mode = '1';
    modeButton1.textContent = '中文模式';
    modeButtonsDiv.appendChild(modeButton1);

    const modeButton2 = document.createElement('button');
    modeButton2.className = 'mode-btn';
    modeButton2.dataset.mode = '2';
    modeButton2.textContent = '英文|中文';
    modeButtonsDiv.appendChild(modeButton2);

    const modeButton3 = document.createElement('button');
    modeButton3.className = 'mode-btn';
    modeButton3.dataset.mode = '3';
    modeButton3.textContent = '中文(英文)';
    modeButtonsDiv.appendChild(modeButton3);


    const actionButtonsDiv = document.createElement('div');
    actionButtonsDiv.id = 'actionButtons';

    const btnApplyAll = document.createElement('button');
    btnApplyAll.id = 'btnApplyAll';
    btnApplyAll.textContent = '应用规则';
    actionButtonsDiv.appendChild(btnApplyAll);

    const btnUndo = document.createElement('button');
    btnUndo.id = 'btnUndo';
    btnUndo.textContent = '撤销';
    btnUndo.disabled = true;
    actionButtonsDiv.appendChild(btnUndo);

    const btnClearCache = document.createElement('button');
    btnClearCache.id = 'btnClearCache';
    btnClearCache.textContent = '清除缓存';
    actionButtonsDiv.appendChild(btnClearCache);

    const closeButton = document.createElement('button');
    closeButton.id = 'dialogCloseButton';
    closeButton.className = 'dialogButton'; // 添加统一样式类
    closeButton.addEventListener('click', toggleDialog);
    dialog.appendChild(closeButton);

    // 新增提示按钮
    const hintButton = document.createElement('button');
    hintButton.id = 'dialogHintButton';
    hintButton.className = 'dialogButton'; // 添加统一样式类
    hintButton.addEventListener('click', toggleHintDialog); // 添加点击事件监听器
    dialog.appendChild(hintButton);

    // 创建提示对话框
    const hintDialog = document.createElement('div');
    hintDialog.id = 'hintDialog';
    hintDialog.textContent = `
        <p>网页多层嵌套操作说明:</p>
        <p>当处理采用多层嵌套结构的网页时,系统表现如下特点:</p>
        <ol>
            <li>非快捷键触发场景<br>
            当用户使用不使用快捷键调用功能面板时,由于网页存在多层嵌套,系统会同时激活两个功能面板。这两个面板各自对应不同层级网页的替换操作需求。</li>
            <li>快捷键触发场景<br>
            当用户使用快捷键调用功能面板时,系统会根据当前鼠标点击位置智能判定目标层级,此时呼出的面板仅作用于用户当前操作的网页层级。<br>
            (说明:网页结构的多层嵌套特性导致了不同触发方式下的面板响应差异,自动触发会启动全量面板,而快捷键触发则是上下文感知的精准响应)</li>
        </ol>
    `;
    document.body.appendChild(hintDialog);


    dialog.appendChild(modeButtonsDiv);
    dialog.appendChild(actionButtonsDiv);

    document.body.appendChild(dialog);

    const elements = {
        modeButtons: dialog.querySelectorAll('.mode-btn'),
        btnApplyAll: dialog.querySelector('#btnApplyAll'),
        btnUndo: dialog.querySelector('#btnUndo'),
        btnClearCache: dialog.querySelector('#btnClearCache'),
        termCount: dialog.querySelector('#termCount')
    };

    elements.modeButtons.forEach(btn => btn.addEventListener('click', handleModeChange));
    elements.btnApplyAll.addEventListener('click', applyAllRules);
    elements.btnUndo.addEventListener('click', undoReplace);
    elements.btnClearCache.addEventListener('click', clearCache);

    document.addEventListener('keydown', (e) => {
        if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'k') {
            toggleDialog();
        }
    });

    GM_registerMenuCommand("打开文本替换工具", toggleDialog);

    document.addEventListener('click', (e) => {
        if (e.target.matches('.gm-open-text-replacer')) {
            toggleDialog();
        }
    });

    // Make dialog draggable
    dialogHeader.addEventListener('mousedown', dragStart);
    document.addEventListener('mousemove', dragMove);
    document.addEventListener('mouseup', dragEnd);

    function dragStart(e) {
        isDragging = true;
        dialog.classList.add('dragging');
        dialogXOffset = dialog.offsetLeft - e.clientX;
        dialogYOffset = dialog.offsetTop - e.clientY;
    }

    function dragMove(e) {
        if (!isDragging) return;
        dialog.style.left = e.clientX + dialogXOffset + 'px';
        dialog.style.top = e.clientY + dialogYOffset + 'px';
    }

    function dragEnd() {
        isDragging = false;
        dialog.classList.remove('dragging');
    }


    initTerminology();
    updateButtonStates();

    function toggleDialog() {
        dialogVisible = !dialogVisible;
        dialog.style.display = dialogVisible ? 'block' : 'none';
        updateButtonStates();
        if (dialogVisible && hintDialogVisible) { // 关闭主面板时同时关闭提示框
            toggleHintDialog();
        }
    }

    function toggleHintDialog() {
        hintDialogVisible = !hintDialogVisible;
        hintDialog.style.display = hintDialogVisible ? 'block' : 'none';
        if (hintDialogVisible && dialogVisible === false) { // 如果提示框显示时主面板未显示,则同时显示主面板
            toggleDialog();
        }
    }

    async function clearCache() {
        try {
            GM_deleteValue('cachedTerms');
            GM_deleteValue('cacheTime');
            const freshData = await fetchTerms();
            termMap = new Map(Object.entries(freshData));
            GM_setValue('cachedTerms', freshData);
            GM_setValue('cacheTime', Date.now());
            updateTermCount();
            alert('✅ 缓存已清除并重新加载成功\n当前种类:武器、护甲、技能、模组\n已加载条目数:' + termMap.size);
        } catch (error) {
            console.error('缓存清除失败:', error);
            alert('❌ 缓存清除失败:' + error.message);
            termMap.clear();
            updateTermCount();
        }
    }

    async function initTerminology() {
        const CACHE_DAYS = 1;
        const cachedData = GM_getValue('cachedTerms');
        const cacheTime = GM_getValue('cacheTime', 0);

        try {
            if (!cachedData || Date.now() - cacheTime > 86400000 * CACHE_DAYS) {
                const freshData = await fetchTerms();
                termMap = new Map(Object.entries(freshData));
                GM_setValue('cachedTerms', freshData);
                GM_setValue('cacheTime', Date.now());
            } else {
                termMap = new Map(Object.entries(cachedData));
            }
        } catch (error) {
            console.error('术语表初始化失败:', error);
            if (cachedData) {
                termMap = new Map(Object.entries(cachedData));
            }
        }
        updateTermCount();
    }

    function updateTermCount() {
        elements.termCount.textContent = termMap.size > 0
            ? `(已加载${termMap.size}条)`
            : '(未加载数据)';
    }

    function fetchTerms() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: ITEM_LIST_URL,
                timeout: 15000,
                onload: (res) => {
                    if (res.status >= 200 && res.status < 300) {
                        try {
                            const data = JSON.parse(res.responseText);
                            if (data && data.data && Object.keys(data.data).length > 0) {
                                resolve(data.data);
                            } else {
                                reject(new Error('获取到空数据或data.data为空'));
                            }
                        } catch (e) {
                            reject(new Error('数据解析失败'));
                        }
                    } else {
                        reject(new Error(`HTTP ${res.status}`));
                    }
                },
                onerror: (err) => {
                    reject(new Error(`网络错误: ${err}`));
                },
                ontimeout: () => {
                    reject(new Error('请求超时(15秒)'));
                }
            });
        });
    }

    function handleModeChange(e) {
        currentMode = parseInt(e.target.dataset.mode);
        updateButtonStates();
    }

    function updateButtonStates() {
        elements.modeButtons.forEach(btn => {
            btn.classList.toggle('active', parseInt(btn.dataset.mode) === currentMode);
        });
    }

    function applyAllRules() {
        const termRules = Array.from(termMap).map(([en, cn]) => {
            switch (currentMode) {
                case 1: return [en, cn];
                case 2: return [en, `${en} | ${cn}`];
                case 3: return [en, `${cn}(${en})`];
                default: return [en, cn];
            }
        });
        performReplace(termRules);
    }

    function performReplace(rules) {
        const regex = buildRegex(rules);
        const replaceMap = new Map(rules);
        const snapshot = [];

        const walker = document.createTreeWalker(
            document.body,
            NodeFilter.SHOW_TEXT,
            null,
            false
        );

        while (walker.nextNode()) {
            const node = walker.currentNode;
            const original = node.nodeValue;
            const replaced = original.replace(regex, (m) => {
                const foundKey = Array.from(replaceMap.keys()).find(k =>
                    k.toLowerCase() === m.toLowerCase()
                );
                return foundKey ? replaceMap.get(foundKey) : m;
            });

            if (replaced !== original) {
                snapshot.push({ node, text: original });
                node.nodeValue = replaced;
            }
        }

        if (snapshot.length) {
            replacementHistory.push(snapshot);
            elements.btnUndo.disabled = false;
        }
    }

    function buildRegex(rules) {
        const sortedKeys = [...new Set(rules.map(([k]) => k))]
            .sort((a, b) => b.length - a.length)
            .map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));

        return new RegExp(`\\b(${sortedKeys.join('|')})\\b`, 'gi');
    }

    function undoReplace() {
        if (replacementHistory.length) {
            const last = replacementHistory.pop();
            last.forEach(({ node, text }) => {
                if (node.parentNode) node.nodeValue = text;
            });
            elements.btnUndo.disabled = !replacementHistory.length;
        }
    }
})();