// ==UserScript==
// @name Destiny2_Term_replace
// @namespace your-namespace
// @version 2.4
// @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 CACHE_DAYS = 1; // 词库缓存天数
const HISTORY_LIMIT = 20; // 撤销记录上限
const DIALOG_POS_KEY = 'dialogPos'; // 面板位置存储键
const USER_TERMS_KEY = 'userDefinedTerms'; // 自定义术语存储键
const ITEMS_PER_PAGE_KEY = 'itemsPerPageSetting';
const ITEM_LIST_URL = 'https://20xiji.github.io/Destiny-item-list/Destiny2_term.json';
let replacementHistory = [];
let termMap = new Map();
let userTerms = {}; // 持久化的自定义术语
let currentMode = 1;
let dialogVisible = false;
let dialogXOffset = 0;
let dialogYOffset = 0;
let isDragging = false;
let posObjs = [];
let hintDialogVisible = false; // 新增提示对话框显示状态
let currentPage = 1;
let itemsPerPage = GM_getValue(ITEMS_PER_PAGE_KEY, 5);
let searchTerm = '';
GM_addStyle(`
:root {
--bg-color:#1f1f1f;
--accent-color:#4caf50;
--accent-color-light:#66bb6a;
--btn-bg:#333;
--text-color:#fff;
--text-muted:#888;
}
@keyframes gm-fadein {from{opacity:0;transform:translateY(-8px);}to{opacity:1;}}
#textReplacerDialog{background:var(--bg-color);color:var(--text-color);animation:gm-fadein .25s ease-out;}
.mode-btn{background:var(--btn-bg);color:var(--text-muted);} .mode-btn:hover{background:#444;color:var(--text-color);} .mode-btn.active{background:var(--accent-color);color:var(--text-color);}
#actionButtons button{background:var(--accent-color);} #actionButtons button:hover{background:var(--accent-color-light);}
#btnClearCache{background:#f44336!important;} #btnClearCache:hover{background:#e53935!important;}
#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;
}
/* Toast 提示样式 */
.gm-toast {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 10px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 10002;
opacity: 0;
transition: opacity .3s ease;
pointer-events: none; /* 不阻挡鼠标操作 */
}
/* 批量添加术语面板(嵌入主对话框) */
#addTermPanel {
margin-top: 12px;
display: none;
}
#addTermPanel textarea {
width: 100%;
height: 100px;
background: #222;
color: #fff;
border: 1px solid #555;
border-radius: 4px;
padding: 6px;
resize: vertical;
box-sizing: border-box;
font-family: monospace;
}
#addTermPanel .panel-actions {
text-align: right;
margin-top: 6px;
}
#addTermPanel .panel-actions button {
margin-left: 8px;
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
}
#btnSaveTerms {background:#4CAF50;color:#fff;}
#btnCancelAdd {background:#666;color:#fff;}
`);
const dialog = document.createElement('div');
dialog.id = 'textReplacerDialog';
/* ===== 读取并应用历史面板位置 ===== */
const savedPos = GM_getValue(DIALOG_POS_KEY);
if (savedPos && typeof savedPos.x === 'number' && typeof savedPos.y === 'number') {
dialog.style.left = `${savedPos.x}px`;
dialog.style.top = `${savedPos.y}px`;
}
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 = '中文模式';
modeButton1.title = '将英文术语替换为纯中文';
modeButtonsDiv.appendChild(modeButton1);
const modeButton2 = document.createElement('button');
modeButton2.className = 'mode-btn';
modeButton2.dataset.mode = '2';
modeButton2.textContent = '英文|中文';
modeButton2.title = '替换为 "英文 | 中文" 组合';
modeButtonsDiv.appendChild(modeButton2);
const modeButton3 = document.createElement('button');
modeButton3.className = 'mode-btn';
modeButton3.dataset.mode = '3';
modeButton3.textContent = '中文(英文)';
modeButton3.title = '替换为 "中文(英文)" 组合';
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 btnAddTerm = document.createElement('button');
btnAddTerm.id = 'btnAddTerm';
btnAddTerm.textContent = '添加术语';
actionButtonsDiv.appendChild(btnAddTerm);
const btnExportTerms = document.createElement('button');
btnExportTerms.id = 'btnExportTerms';
btnExportTerms.textContent = '导出';
actionButtonsDiv.appendChild(btnExportTerms);
const btnImportTerms = document.createElement('button');
btnImportTerms.id = 'btnImportTerms';
btnImportTerms.textContent = '导入';
actionButtonsDiv.appendChild(btnImportTerms);
/* === 新增:管理自定义术语按钮 === */
const btnManageTerms = document.createElement('button');
btnManageTerms.id = 'btnManageTerms';
btnManageTerms.textContent = '管理术语';
actionButtonsDiv.appendChild(btnManageTerms);
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.innerHTML = `
<h3 style="margin:0 0 8px 0;">使用小贴士</h3>
<ul style="padding-left:20px;line-height:1.7">
<li><b>启动模式:</b>按 <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>K</kbd> 或 点击右上角按钮 或 鼠标右键菜单可随时打开/关闭面板</li>
<li><b>批量添加:</b>点「添加术语」后粘贴多行 <code>英文=中文</code> 或 <code>英文 中文</code> 映射即可导入</li>
<li><b>撤销:</b>点击“撤销”按钮可回退最近 20 次替换操作</li>
<li><b>自定义词库:</b>使用「导出 / 导入」按钮可备份和恢复自定义术语</li>
<li><b>缓存:</b>如词库异常,可点“清除缓存”重新下载最新数据</li>
<li><b>拖拽面板:</b>按住标题栏拖动可移动面板,位置会自动保存</li>
<li><b>多层网页:</b>在 iframe 层内单击空白处后按快捷键,只替换当前层</li>
</ul>
`;
document.body.appendChild(hintDialog);
/* === 批量添加术语面板(在主面板内部) === */
const addTermPanel = document.createElement('div');
addTermPanel.id = 'addTermPanel';
addTermPanel.innerHTML = `
<p style="font-size:12px;color:#bbb;margin:0 0 6px;line-height:1.4;">
<b>批量添加说明:</b>每行一条,英文与中文之间可使用 <code>=</code> 或 <code>|</code> 分隔。<br>
例如:<br>
<code>Gjallarhorn=加拉尔号角</code><br>
<code>Gjallarhorn|加拉尔号角</code>
</p>
<textarea id="batchTermInput" placeholder="在此粘贴或输入多行术语映射..."></textarea>
<div class="panel-actions">
<button id="btnSaveTerms">保存</button>
<button id="btnCancelAdd">取消</button>
</div>`;
// 先添加模式与操作区域,再放置批量添加面板,保证面板在最下方向下展开
dialog.appendChild(modeButtonsDiv);
dialog.appendChild(actionButtonsDiv);
dialog.appendChild(addTermPanel);
/* === 新增:管理自定义术语面板 === */
const manageTermPanel = document.createElement('div');
manageTermPanel.id = 'manageTermPanel';
manageTermPanel.style.display = 'none';
manageTermPanel.innerHTML = `
<h4 style="margin:0 0 6px 0;">我的自定义术语</h4>
<div style="margin-bottom:6px;display:flex;align-items:center;gap:4px;font-size:12px;flex-wrap:nowrap;">
<input id="termSearchInput" type="text" placeholder="搜索..." style="flex:1 1 auto;min-width:0;background:#111;border:1px solid #555;border-radius:4px;padding:4px 6px;color:#fff;" />
<label style="white-space:nowrap;">每页
<input id="itemsPerPageInput" type="number" min="1" max="100" value="20" style="width:40px;margin:0 4px;background:#111;border:1px solid #555;border-radius:4px;padding:2px 4px;color:#fff;" />
条
</label>
</div>
<div id="termsList" style="max-height:160px;overflow:auto;border:1px solid #555;padding:6px;border-radius:4px;background:#222;"></div>
<div id="paginationControls" style="margin-top:6px;text-align:center;font-size:12px;"></div>
<div class="panel-actions" style="text-align:right;margin-top:6px;">
<button id="btnCloseManage" style="background:#666;color:#fff;border:none;border-radius:4px;padding:6px 12px;cursor:pointer;">关闭</button>
</div>`;
dialog.appendChild(manageTermPanel);
const batchInput = addTermPanel.querySelector('#batchTermInput');
const btnSaveTerms = addTermPanel.querySelector('#btnSaveTerms');
const btnCancelAdd = addTermPanel.querySelector('#btnCancelAdd');
document.body.appendChild(dialog);
const elements = {
modeButtons: dialog.querySelectorAll('.mode-btn'),
btnApplyAll: dialog.querySelector('#btnApplyAll'),
btnUndo: dialog.querySelector('#btnUndo'),
btnClearCache: dialog.querySelector('#btnClearCache'),
btnAddTerm: dialog.querySelector('#btnAddTerm'),
btnExportTerms: dialog.querySelector('#btnExportTerms'),
btnImportTerms: dialog.querySelector('#btnImportTerms'),
termCount: dialog.querySelector('#termCount'),
/* === 新增 === */
btnManageTerms: dialog.querySelector('#btnManageTerms')
};
elements.modeButtons.forEach(btn => btn.addEventListener('click', handleModeChange));
elements.btnApplyAll.addEventListener('click', applyAllRules);
elements.btnUndo.addEventListener('click', undoReplace);
elements.btnClearCache.addEventListener('click', clearCache);
/* === 自定义术语按钮事件 === */
/* === 打开/关闭批量添加覆盖层 === */
let addPanelVisible = false;
function toggleAddTermPanel(show = !addPanelVisible) {
addPanelVisible = show;
addTermPanel.style.display = show ? 'block' : 'none';
if (show) batchInput.focus();
}
elements.btnAddTerm.addEventListener('click', () => toggleAddTermPanel(true));
btnCancelAdd.addEventListener('click', () => toggleAddTermPanel(false));
btnSaveTerms.addEventListener('click', () => {
const raw = batchInput.value;
if (!raw) { showToast('❌ 内容为空', false); return; }
const lines = raw.split(/\n+/);
let added = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(.+?)[=|]+(.+)$/);
if (match) {
const en = match[1].trim();
const cn = match[2].trim();
if (en && cn) {
userTerms[en] = cn;
termMap.set(en, cn);
added++;
}
}
}
if (added) {
GM_setValue(USER_TERMS_KEY, userTerms);
updateTermCount();
showToast(`✅ 已添加 ${added} 条自定义术语`);
batchInput.value = '';
toggleAddTermPanel(false);
} else {
showToast('❌ 未检测到有效输入', false);
}
});
elements.btnExportTerms.addEventListener('click', exportUserTerms);
elements.btnImportTerms.addEventListener('click', importUserTerms);
/* === 新增:管理术语按钮事件 === */
let managePanelVisible = false;
function toggleManagePanel(show = !managePanelVisible) {
managePanelVisible = show;
manageTermPanel.style.display = show ? 'block' : 'none';
if (show) {
// 同步控件默认值
manageTermPanel.querySelector('#termSearchInput').value = searchTerm = '';
const perInput = manageTermPanel.querySelector('#itemsPerPageInput');
perInput.value = itemsPerPage;
renderUserTermsList();
// 绑定事件(仅绑定一次)
if (!perInput.dataset.bound) {
const searchInput = manageTermPanel.querySelector('#termSearchInput');
searchInput.addEventListener('input', () => {
searchTerm = searchInput.value.trim().toLowerCase();
currentPage = 1;
renderUserTermsList();
});
perInput.addEventListener('change', () => {
const v = parseInt(perInput.value);
if (!v || v < 1) { perInput.value = 1; itemsPerPage = 1; }
else { itemsPerPage = v; }
GM_setValue(ITEMS_PER_PAGE_KEY, itemsPerPage);
currentPage = 1;
renderUserTermsList();
});
perInput.dataset.bound = '1';
}
}
}
elements.btnManageTerms.addEventListener('click', () => toggleManagePanel(true));
manageTermPanel.querySelector('#btnCloseManage').addEventListener('click', () => toggleManagePanel(false));
function renderUserTermsList() {
const listContainer = manageTermPanel.querySelector('#termsList');
const pageControls = manageTermPanel.querySelector('#paginationControls');
const allEntries = Object.entries(userTerms);
const filtered = allEntries.filter(([en, cn]) => en.toLowerCase().includes(searchTerm) || cn.toLowerCase().includes(searchTerm));
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
if (currentPage > totalPages) currentPage = totalPages;
const startIdx = (currentPage - 1) * itemsPerPage;
const pageEntries = filtered.slice(startIdx, startIdx + itemsPerPage);
listContainer.innerHTML = '';
if (!pageEntries.length) {
listContainer.textContent = '(无匹配结果)';
} else {
for (const [en, cn] of pageEntries) {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.margin = '2px 0';
const textSpan = document.createElement('span');
textSpan.style.fontFamily = 'monospace';
textSpan.style.fontSize = '12px';
textSpan.textContent = `${en} → ${cn}`;
const delBtn = document.createElement('button');
delBtn.textContent = '删除';
delBtn.style.background = '#f44336';
delBtn.style.border = 'none';
delBtn.style.borderRadius = '4px';
delBtn.style.color = '#fff';
delBtn.style.cursor = 'pointer';
delBtn.style.fontSize = '12px';
delBtn.addEventListener('click', () => {
deleteUserTerm(en);
renderUserTermsList();
});
row.appendChild(textSpan);
row.appendChild(delBtn);
listContainer.appendChild(row);
}
}
// 渲染分页控件
pageControls.innerHTML = '';
if (totalPages > 1) {
const prevBtn = document.createElement('button');
prevBtn.textContent = '上一页';
prevBtn.disabled = currentPage === 1;
prevBtn.style.marginRight = '8px';
prevBtn.addEventListener('click', () => { if (currentPage > 1) { currentPage--; renderUserTermsList(); } });
const nextBtn = document.createElement('button');
nextBtn.textContent = '下一页';
nextBtn.disabled = currentPage === totalPages;
nextBtn.style.marginLeft = '8px';
nextBtn.addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; renderUserTermsList(); } });
const infoSpan = document.createElement('span');
infoSpan.textContent = `第 ${currentPage} / ${totalPages} 页`;
pageControls.appendChild(prevBtn);
pageControls.appendChild(infoSpan);
pageControls.appendChild(nextBtn);
}
}
/* === 新增:删除自定义术语 === */
function deleteUserTerm(en) {
if (!userTerms[en]) return;
if (!confirm(`确定删除术语:${en} ?`)) return;
delete userTerms[en];
GM_setValue(USER_TERMS_KEY, userTerms);
termMap.delete(en);
updateTermCount();
showToast(`✅ 已删除术语:${en}`);
}
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');
// 保存当前位置
GM_setValue(DIALOG_POS_KEY, { x: dialog.offsetLeft, y: dialog.offsetTop });
}
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();
showToast(`✅ 缓存已清除并重新加载成功,已加载 ${termMap.size} 条术语`);
} catch (error) {
console.error('缓存清除失败:', error);
showToast(`❌ 缓存清除失败:${error.message}`, false);
termMap.clear();
updateTermCount();
}
}
async function initTerminology() {
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));
}
/* === 合并并加载用户自定义术语 === */
userTerms = GM_getValue(USER_TERMS_KEY, {});
if (userTerms && typeof userTerms === 'object') {
for (const [en, cn] of Object.entries(userTerms)) {
termMap.set(en, cn);
}
}
} 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 lowerMap = new Map(rules.map(([k, v]) => [k.toLowerCase(), v]));
const snapshot = [];
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA', 'NOSCRIPT']);
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
while (walker.nextNode()) {
const node = walker.currentNode;
if (SKIP_TAGS.has(node.parentNode && node.parentNode.nodeName)) continue;
const original = node.nodeValue;
const replaced = original.replace(regex, (m) => lowerMap.get(m.toLowerCase()) ?? m);
if (replaced !== original) {
snapshot.push({ node, text: original });
node.nodeValue = replaced;
}
}
if (snapshot.length) {
replacementHistory.push(snapshot);
if (replacementHistory.length > HISTORY_LIMIT) {
replacementHistory.shift();
}
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;
}
}
/* ===== 自定义术语相关 ===== */
function addUserTerm(en, cn) {
if (!en || !cn) return;
userTerms[en] = cn;
GM_setValue(USER_TERMS_KEY, userTerms);
termMap.set(en, cn);
updateTermCount();
showToast(`✅ 已添加术语:${en} → ${cn}`);
}
function exportUserTerms() {
const blob = new Blob([JSON.stringify(userTerms, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'destiny2_custom_terms.json';
a.click();
URL.revokeObjectURL(url);
}
function importUserTerms() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = () => {
if (!input.files.length) return;
const file = input.files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
if (data && typeof data === 'object') {
Object.entries(data).forEach(([en, cn]) => {
userTerms[en] = cn;
termMap.set(en, cn);
});
GM_setValue(USER_TERMS_KEY, userTerms);
updateTermCount();
showToast(`✅ 已导入 ${Object.keys(data).length} 条自定义术语`);
} else {
showToast('❌ JSON 格式不正确', false);
}
} catch (e) {
showToast('❌ 解析失败:' + e.message, false);
}
};
reader.readAsText(file);
};
input.click();
}
/* ===== Toast 提示 ===== */
function showToast(message, success = true) {
const toast = document.createElement('div');
toast.className = 'gm-toast';
toast.textContent = message;
toast.style.background = success ? 'rgba(76,175,80,0.9)' : 'rgba(244,67,54,0.9)';
document.body.appendChild(toast);
// 强制触发回流,启用过渡
void toast.offsetWidth;
toast.style.opacity = '1';
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 3000);
}
/* === 首次使用欢迎提示 === */
const WELCOME_KEY = 'hasShownWelcome_v2';
if (!GM_getValue(WELCOME_KEY)) {
// 延迟以免与面板动画冲突
setTimeout(()=>showToast('提示:按 Ctrl+Alt+K 或 点击右上角按钮 或 鼠标右键菜单 打开命运2术语替换面板'),500);
GM_setValue(WELCOME_KEY,true);
}
})();