// ==UserScript== // @name 网页划词、全页翻译 // @namespace wb.web.translator // @version 1.4.4 // @description 悬浮球翻译:划词自动弹出悬浮翻译,点悬浮球展开"翻译本页/模式"小图标,支持拖拽移动与随时停止。首选 MyMemory 免费翻译记忆库(无需密钥),失败或超时自动兜底谷歌公共接口。 // @author shanlinzhizi // @match *://*/* // @exclude *://*.google.com/search* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect api.mymemory.translated.net // @connect translate.googleapis.com // @connect translate.google.com // @connect clients5.google.com // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ---------------- 配置 ---------------- */ const TARGET_LANG = 'zh-CN'; const SOURCE_LANG = detectSourceLang(); // 按 猜测源语言 const MM_TIMEOUT = 5000; // MyMemory 超时(ms),超过即兜底 const GOOGLE_TIMEOUT = 6000; const CHUNK_MAX = 480; // MyMemory 单次请求上限约 500 字符 const CONCURRENCY = 4; // 并发请求数 const MM_MIN_INTERVAL = 260; // MyMemory 相邻请求最小间隔(ms),防 429 限流 const MAX_NODES = 1500; // 单次最多翻译的文本节点数,防止超大页面失控 const SKIP_TAGS = new Set([ 'SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA', 'NOSCRIPT', 'IFRAME', 'SVG', 'CANVAS', 'SELECT', 'OPTION', 'INPUT', 'META', 'TITLE' ]); /* ---------------- 状态 ---------------- */ let translated = false; // 当前是否处于已翻译状态 let running = false; // 全页翻译进行中 let stopRequested = false; // 用户要求停止 let mode = localStorage.getItem('mt-mode') || 'bilingual'; // bilingual | only const cache = new Map(); // 原文 -> 译文 const records = []; // { wrapEl, origText } let statsOk = 0, statsFail = 0, statsSkip = 0; let runTotal = 0, runDone = 0, lastError = ''; // 本轮进度与最近一次失败原因 let lastProgressCount = 0, lastProgressAt = 0; // 看门狗用:最近一次进度推进 /* ---------------- 工具 ---------------- */ function detectSourceLang() { const lang = (document.documentElement.lang || '').toLowerCase(); const m = lang.match(/^(en|fr|de|es|it|pt|ru|nl|pl|tr)/); return m ? m[1] : 'en'; } // 只要含 2 个以上拉丁字母、且完全没有中日韩字符的文本才翻 function needTranslate(text) { const s = text.trim(); if (s.length < 2 || s.length > 1500) return false; if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(s)) return false; if (/^[\s\d\W]*$/.test(s)) return false; // 纯数字/符号 const latin = (s.match(/[A-Za-z\u00C0-\u024F]/g) || []).length; return latin >= 2; } function decodeEntities(raw) { const d = document.createElement('div'); d.innerHTML = raw; return d.textContent; } function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // 把长文本按句子切成 <= CHUNK_MAX 的块 function splitChunks(text) { if (text.length <= CHUNK_MAX) return [text]; const sentences = text.split(/(?<=[.!?。!?])\s+/); const chunks = []; let cur = ''; for (const sen of sentences) { if ((cur + ' ' + sen).length > CHUNK_MAX && cur) { chunks.push(cur); cur = sen; } else { cur = cur ? cur + ' ' + sen : sen; } while (cur.length > CHUNK_MAX) { // 单句超长的兜底:硬切 chunks.push(cur.slice(0, CHUNK_MAX)); cur = cur.slice(CHUNK_MAX); } } if (cur) chunks.push(cur); return chunks; } // GM_xmlhttpRequest 的 Promise 封装,网络错误/超时自动重试一次 function request(url, timeout) { return new Promise((resolve, reject) => { const attempt = (left) => { GM_xmlhttpRequest({ method: 'GET', url, timeout, onload: res => (res.status === 200 ? resolve(res.responseText) : reject(new Error('HTTP ' + res.status))), onerror: () => (left > 0 ? setTimeout(() => attempt(left - 1), 400) : reject(new Error('网络错误'))), // 超时不重试:重试会让最坏等待时间翻倍,直接交给兜底链路 ontimeout: () => reject(new Error('超时')), }); }; attempt(1); }); } /* ---------------- 翻译接口 ---------------- */ const sleep = ms => new Promise(r => setTimeout(r, ms)); // MyMemory 全局节流:所有请求串行排队,保证相邻请求间隔 >= MM_MIN_INTERVAL let mmChain = Promise.resolve(); function mmThrottle() { const p = mmChain.then(() => sleep(0)); mmChain = p.then(() => sleep(MM_MIN_INTERVAL)); return p; } // MyMemory 熔断:429/配额用尽后 2 分钟内直接跳过,不再退避重试浪费时间 let mmDownUntil = 0; function mmAvailable() { return Date.now() >= mmDownUntil; } function markMyMemoryDown() { mmDownUntil = Date.now() + 120000; } // MyMemory:免费翻译记忆库,无需密钥。429/配额问题触发熔断 async function translateByMyMemory(text) { const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${SOURCE_LANG}|${TARGET_LANG}`; await mmThrottle(); try { const raw = await request(url, MM_TIMEOUT); const data = JSON.parse(raw); const out = data && data.responseData && data.responseData.translatedText; if (!out) throw new Error('空结果'); // 配额用尽 / 限流时 MyMemory 会把警告文本塞进翻译结果里,必须识别 if (/MYMEMORY WARNING|QUOTA|INVALID (SOURCE|TARGET)|EXCEEDED/i.test(out)) { markMyMemoryDown(); throw new Error('配额或参数问题: ' + out.slice(0, 50)); } return decodeEntities(out); } catch (e) { if (/HTTP 429/.test(String(e.message))) markMyMemoryDown(); throw e; } } // 谷歌接口熔断:无代理等场景下连续失败后,60 秒内直接跳过谷歌,不再浪费时间 let googleDownUntil = 0; function googleAvailable() { return Date.now() >= googleDownUntil; } function markGoogleDown() { googleDownUntil = Date.now() + 60000; } // 谷歌公共网页接口(client=gtx,无需密钥)。多域名轮询, // 应对代理规则把某个 google 域名设为直连而连不上的情况 const GOOGLE_HOSTS = [ 'translate.googleapis.com', 'translate.google.com', 'clients5.google.com' ]; async function translateByGoogle(text) { if (!googleAvailable()) throw new Error('谷歌接口暂时熔断(近期连续失败)'); let lastErr = null; for (const host of GOOGLE_HOSTS) { try { const url = `https://${host}/translate_a/single?client=gtx&sl=auto&tl=${TARGET_LANG}&dt=t&q=${encodeURIComponent(text)}`; const raw = await request(url, GOOGLE_TIMEOUT); const data = JSON.parse(raw); if (!Array.isArray(data) || !Array.isArray(data[0])) throw new Error('返回异常'); googleDownUntil = 0; return data[0].map(seg => seg[0] || '').join(''); } catch (e) { lastErr = e; } } markGoogleDown(); throw lastErr || new Error('全部域名失败'); } // 单条文本翻译:MyMemory 与谷歌"赛跑"而非串行等待。 // MyMemory 先出发,2.5 秒内没结果就并行启动谷歌,谁先返回用谁; // 两者都失败时报告各自原因。 async function translateText(text) { if (cache.has(text)) return cache.get(text); return new Promise((resolve, reject) => { let settled = false; let gStarted = false; const errs = { mm: null, g: null }; let timer = null; const succeed = r => { if (settled) return; settled = true; if (timer) clearTimeout(timer); cache.set(text, r); resolve(r); }; const failAll = () => { if (settled || !errs.mm || !errs.g) return; settled = true; if (timer) clearTimeout(timer); reject(new Error(`MyMemory(${errs.mm.message}) / 谷歌(${errs.g.message})`)); }; function startGoogle() { if (gStarted || settled) return; gStarted = true; translateByGoogle(text).then(r => succeed(r)).catch(e => { errs.g = e; failAll(); }); } if (mmAvailable()) { // MyMemory 先出发,1.5 秒内没结果谷歌并行开跑,谁先返回用谁 translateByMyMemory(text).then(r => succeed(r)).catch(e => { errs.mm = e; startGoogle(); // MyMemory 先挂就立刻启动谷歌 failAll(); }); timer = setTimeout(startGoogle, 1500); } else { // MyMemory 熔断中(429/配额):直接谷歌,不等 errs.mm = new Error('熔断中(限流)'); startGoogle(); } }); } // 长文本按块翻译后拼接(每块独立走 MyMemory -> 谷歌) async function translateLong(text) { const parts = []; for (const c of splitChunks(text)) parts.push(await translateText(c)); return parts.join(' '); } /* ---------------- 并发队列 ---------------- */ function createQueue(limit) { let active = 0; const tasks = []; function next() { if (active >= limit || !tasks.length) return; active++; const t = tasks.shift(); // 任务内部已 try/finally,这里再兜一层:异常不能中断队列 t().catch(err => console.error('[网页翻译] 任务异常:', err)).finally(() => { active--; next(); }); } return fn => new Promise(res => { tasks.push(async () => { try { await fn(); } finally { res(); } }); next(); }); } /* ---------------- 收集与替换节点 ---------------- */ function collectNodes() { const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; const p = node.parentElement; if (!p || SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT; if (p.closest('[data-mt-wrap]')) return NodeFilter.FILTER_REJECT; // 已翻译 if (!needTranslate(node.nodeValue)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); const nodes = []; while (walker.nextNode() && nodes.length < MAX_NODES) nodes.push(walker.currentNode); return nodes; } function buildWrap(origText, transText) { const wrap = document.createElement('span'); wrap.setAttribute('data-mt-wrap', '1'); const t = document.createElement('span'); t.className = 'mt-trans'; t.textContent = transText; wrap.appendChild(t); if (mode === 'bilingual') { const o = document.createElement('span'); o.className = 'mt-orig'; o.textContent = origText; wrap.appendChild(o); } return wrap; } function renderAll() { document.body.classList.toggle('mt-only-mode', mode === 'only'); for (const r of records) { const transText = r.wrapEl.querySelector('.mt-trans').textContent; const fresh = buildWrap(r.origText, transText); r.wrapEl.replaceWith(fresh); r.wrapEl = fresh; } } /* ---------------- 主流程:全页翻译 ---------------- */ async function doTranslate() { if (running || translated) return; running = true; stopRequested = false; statsOk = 0; statsFail = 0; statsSkip = 0; runTotal = 0; runDone = 0; lastError = ''; lastProgressCount = 0; lastProgressAt = Date.now(); updateUI(); // 启动阶段(同步)任何异常都不能让 running 卡在 true let nodes, batches, singles, queue, done; try { nodes = collectNodes(); if (nodes.length) { // 视口内可见的段落优先翻译:先出用户正在看的内容, // 页头导航/屏幕外的内容靠后排 const vh = window.innerHeight; const visRank = node => { const el = node.parentElement; if (!el || !el.getBoundingClientRect) return 2; const r = el.getBoundingClientRect(); return (r.top < vh && r.bottom > 0) ? 0 : 1; // 0=可见 1=不可见 }; nodes.sort((a, b) => visRank(a) - visRank(b)); } runTotal = nodes.length; if (!nodes.length) { running = false; updateUI(); alert('本页没有检测到需要翻译的外文文本(已翻译过或本身就是中文)。'); return; } queue = createQueue(CONCURRENCY); done = 0; // 本轮已处理完的任务数(含跳过/失败),只用于等待队列清空 function finishNode(node, origText, trans) { // 请求期间节点可能已被页面(SPA/动态渲染)移除或改动 if (!node.isConnected || node.nodeValue !== origText) { statsSkip++; return; } const wrap = buildWrap(origText, trans); node.replaceWith(wrap); // 译好一段才替换一段,此前保持原文 records.push({ wrapEl: wrap, origText }); statsOk++; } // ---- 短文本批量翻译:多段合并成一次谷歌请求,请求数减少一个数量级 ---- const BATCH_NODE_MAX = 300; // 参与批量的单段长度上限 const BATCH_TOTAL_MAX = 1400; // 单次批量请求的总字符上限 async function translateBatchNodes(batchNodes) { const texts = batchNodes.map(n => n.nodeValue); const results = new Array(texts.length); const uncachedIdx = []; texts.forEach((t, i) => { if (cache.has(t)) results[i] = cache.get(t); else uncachedIdx.push(i); }); if (uncachedIdx.length) { if (!googleAvailable()) throw new Error('谷歌接口暂时熔断'); const q = uncachedIdx.map(i => texts[i]).join('\n'); const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${TARGET_LANG}&dt=t&q=${encodeURIComponent(q)}`; const raw = await request(url, GOOGLE_TIMEOUT); const data = JSON.parse(raw); if (!Array.isArray(data) || !Array.isArray(data[0])) throw new Error('返回异常'); const joined = data[0].map(s => s[0] || '').join(''); const parts = joined.split('\n'); if (parts.length !== uncachedIdx.length) throw new Error('批量分段数不匹配'); googleDownUntil = 0; uncachedIdx.forEach((idx, k) => { results[idx] = parts[k]; cache.set(texts[idx], parts[k]); }); } return results; } // 批次划分:短且无换行的段落合并批量,长段落单独翻译(仍走 MyMemory 优先) batches = []; singles = []; let curBatch = [], curLen = 0; for (const node of nodes) { const t = node.nodeValue; if (t.length <= BATCH_NODE_MAX && !t.includes('\n')) { if (curLen + t.length + 1 > BATCH_TOTAL_MAX && curBatch.length) { batches.push(curBatch); curBatch = []; curLen = 0; } curBatch.push(node); curLen += t.length + 1; } else { if (curBatch.length) { batches.push(curBatch); curBatch = []; curLen = 0; } singles.push(node); } } if (curBatch.length) batches.push(curBatch); for (const batch of batches) { const origTexts = batch.map(n => n.nodeValue); queue(async () => { try { if (stopRequested) { statsSkip += batch.length; return; } try { const results = await translateBatchNodes(batch); batch.forEach((node, i) => finishNode(node, origTexts[i], results[i])); } catch (e) { // 网络层面的失败记入熔断(分段数不匹配属于解析问题,不熔断) if (/网络错误|超时/.test(String(e.message || e))) markGoogleDown(); // 批量失败:降级为逐段翻译(MyMemory 优先 -> 谷歌兜底) for (const node of batch) { if (stopRequested) { statsSkip++; continue; } // 逐段检查停止,点了停立刻收手 const origText = node.nodeValue; try { const trans = origText.length > CHUNK_MAX ? await translateLong(origText) : await translateText(origText); finishNode(node, origText, trans); } catch (e2) { statsFail++; lastError = String(e2.message || e2); } } } } finally { done += batch.length; runDone += batch.length; updateUI(); } }); } for (const node of singles) { const origText = node.nodeValue; queue(async () => { try { if (stopRequested) { statsSkip++; return; } try { const trans = origText.length > CHUNK_MAX ? await translateLong(origText) : await translateText(origText); finishNode(node, origText, trans); } catch (e) { statsFail++; lastError = String(e.message || e); console.warn('[网页翻译] 段落翻译失败:', lastError, '原文:', origText.slice(0, 80)); } } finally { done++; runDone++; updateUI(); } }); } } catch (e) { // 启动/派发阶段异常:立即结束,绝不让状态卡死 running = false; lastError = String((e && e.message) || e); console.error('[网页翻译] 翻译启动失败:', e); updateUI(); alert('翻译启动失败:' + lastError); return; } // 看门狗:45 秒没有任何进度推进就自动熔断结束,防止永久卡在"翻译中" const watchdog = setInterval(() => { if (!running) { clearInterval(watchdog); return; } if (Date.now() - lastProgressAt > 45000) { stopRequested = true; lastError = '45 秒无进度,已自动停止(网络可能不可用)'; updateUI(); } }, 5000); // 等本轮队列清空 try { await new Promise(res => { const timer = setInterval(() => { if (done >= nodes.length) { clearInterval(timer); res(); } }, 300); }); } finally { clearInterval(watchdog); } running = false; // 中途停止不算"已完成翻译",保持可继续/可重翻状态 translated = !stopRequested; updateUI(); } function doRestore() { for (const r of records) { r.wrapEl.replaceWith(document.createTextNode(r.origText)); } records.length = 0; translated = false; statsOk = 0; statsFail = 0; statsSkip = 0; updateUI(); } function doStop() { stopRequested = true; } /* ---------------- 悬浮球 UI ---------------- */ GM_addStyle(` #mt-ball { position: fixed; z-index: 2147483647; width: 52px; height: 52px; border-radius: 50%; cursor: pointer; user-select: none; box-shadow: 0 6px 20px rgba(0,0,0,.28); transition: transform .15s ease, opacity .15s; line-height: 0; } #mt-ball:hover { transform: scale(1.08); } #mt-ball.mt-dragging { transition: none; transform: scale(1.05); opacity: .88; } #mt-ball svg { pointer-events: none; } #mt-ball .mt-ring { display: none; position: absolute; inset: -4px; border-radius: 50%; border: 3px solid transparent; border-top-color: #34a853; animation: mt-spin 1s linear infinite; pointer-events: none; } #mt-ball.mt-running .mt-ring { display: block; } @keyframes mt-spin { to { transform: rotate(360deg); } } #mt-fabs { position: fixed; z-index: 2147483647; display: none; flex-direction: column; gap: 10px; align-items: center; } #mt-fabs.mt-show { display: flex; animation: mt-fabs-in .15s ease-out; } @keyframes mt-fabs-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } .mt-fab { width: 42px; height: 42px; border-radius: 50%; border: none; cursor: pointer; color: #fff; user-select: none; font: 700 15px/1 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; box-shadow: 0 3px 10px rgba(0,0,0,.22); display: flex; align-items: center; justify-content: center; transition: transform .12s ease; -webkit-font-smoothing: antialiased; } .mt-fab:hover { transform: scale(1.12); } .mt-fab.mt-fab-translate { background: #34a853; font-size: 16px; } .mt-fab.mt-fab-stop { background: #e5484d; } .mt-fab.mt-fab-restore { background: #f59e0b; } .mt-fab.mt-fab-mode { background: #7c5cff; } /* 划词悬浮翻译 */ #mt-pop { position: absolute; z-index: 2147483647; max-width: 420px; background: #fff; color: #333; border-radius: 8px; border: 1px solid #e2e6ea; box-shadow: 0 4px 16px rgba(0,0,0,.15); font: 13px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; padding: 8px 12px; user-select: none; display: none; } #mt-pop .mt-pop-text { color: #1a73e8; white-space: pre-wrap; word-break: break-word; } #mt-pop .mt-pop-loading { color: #999; font-size: 12px; } #mt-pop .mt-pop-err { color: #c0392b; font-size: 12px; } .mt-trans { color: #1a73e8; } .mt-orig { display: block; font-size: .85em; opacity: .45; margin-top: 1px; } .mt-only-mode .mt-orig { display: none !important; } `); /* --- 主悬浮球(可拖拽) --- */ const BALL_SIZE = 52, FAB_SIZE = 42, FAB_GAP = 10; const ball = document.createElement('div'); ball.id = 'mt-ball'; ball.innerHTML = ` `; document.documentElement.appendChild(ball); /* --- 弹出小图标(翻译/停止/恢复 + 模式) --- */ const fabs = document.createElement('div'); fabs.id = 'mt-fabs'; // 用 div 而非 button:button 点击获得焦点可能引发滚动导致小图标收起 const modeFab = document.createElement('div'); modeFab.className = 'mt-fab mt-fab-mode'; const transFab = document.createElement('div'); transFab.className = 'mt-fab mt-fab-translate'; fabs.appendChild(modeFab); fabs.appendChild(transFab); document.documentElement.appendChild(fabs); // 悬浮球位置(fixed 坐标),持久化 let bx = parseFloat(localStorage.getItem('mt-bx')); let by = parseFloat(localStorage.getItem('mt-by')); if (isNaN(bx) || isNaN(by)) { // 默认右下角 bx = window.innerWidth - 90; by = window.innerHeight - 160; } function clampPos() { const w = document.documentElement.clientWidth, h = window.innerHeight; bx = Math.min(Math.max(bx, 6), w - BALL_SIZE - 6); by = Math.min(Math.max(by, 6), h - BALL_SIZE - 6); } function placeBall() { clampPos(); ball.style.left = bx + 'px'; ball.style.top = by + 'px'; } function placeFabs() { // 小图标竖排叠在悬浮球上方 const totalH = FAB_SIZE * 2 + FAB_GAP; let fx = bx + (BALL_SIZE - FAB_SIZE) / 2; let fy = by - totalH - FAB_GAP; if (fy < 6) fy = by + BALL_SIZE + FAB_GAP; // 球太靠上时弹到下方 fx = Math.min(Math.max(fx, 6), document.documentElement.clientWidth - FAB_SIZE - 6); fabs.style.left = fx + 'px'; fabs.style.top = fy + 'px'; } let fabsOpen = false; function showFabs() { fabsOpen = true; placeFabs(); fabs.classList.add('mt-show'); } function hideFabs() { fabsOpen = false; fabs.classList.remove('mt-show'); } function toggleFabs() { fabsOpen ? hideFabs() : showFabs(); } /* --- 拖拽 vs 点击 --- */ let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0; ball.addEventListener('mousedown', e => { if (e.button !== 0) return; dragging = true; moved = false; sx = e.clientX; sy = e.clientY; ox = bx; oy = by; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; const dx = e.clientX - sx, dy = e.clientY - sy; if (!moved && Math.abs(dx) + Math.abs(dy) > 4) { moved = true; ball.classList.add('mt-dragging'); hideFabs(); } if (moved) { bx = ox + dx; by = oy + dy; placeBall(); } }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; ball.classList.remove('mt-dragging'); if (moved) { localStorage.setItem('mt-bx', bx); localStorage.setItem('mt-by', by); } else { toggleFabs(); } }); // 点击球/小图标以外区域、滚动、Esc:收起小图标 document.addEventListener('mousedown', e => { if (fabsOpen && !fabs.contains(e.target) && !ball.contains(e.target)) hideFabs(); }); window.addEventListener('scroll', hideFabs, true); document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideFabs(); hidePop(); } }); /* --- 小图标行为 --- */ transFab.addEventListener('click', () => { if (running) doStop(); else if (translated) doRestore(); else doTranslate(); }); modeFab.addEventListener('click', () => { mode = (mode === 'bilingual') ? 'only' : 'bilingual'; localStorage.setItem('mt-mode', mode); if (records.length) renderAll(); updateUI(); showFabs(); // 切换模式后保持小图标展开,直到点击其他区域 }); function updateUI() { // 进度有推进就刷新看门狗时间戳 if (runDone > lastProgressCount) { lastProgressCount = runDone; lastProgressAt = Date.now(); } // 主按钮三态:翻译 -> 停止 -> 恢复原文 if (running) { transFab.className = 'mt-fab mt-fab-stop'; transFab.textContent = '⏹'; transFab.title = '停止翻译'; ball.classList.add('mt-running'); ball.title = `翻译中… ${runDone}/${runTotal},已译 ${statsOk},失败 ${statsFail}` + (lastError ? `\n最近错误:${lastError}` : ''); } else if (translated) { transFab.className = 'mt-fab mt-fab-restore'; transFab.textContent = '原'; transFab.title = '恢复原文'; ball.classList.remove('mt-running'); ball.title = `已翻译 ${statsOk} 段${statsFail ? `,失败 ${statsFail}` : ''}${statsSkip ? `,已跳过 ${statsSkip}` : ''}` + (lastError ? `\n最近错误:${lastError}` : ''); } else { transFab.className = 'mt-fab mt-fab-translate'; transFab.textContent = '译'; transFab.title = '翻译本页'; ball.classList.remove('mt-running'); ball.title = '网页翻译:点击球展开菜单'; } // 图标显示"切换目标":当前双语 -> 显示"单";当前单语 -> 显示"双" modeFab.textContent = mode === 'bilingual' ? '单' : '双'; modeFab.title = '切换为' + (mode === 'bilingual' ? '仅译文' : '双语对照'); } placeBall(); updateUI(); /* ---------------- 划词悬浮翻译 ---------------- */ const SEL_MIN = 2, SEL_MAX = 1000; let selSeq = 0; // 请求序号,防止连续划词时旧结果覆盖新结果 const pop = document.createElement('div'); pop.id = 'mt-pop'; document.documentElement.appendChild(pop); function hidePop() { pop.style.display = 'none'; selSeq++; } function showPopAt(rect) { pop.style.display = 'block'; pop.style.visibility = 'hidden'; // 先渲染拿到尺寸再定位 const w = pop.offsetWidth, h = pop.offsetHeight; let x = rect.left + window.scrollX; let y = rect.bottom + window.scrollY + 8; if (x + w > window.scrollX + document.documentElement.clientWidth - 12) { x = window.scrollX + document.documentElement.clientWidth - w - 12; } if (y + h > window.scrollY + window.innerHeight - 12 && rect.top > h + 20) { y = rect.top + window.scrollY - h - 8; } pop.style.left = Math.max(x, 8) + 'px'; pop.style.top = Math.max(y, 8) + 'px'; pop.style.visibility = 'visible'; } function setPopContent(html, cls) { pop.innerHTML = `