// ==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;
}
}
})();