From 8398984cc5085e8b9cf37d0e648f1c943fec6701 Mon Sep 17 00:00:00 2001 From: PushGo User Date: Sun, 29 Mar 2026 20:17:45 +0800 Subject: [PATCH] PushGo Auto Commit 2026-03-29T12:17:45.673Z --- README.md | 36 ++++++ background.js | 230 ++++++++++++++++++++++++++++++++++++ content.js | 206 +++++++++++++++++++++++++++++++++ icons/icon128.png | Bin 0 -> 1333 bytes icons/icon16.png | Bin 0 -> 289 bytes icons/icon32.png | Bin 0 -> 470 bytes icons/icon48.png | Bin 0 -> 605 bytes manifest.json | 33 ++++++ options.html | 288 ++++++++++++++++++++++++++++++++++++++++++++++ options.js | 153 ++++++++++++++++++++++++ popup.html | 205 +++++++++++++++++++++++++++++++++ popup.js | 125 ++++++++++++++++++++ 12 files changed, 1276 insertions(+) create mode 100644 README.md create mode 100644 background.js create mode 100644 content.js create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon32.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 options.html create mode 100644 options.js create mode 100644 popup.html create mode 100644 popup.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae50ef5 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Transly(极简 Chrome 直译插件) + +## 本次界面更新 +- popup 重做为轻量卡片布局,强化主操作按钮层级与状态反馈。 +- options 重做为双分区配置面板,输入焦点、保存态与提示信息更清晰。 +- 新增扩展图标:`icons/icon16.png`、`icons/icon32.png`、`icons/icon48.png`、`icons/icon128.png`。 + +## 配置入口 +- 入口1:点击浏览器工具栏里的 Transly 图标,在弹窗点 `打开配置(模型/API)` +- 入口2:`chrome://extensions` -> Transly -> `扩展程序选项` +- 首次安装会自动打开配置页 +- 配置页支持 `测试连接`(校验 API Key/Model)与 `清除配置`(删除浏览器已保存配置) + +## 需要填写 +- `API Base URL`:例如 `https://api.openai.com` +- `API Key`:你的密钥 +- `Model`:例如 `gpt-4o-mini` +- `目标语言`:默认 `zh-CN` + +## 存储说明 +- `baseUrl/model/targetLang` 存在 `chrome.storage.sync` +- `apiKey` 存在 `chrome.storage.local`(不走跨设备同步,更安全) + +## 使用 +- 选中文本后: + - 右键 `Transly: 翻译选中文本`,或 + - 点击插件弹窗里的 `翻译选中文本` +- 整页翻译:弹窗点 `整页翻译(随滚动)` + - 只翻译当前可视区域 + - 向下滚动时自动继续翻译新进入可视区域的文本 +- 恢复原文:弹窗点 `恢复原文` + +## 加载方式 +1. 打开 `chrome://extensions` +2. 开启“开发者模式” +3. 点击“加载已解压的扩展程序”并选择本目录 diff --git a/background.js b/background.js new file mode 100644 index 0000000..3f1970b --- /dev/null +++ b/background.js @@ -0,0 +1,230 @@ +const SYSTEM_PROMPT = "你是翻译引擎,只输出译文,不解释不分析不扩展"; + +function setupContextMenu() { + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + id: "transly-translate-selection", + title: "Transly: 翻译选中文本", + contexts: ["selection"] + }); + }); +} + +chrome.runtime.onInstalled.addListener((details) => { + setupContextMenu(); + if (details.reason === "install") { + chrome.runtime.openOptionsPage(); + } +}); +chrome.runtime.onStartup.addListener(setupContextMenu); + +async function getConfig() { + const syncData = await chrome.storage.sync.get({ + baseUrl: "https://api.openai.com", + model: "gpt-4o-mini", + targetLang: "zh-CN" + }); + const localData = await chrome.storage.local.get({ apiKey: "" }); + + return normalizeConfig({ + ...syncData, + ...localData + }); +} + +function normalizeConfig(config) { + return { + baseUrl: String(config?.baseUrl || "").trim().replace(/\/+$/, ""), + apiKey: String(config?.apiKey || "").trim(), + model: String(config?.model || "").trim(), + targetLang: String(config?.targetLang || "zh-CN").trim() || "zh-CN" + }; +} + +function parseContent(content) { + if (Array.isArray(content)) { + return content.map((item) => item?.text || "").join("").trim(); + } + return String(content || "").trim(); +} + +async function requestTranslate(text, useReasoningEffort, configOverride) { + const cfg = configOverride ? normalizeConfig(configOverride) : await getConfig(); + const { baseUrl, apiKey, model, targetLang } = cfg; + if (!baseUrl || !apiKey || !model) { + throw new Error("请先在 options 填写 Base URL / API Key / Model"); + } + + const body = { + model, + temperature: 0, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: `请翻译为 ${targetLang},只输出译文:\n${text}` } + ] + }; + + if (useReasoningEffort) { + body.reasoning_effort = "low"; + } + + const res = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const errText = (await res.text()).slice(0, 200); + throw new Error(`请求失败(${res.status}) ${errText}`); + } + + const data = await res.json(); + const translated = parseContent(data?.choices?.[0]?.message?.content); + if (!translated) { + throw new Error("未收到有效译文"); + } + + return translated; +} + +async function translate(text, configOverride) { + if (!text || !text.trim()) { + throw new Error("没有可翻译文本"); + } + + try { + return await requestTranslate(text.trim(), true, configOverride); + } catch (err) { + const message = String(err?.message || ""); + if (message.includes("400") || message.includes("reasoning")) { + return requestTranslate(text.trim(), false, configOverride); + } + throw err; + } +} + +async function testConfig(configInput) { + const cfg = normalizeConfig(configInput); + if (!cfg.baseUrl || !cfg.apiKey || !cfg.model) { + throw new Error("请先填写 Base URL / API Key / Model"); + } + + await translate("hello world", cfg); + return "连接成功,API Key 与模型可用"; +} + +async function getSelectedTextFromTab(tabId) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + const selected = String(window.getSelection() || "").trim(); + if (selected) { + return selected; + } + + const el = document.activeElement; + if (!el) { + return ""; + } + + const isInput = el.tagName === "INPUT" || el.tagName === "TEXTAREA"; + if (!isInput || typeof el.value !== "string") { + return ""; + } + + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + return el.value.slice(Math.min(start, end), Math.max(start, end)).trim(); + } + }); + + return String(results?.[0]?.result || "").trim(); +} + +async function showTextInTab(tabId, text, isError) { + const message = String(text || "").trim(); + if (!message) { + return false; + } + + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (msg, error) => { + alert(error ? `Transly: ${msg}` : msg); + }, + args: [message, !!isError] + }); + return true; + } catch (_err) { + // 某些页面禁止注入,降级尝试 content script 消息。 + } + + return new Promise((resolve) => { + chrome.tabs.sendMessage( + tabId, + isError ? { action: "showError", error: message } : { action: "showTranslation", text: message }, + (response) => { + if (chrome.runtime.lastError || !response?.ok) { + resolve(false); + return; + } + resolve(true); + } + ); + }); +} + +async function translateSelectionInTab(tabId) { + const selectedText = await getSelectedTextFromTab(tabId); + if (!selectedText) { + throw new Error("请先选中文本"); + } + + const text = await translate(selectedText); + const shown = await showTextInTab(tabId, text, false); + return { text, shown }; +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.action === "translate") { + translate(message.text) + .then((text) => sendResponse({ ok: true, text })) + .catch((err) => sendResponse({ ok: false, error: err.message || "翻译失败" })); + + return true; + } + + if (message?.action === "translateSelectionInTab") { + translateSelectionInTab(message.tabId) + .then((result) => sendResponse({ ok: true, text: result.text, shown: result.shown })) + .catch((err) => sendResponse({ ok: false, error: err.message || "翻译失败" })); + + return true; + } + + if (message?.action === "testConfig") { + testConfig(message.config) + .then((text) => sendResponse({ ok: true, text })) + .catch((err) => sendResponse({ ok: false, error: err.message || "测试失败" })); + + return true; + } +}); + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId !== "transly-translate-selection" || !info.selectionText || !tab?.id) { + return; + } + + try { + const text = await translate(info.selectionText); + await showTextInTab(tab.id, text, false); + } catch (err) { + await showTextInTab(tab.id, err.message || "翻译失败", true); + } +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..fdc9935 --- /dev/null +++ b/content.js @@ -0,0 +1,206 @@ +const BLOCKED_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"]); +const SCROLL_DEBOUNCE_MS = 250; +const MAX_NODES_PER_PASS = 30; + +const originalTextMap = new Map(); +let autoTranslateEnabled = false; +let scrollTimer = null; +let translating = false; +let needRerun = false; + +function isVisible(element) { + let el = element; + while (el) { + const style = window.getComputedStyle(el); + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + el = el.parentElement; + } + return true; +} + +function isInViewport(element) { + const rect = element.getBoundingClientRect(); + return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth; +} + +function collectTextNodes(onlyViewport) { + const root = document.body || document.documentElement; + if (!root) { + return []; + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const text = node.nodeValue?.trim(); + const parent = node.parentElement; + if (!text || !parent) { + return NodeFilter.FILTER_REJECT; + } + if (BLOCKED_TAGS.has(parent.tagName) || parent.isContentEditable) { + return NodeFilter.FILTER_REJECT; + } + if (originalTextMap.has(node)) { + return NodeFilter.FILTER_REJECT; + } + if (!isVisible(parent)) { + return NodeFilter.FILTER_REJECT; + } + if (onlyViewport && !isInViewport(parent)) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + + const nodes = []; + while (walker.nextNode()) { + nodes.push(walker.currentNode); + } + return nodes; +} + +function requestTranslation(text) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action: "translate", text }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response?.ok) { + reject(new Error(response?.error || "翻译失败")); + return; + } + resolve(response.text); + }); + }); +} + +function stopAutoTranslate() { + autoTranslateEnabled = false; + if (scrollTimer) { + clearTimeout(scrollTimer); + scrollTimer = null; + } + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleScroll); +} + +async function translateVisibleOnce() { + const nodes = collectTextNodes(true); + if (!nodes.length) { + return 0; + } + + const batch = nodes.slice(0, MAX_NODES_PER_PASS); + for (const node of batch) { + if (!autoTranslateEnabled) { + break; + } + const original = node.nodeValue; + const translated = await requestTranslation(original); + originalTextMap.set(node, original); + node.nodeValue = translated; + } + + if (nodes.length > batch.length) { + needRerun = true; + } + return batch.length; +} + +async function runTranslateCycle() { + if (!autoTranslateEnabled) { + return; + } + if (translating) { + needRerun = true; + return; + } + + translating = true; + try { + await translateVisibleOnce(); + } catch (err) { + stopAutoTranslate(); + alert(`Transly: ${err.message || "翻译失败"}`); + } finally { + translating = false; + if (autoTranslateEnabled && needRerun) { + needRerun = false; + setTimeout(() => { + runTranslateCycle().catch(() => {}); + }, 0); + } + } +} + +function handleScroll() { + if (!autoTranslateEnabled) { + return; + } + if (scrollTimer) { + clearTimeout(scrollTimer); + } + scrollTimer = setTimeout(() => { + runTranslateCycle().catch(() => {}); + }, SCROLL_DEBOUNCE_MS); +} + +async function translatePage() { + const hasCurrentNodes = collectTextNodes(true).length > 0; + + if (!autoTranslateEnabled) { + autoTranslateEnabled = true; + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", handleScroll); + } + + await runTranslateCycle(); + if (!hasCurrentNodes && !originalTextMap.size) { + alert("Transly: 当前可视区域没有可翻译文本"); + } +} + +function restorePage() { + stopAutoTranslate(); + + if (!originalTextMap.size) { + alert("Transly: 没有可恢复内容"); + return; + } + + for (const [node, text] of originalTextMap.entries()) { + if (node?.isConnected) { + node.nodeValue = text; + } + } + originalTextMap.clear(); +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.action === "translatePage") { + translatePage() + .then(() => sendResponse({ ok: true })) + .catch((err) => sendResponse({ ok: false, error: err.message || "翻译失败" })); + return true; + } + + if (message?.action === "restorePage") { + restorePage(); + sendResponse({ ok: true }); + return; + } + + if (message?.action === "showTranslation") { + alert(message.text || ""); + sendResponse({ ok: true }); + return; + } + + if (message?.action === "showError") { + alert(`Transly: ${message.error || "翻译失败"}`); + sendResponse({ ok: true }); + } +}); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..5a6d32db890f209f84889ad48d83d9b8d167bb52 GIT binary patch literal 1333 zcmV-51 z%We}%6o#uU?}Jey+d(sC!yC*J9%b0fehAFSxj5uPxQwx#z#cY?h8G#w@dnw9GSYys zoybqXES!#vDVu7$tLpUm|4%9jk``Tk{?q52x^$7OG=KeK2rQ-89UyU`1AtRDN$f6= zm@oo>Lk9q-Y!c=GWXhBu8y11!um%999J>SaxOn>FR+j54I~%9I`l6NO+Ri-@Q^6Nc zvWe|VrA4QhM{;dvBbozTJUQ_q`YbA~`*emWl1eMA1Drn&EBY+1@6s8J3FrV)gr5f8 z5xhHqRN<#Vb%c^hjPccqBEB(~ef4Mysm$^$)V}>GpIfq00V4o7JR&9r zf^5zR0FLu0p!%!{@u5Tr1f>3nQ9zcf{qs|hyF{e=M{^~*>MR0DinIt|jN&LzZOeN5 zFC&2a)z$W9s5(HM6I9!qE78@qG|4GIjT4N;mq}7>H#2`8{;Amxy)<0N8N%9gxx%Lu zWkX0_(ZJP_=H9RNJXk99&O#rC)H-?$kd<0z^Hf0Uj#C6qwpwQk&I!iPEI_p!o8%&p z1YoJp!)slfC{XKesS5pRTl$QkI)D?@yIZFMwDQt&IU=Us)9VEFp3XNvZMoF|8l6Dn zGflJbD8Sb0y=^rH8$XMHSJZP$YP~HlX9a(J@J!;zlTH&s_2X^vGkAI7Z5UWT))Ngh zQ%VwJe6{tHVmg6On@DTkCxX^bwoi-d2nYn538W*)B>1-Q^%px=PVnExQ;CJ5k+f~w zH&K^^Lpa0KJw`t6QDnsI2bdHpC96}|{tYr8(PsqRc$rPmyq-m}P4b4z!Qk*ekG271 z6CM<41;-{#0pKtN*Z{Ig8R%J^L9z)WfDIs>JJ2zp{V({a=K zNeLN;c@xr{js^*91ztLM`Hxv>%)JToB98VaF8DGfYYyj2&$83*Y(Ow`rKyBeVnZuu zwax;D8ME9Ax8+L!!4olAu}0|w9g^G<46(dM^H)SPtPG5(^kJEK{s0iH*s)>au9=-F h%rhc5+8ON_7*2T`v3vYvE+kx!M@;5RJ3zQ{2V3Am_V9^A|QZha>;?;S_K9-1qnhp=Pf?D6w&IJqBz_XM1&w`&0b>iAZE>8DU8KU zX~ER>jLCzTx?W;CEF#uEd}WKc@%-R=w{>po>SYgXgFG5v*AKp~2P|mjz{a7$w|EYi zhYnv_;B>322FkH1xznxM+O4?Ptu9T%&x~C&3UA%wkfOLw+uz z+>Y~dISEq-#-)~2@&?AGmUJi992ghXke`bvwxl}YP9Kn%lK~ZfqZwul>|@mK?ANGWk_}-#2us1B}RD8u_`1TwSMV@DZ-OIuN+{ zkSQQS1cl2|BMVj2qBBdvekyB(s%gKJMrH%jM1*U%esVJl)zWLz=+{kEMn70B^aKDv zxPB{jGG%6^9d2ZljKASVW+1D9nLb7rBhr*BBGSwZBy$Hujsr$ST6gk9s9=u*qeQel z6ERFZIjRd)?MAqfMLXKujbXCF6=NJXOjh)z9f}l?hPmTsjeBeH)^ptf$JiwYDDWKM zV0*8WsvMwvTBJZ#o}F^w^ZaaNY@fWo1x1!+$Yyi6>{#Q!;rK=Jh8U4NyIsTr=j`-j zROB4XVa`#Qv9X9)ufKH9$9nysNFI#6"], + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_title": "Transly", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png" + }, + "default_popup": "popup.html" + }, + "options_page": "options.html", + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ] +} diff --git a/options.html b/options.html new file mode 100644 index 0000000..f13a312 --- /dev/null +++ b/options.html @@ -0,0 +1,288 @@ + + + + + + Transly 配置 + + + +
+
+ +
+

Transly 配置

+
最小配置即可开始直译
+
+
+ +
+
+
+

接口信息

+

支持 OpenAI 兼容接口。

+ + + +

提示:填写接口根地址,不要加 /v1(例如 https://api.openai.com)。

+ + + +

API Key 存在 chrome.storage.local;其余字段存在 chrome.storage.sync。

+
+ +
+

翻译偏好

+

默认低温、纯译文输出。

+ + + + + + +

示例:`zh-CN`、`en`、`ja`。

+
+
+ +
+ + + +
+
+
+
+ + + + diff --git a/options.js b/options.js new file mode 100644 index 0000000..6d600dd --- /dev/null +++ b/options.js @@ -0,0 +1,153 @@ +const defaults = { + baseUrl: "https://api.openai.com", + model: "gpt-4o-mini", + targetLang: "zh-CN" +}; + +const baseUrlEl = document.getElementById("baseUrl"); +const apiKeyEl = document.getElementById("apiKey"); +const modelEl = document.getElementById("model"); +const targetLangEl = document.getElementById("targetLang"); +const msgEl = document.getElementById("msg"); +const saveBtn = document.getElementById("save"); +const testBtn = document.getElementById("test"); +const clearBtn = document.getElementById("clear"); + +function setMessage(text, type = "info") { + msgEl.textContent = text || ""; + msgEl.dataset.type = type; +} + +function resetActionButtons() { + saveBtn.disabled = false; + testBtn.disabled = false; + clearBtn.disabled = false; + saveBtn.textContent = "保存配置"; + testBtn.textContent = "测试连接"; + clearBtn.textContent = "清除配置"; +} + +function setActionBusy(activeBtn, busyText) { + saveBtn.disabled = true; + testBtn.disabled = true; + clearBtn.disabled = true; + activeBtn.textContent = busyText; +} + +function readFormConfig() { + return { + baseUrl: (baseUrlEl.value || "").trim(), + apiKey: (apiKeyEl.value || "").trim(), + model: (modelEl.value || "").trim(), + targetLang: (targetLangEl.value || "").trim() || defaults.targetLang + }; +} + +function sendRuntimeMessage(payload) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(payload, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response?.ok) { + reject(new Error(response?.error || "请求失败")); + return; + } + resolve(response); + }); + }); +} + +async function loadConfig() { + const syncData = await chrome.storage.sync.get(defaults); + const localData = await chrome.storage.local.get({ apiKey: "" }); + + baseUrlEl.value = syncData.baseUrl || defaults.baseUrl; + modelEl.value = syncData.model || defaults.model; + targetLangEl.value = syncData.targetLang || defaults.targetLang; + apiKeyEl.value = localData.apiKey || ""; +} + +async function saveConfig() { + const { baseUrl, apiKey, model, targetLang } = readFormConfig(); + + if (!baseUrl || !model) { + setMessage("Base URL 和 Model 不能为空", "error"); + return; + } + + setActionBusy(saveBtn, "保存中..."); + try { + await chrome.storage.sync.set({ baseUrl, model, targetLang }); + await chrome.storage.local.set({ apiKey }); + setMessage("已保存,可直接关闭此页", "success"); + } catch (err) { + setMessage(err.message || "保存失败", "error"); + } finally { + resetActionButtons(); + } +} + +async function testConfig() { + const config = readFormConfig(); + if (!config.baseUrl || !config.apiKey || !config.model) { + setMessage("请先填写 Base URL / API Key / Model", "error"); + return; + } + + setActionBusy(testBtn, "测试中..."); + try { + const response = await sendRuntimeMessage({ + action: "testConfig", + config + }); + setMessage(response.text || "连接成功", "success"); + } catch (err) { + setMessage(err.message || "测试失败", "error"); + } finally { + resetActionButtons(); + } +} + +async function clearConfig() { + const ok = confirm("确定清除已保存的 Base URL / API Key / Model / 目标语言吗?"); + if (!ok) { + return; + } + + setActionBusy(clearBtn, "清除中..."); + try { + await chrome.storage.sync.remove(["baseUrl", "model", "targetLang"]); + await chrome.storage.local.remove(["apiKey"]); + + baseUrlEl.value = defaults.baseUrl; + modelEl.value = defaults.model; + targetLangEl.value = defaults.targetLang; + apiKeyEl.value = ""; + + setMessage("已清除浏览器中的配置", "success"); + } catch (err) { + setMessage(err.message || "清除失败", "error"); + } finally { + resetActionButtons(); + } +} + +saveBtn.addEventListener("click", () => { + saveConfig().catch((err) => setMessage(err.message || "保存失败", "error")); +}); + +testBtn.addEventListener("click", () => { + testConfig().catch((err) => setMessage(err.message || "测试失败", "error")); +}); + +clearBtn.addEventListener("click", () => { + clearConfig().catch((err) => setMessage(err.message || "清除失败", "error")); +}); + +loadConfig().then(() => { + setMessage("已加载当前配置", "info"); +}).catch((err) => { + setMessage(err.message || "加载失败", "error"); +}); diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..c92189d --- /dev/null +++ b/popup.html @@ -0,0 +1,205 @@ + + + + + + Transly + + + +
+
+ +
+
Transly
+
快速直译,尽量少一步
+
+
+ + + + + + +
+
+ + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..7d495bb --- /dev/null +++ b/popup.js @@ -0,0 +1,125 @@ +const statusEl = document.getElementById("status"); +const btnSelection = document.getElementById("translateSelection"); +const btnPage = document.getElementById("translatePage"); +const btnRestore = document.getElementById("restorePage"); +const btnOptions = document.getElementById("openOptions"); + +function setStatus(text, type = "info") { + statusEl.textContent = text || ""; + statusEl.dataset.type = type; +} + +function setButtonBusy(button, busy, busyText) { + if (!button) { + return; + } + if (!button.dataset.label) { + button.dataset.label = button.textContent; + } + button.disabled = busy; + button.textContent = busy ? busyText : button.dataset.label; +} + +function setAllButtonsDisabled(disabled) { + btnSelection.disabled = disabled; + btnPage.disabled = disabled; + btnRestore.disabled = disabled; + btnOptions.disabled = disabled; +} + +async function getActiveTabId() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) { + throw new Error("未找到当前标签页"); + } + return tab.id; +} + +async function sendAction(action) { + const tabId = await getActiveTabId(); + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, { action }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error("当前页面不支持此操作")); + return; + } + if (response?.ok === false) { + reject(new Error(response.error || "操作失败")); + return; + } + resolve(); + }); + }); +} + +async function translateSelectionInTab() { + const tabId = await getActiveTabId(); + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action: "translateSelectionInTab", tabId }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response?.ok) { + reject(new Error(response?.error || "翻译失败")); + return; + } + resolve({ + text: response.text || "", + shown: !!response.shown + }); + }); + }); +} + +async function runAction(button, busyText, action) { + setStatus(""); + setAllButtonsDisabled(true); + setButtonBusy(button, true, busyText); + + try { + const result = await action(); + return result; + } finally { + setButtonBusy(button, false, busyText); + setAllButtonsDisabled(false); + } +} + +btnOptions.addEventListener("click", () => { + chrome.runtime.openOptionsPage(); + window.close(); +}); + +btnSelection.addEventListener("click", async () => { + try { + const result = await runAction(btnSelection, "翻译中...", translateSelectionInTab); + if (!result.shown && result.text) { + alert(result.text); + } + setStatus("翻译完成", "success"); + setTimeout(() => window.close(), 120); + } catch (err) { + setStatus(err.message || "翻译失败", "error"); + } +}); + +btnPage.addEventListener("click", async () => { + try { + await runAction(btnPage, "启动中...", () => sendAction("translatePage")); + setStatus("已开启随滚动翻译", "success"); + setTimeout(() => window.close(), 120); + } catch (err) { + setStatus(err.message || "操作失败", "error"); + } +}); + +btnRestore.addEventListener("click", async () => { + try { + await runAction(btnRestore, "恢复中...", () => sendAction("restorePage")); + setStatus("已恢复原文", "success"); + setTimeout(() => window.close(), 120); + } catch (err) { + setStatus(err.message || "操作失败", "error"); + } +});