PushGo Auto Commit 2026-03-29T12:17:45.673Z
This commit is contained in:
36
README.md
Normal file
36
README.md
Normal file
@@ -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. 点击“加载已解压的扩展程序”并选择本目录
|
||||||
230
background.js
Normal file
230
background.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
206
content.js
Normal file
206
content.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 289 B |
BIN
icons/icon32.png
Normal file
BIN
icons/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 470 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 605 B |
33
manifest.json
Normal file
33
manifest.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Transly",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "极简直译 Chrome 插件",
|
||||||
|
"permissions": ["storage", "contextMenus", "activeTab", "scripting"],
|
||||||
|
"host_permissions": ["<all_urls>"],
|
||||||
|
"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": ["<all_urls>"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
288
options.html
Normal file
288
options.html
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Transly 配置</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-top: #e6f6f1;
|
||||||
|
--bg-bottom: #f7f9fc;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: #d6e6e1;
|
||||||
|
--text-main: #13221d;
|
||||||
|
--text-sub: #546860;
|
||||||
|
--brand: #0f766e;
|
||||||
|
--brand-hover: #0c625b;
|
||||||
|
--focus: #0f766e;
|
||||||
|
--danger: #b42318;
|
||||||
|
--success: #126f42;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
font-family: "Trebuchet MS", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-mark {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #24a781);
|
||||||
|
color: #ffffff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 30px rgba(17, 66, 56, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group + .group {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-sub {
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid #bfd4cc;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: #809790;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: #f7fcfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
border: none;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 132px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--brand);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--brand-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.56;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#test {
|
||||||
|
color: #124b41;
|
||||||
|
background: #e9f4f1;
|
||||||
|
border: 1px solid #bfd4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#test:hover {
|
||||||
|
background: #deefea;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear {
|
||||||
|
color: #9b1b14;
|
||||||
|
background: #fff4f2;
|
||||||
|
border: 1px solid #efcdc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear:hover {
|
||||||
|
background: #fdebe8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#save:hover {
|
||||||
|
background: var(--brand-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
#save:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg {
|
||||||
|
min-height: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg[data-type="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg[data-type="success"] {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 760px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group + .group {
|
||||||
|
border-top: 0;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.action-btn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-mark" aria-hidden="true">T</div>
|
||||||
|
<div>
|
||||||
|
<h1>Transly 配置</h1>
|
||||||
|
<div class="subtitle">最小配置即可开始直译</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card" aria-label="配置表单">
|
||||||
|
<div class="grid">
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="group-title">接口信息</h2>
|
||||||
|
<p class="group-sub">支持 OpenAI 兼容接口。</p>
|
||||||
|
|
||||||
|
<label for="baseUrl">API Base URL</label>
|
||||||
|
<input id="baseUrl" placeholder="https://api.openai.com" autocomplete="off" />
|
||||||
|
<p class="hint">提示:填写接口根地址,不要加 <code>/v1</code>(例如 <code>https://api.openai.com</code>)。</p>
|
||||||
|
|
||||||
|
<label for="apiKey">API Key</label>
|
||||||
|
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" />
|
||||||
|
<p class="hint">API Key 存在 chrome.storage.local;其余字段存在 chrome.storage.sync。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="group-title">翻译偏好</h2>
|
||||||
|
<p class="group-sub">默认低温、纯译文输出。</p>
|
||||||
|
|
||||||
|
<label for="model">Model</label>
|
||||||
|
<input id="model" placeholder="gpt-4o-mini" autocomplete="off" />
|
||||||
|
|
||||||
|
<label for="targetLang">目标语言</label>
|
||||||
|
<input id="targetLang" placeholder="zh-CN" autocomplete="off" />
|
||||||
|
<p class="hint">示例:`zh-CN`、`en`、`ja`。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="actions">
|
||||||
|
<button id="save" class="action-btn" type="button">保存配置</button>
|
||||||
|
<button id="test" class="action-btn" type="button">测试连接</button>
|
||||||
|
<button id="clear" class="action-btn" type="button">清除配置</button>
|
||||||
|
<div id="msg" role="status" aria-live="polite"></div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
153
options.js
Normal file
153
options.js
Normal file
@@ -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");
|
||||||
|
});
|
||||||
205
popup.html
Normal file
205
popup.html
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Transly</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-top: #eaf9f5;
|
||||||
|
--bg-bottom: #f6f8fb;
|
||||||
|
--panel-bg: #ffffff;
|
||||||
|
--text-main: #13221d;
|
||||||
|
--text-subtle: #4d6259;
|
||||||
|
--brand: #0f766e;
|
||||||
|
--brand-hover: #0c625b;
|
||||||
|
--line: #d7e3df;
|
||||||
|
--danger: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 300px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Trebuchet MS", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||||
|
background: linear-gradient(160deg, var(--bg-top), var(--bg-bottom));
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 10px 28px rgba(13, 58, 47, 0.12);
|
||||||
|
animation: rise 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #22a884);
|
||||||
|
color: #ffffff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn + .btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible,
|
||||||
|
.link-btn:focus-visible {
|
||||||
|
outline: 2px solid #0f766e;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled,
|
||||||
|
.link-btn:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--brand-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #eff7f5;
|
||||||
|
border-color: #cadfd8;
|
||||||
|
color: #113b33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e5f3ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tertiary {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: #35554b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tertiary:hover {
|
||||||
|
background: #f7fbfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 38px;
|
||||||
|
background: transparent;
|
||||||
|
color: #0a5d57;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
background: #eef7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
min-height: 19px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status[data-type="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status[data-type="success"] {
|
||||||
|
color: #147145;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
transform: translateY(6px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.panel,
|
||||||
|
.btn,
|
||||||
|
.link-btn {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="panel">
|
||||||
|
<header class="brand">
|
||||||
|
<div class="brand-mark" aria-hidden="true">T</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">Transly</div>
|
||||||
|
<div class="brand-sub">快速直译,尽量少一步</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<button id="translateSelection" class="btn btn-primary">翻译选中文本</button>
|
||||||
|
<button id="translatePage" class="btn btn-secondary">整页翻译(随滚动)</button>
|
||||||
|
<button id="restorePage" class="btn btn-tertiary">恢复原文</button>
|
||||||
|
<button id="openOptions" class="link-btn">打开配置(模型/API)</button>
|
||||||
|
|
||||||
|
<div id="status" role="status" aria-live="polite"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
125
popup.js
Normal file
125
popup.js
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user