diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ad68d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*bak** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b68ae7 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# 30 分钟,使用 cursor 开发的一个极简低配版"沉浸式翻译"chrome 插件 + +## 说明 + +- 这个插件是使用 cursor 的 composer 功能开发的,我本人是完全不懂 chrome 插件开发的。 +- 翻译功能是基于调用在线服务大模型 API 实现的,所以需要使用者有可用的平台地址和 ak。 + - 因为是调用大模型API进行翻译,可能完全翻译完会比较慢。 + - 可以试用下我写在代码里的是硅基流动中免费的模型 `Qwen/Qwen2.5-7B-Instruct`。 + - 当然,如果对硅基流动平台感兴趣,还能用下我的邀请码注册,那就更好了: + - https://cloud.siliconflow.cn/i/tRIcST68 +- 整页翻译的内容会保存在缓存中,1小时内同一个网站不会重复调用API进行翻译。 + - 如果需要强制重新翻译,可以点击对应的清除缓存按钮后,重新翻译。 + +## 先看看效果 + +- 安装插件后,点击插件图标,右上角会显示出功能弹窗: + +![点击插件按钮出现的嵌入式页面](./screenshots/点击插件按钮出现的嵌入式页面.png) + +- 点击“设置”按钮,配置大模型平台地址、模型名、和 AK,**记得首次使用要保存设置才生效**。 + +![自定义大模型API地址和ak](./screenshots/自定义大模型API地址和ak.png) + +- 整页翻译:对比翻译的效果 + +![对比翻译示例页面](./screenshots/对比翻译示例页面.png) + +- 整页翻译:替换翻译的效果 + +![替换翻译示例页面](./screenshots/替换翻译示例页面.png) + +- 划词翻译:对只需要翻译网页中部分文本,在选中文本(划词)后,会出现一个小的“翻译”按钮,点击之后就会弹窗显示翻译结果,目标语言在右上角的配置面板中指定。 + +![划词翻译](./screenshots/划词翻译.png) + +- 如果是阅读 pdf 文件,或者也是一般网页,右键选择“AI 翻译助手-翻译选中文本”,会弹出独立翻译窗口。 + +![pdf右键](./screenshots/pdf右键.png) + +- 这个独立窗口可以当成个简单的翻译工具,复制需要翻译的内容,选择目标语言,然后随意翻译即可。 + +![独立翻译弹窗](./screenshots/独立翻译弹窗.png) + +## 其他补充 + +- 翻译效果和大模型质量相关 +- 网页内容过大,可能翻译比较慢,只会翻译点击翻译时已经加载的内容 +- 嵌入式(对比翻译)效果不一定好看 + +## 安装使用 + +下载这个项目,解压后,打开 chrome 或 edge浏览器,进入 `chrome://extensions/` 或`edge://extensions/`页面,点击“加载已解压的扩展程序”,选择解压后的文件夹即可。 + +首次使用一定点击“设置”按钮或者插件图标右键选“选项”,去配置 API 地址、AK 和模型名称,点击“保存设置”。 \ No newline at end of file diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..6d28cf4 --- /dev/null +++ b/background/background.js @@ -0,0 +1,194 @@ +// 获取特定标签页的设置 +const getTabSettings = async (tabId) => { + return new Promise((resolve) => { + chrome.storage.local.get( + { + [`targetLang_${tabId}`]: 'zh' // 获取特定标签页的目标语言 + }, + async (tabItems) => { + // 获取全局API设置 + const globalSettings = await chrome.storage.sync.get({ + apiEndpoint: '', // 移除默认值 + apiKey: '', + model: '' + }); + + resolve({ + ...globalSettings, + targetLang: tabItems[`targetLang_${tabId}`] + }); + } + ); + }); +}; + +// 调用API进行翻译 +const translateWithAPI = async (text, targetLang, apiKey, model, apiEndpoint) => { + // 检查必要的API设置 + if (!apiEndpoint || !apiKey || !model) { + throw new Error('请先在设置页面配置API信息'); + } + + const response = await fetch(apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: model, + messages: [ + { + role: "system", + content: `你是一个翻译助手。请将用户输入的文本翻译成${targetLang},只返回翻译结果,不需要解释。` + }, + { + role: "user", + content: text + } + ], + temperature: 0.3 + }) + }); + + if (!response.ok) { + throw new Error('翻译请求失败'); + } + + const data = await response.json(); + return data.choices[0].message.content.trim(); +}; + +// 监听来自content script的消息 +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'translate') { + (async () => { + try { + let settings; + if (request.isPopupWindow) { + // 如果是独立窗口的请求,获取全局API设置 + settings = await chrome.storage.sync.get({ + apiEndpoint: '', + apiKey: '', + model: '' + }); + settings.targetLang = request.targetLang; + } else { + // 否则使用标签页的设置 + settings = await getTabSettings(request.tabId); + } + + const translation = await translateWithAPI( + request.text, + settings.targetLang, + settings.apiKey, + settings.model, + settings.apiEndpoint + ); + sendResponse({ translation }); + } catch (error) { + sendResponse({ error: error.message }); + } + })(); + return true; + } else if (request.action === 'getCurrentTabId') { + sendResponse({ tabId: sender.tab?.id }); + return false; + } +}); + +// 存储面板状态(使用对象而不是 Map) +let panelStates = {}; + +// 添加新的消息处理 +chrome.action.onClicked.addListener(async (tab) => { + const tabId = tab.id; + + try { + // 检查当前标签页是否已有面板 + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tabId }, + func: togglePanel, + args: [tabId, !!panelStates[tabId]] // 转换为布尔值 + }); + + // 更新面板状态 + panelStates[tabId] = result.result; + } catch (error) { + console.error('面板切换失败:', error); + } +}); + +// 监听标签页关闭事件 +chrome.tabs.onRemoved.addListener((tabId) => { + delete panelStates[tabId]; +}); + +// 面板切换函数 +function togglePanel(tabId, isVisible) { + let panel = document.querySelector(`.translator-panel[data-tab-id="${tabId}"]`); + + if (panel) { + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + return panel.style.display === 'block'; + } else { + const iframe = document.createElement('iframe'); + iframe.className = 'translator-panel'; + iframe.setAttribute('data-tab-id', tabId); + iframe.src = chrome.runtime.getURL('content/panel.html'); + iframe.style.display = 'block'; + document.body.appendChild(iframe); + return true; + } +} + +let translateWindow = null; + +// 创建右键菜单 +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'translateSelection', + title: 'AI翻译助手 - 翻译选中文本', + contexts: ['selection'] + }); +}); + +// 处理右键菜单点击 +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId === 'translateSelection') { + const selectedText = info.selectionText; + + if (!translateWindow) { + // 创建新窗口 + const window = await chrome.windows.create({ + url: chrome.runtime.getURL('translate/translate.html'), + type: 'popup', + width: 800, + height: 600 + }); + translateWindow = window.id; + + // 等待窗口加载完成 + setTimeout(() => { + chrome.runtime.sendMessage({ + action: 'updateText', + text: selectedText + }); + }, 1000); + } else { + // 更新已存在的窗口 + chrome.windows.update(translateWindow, { focused: true }); + chrome.runtime.sendMessage({ + action: 'updateText', + text: selectedText + }); + } + } +}); + +// 监听窗口关闭 +chrome.windows.onRemoved.addListener((windowId) => { + if (windowId === translateWindow) { + translateWindow = null; + } +}); \ No newline at end of file diff --git a/content/cache-manager.js b/content/cache-manager.js new file mode 100644 index 0000000..c5b6916 --- /dev/null +++ b/content/cache-manager.js @@ -0,0 +1,169 @@ +// 缓存管理器 +const CacheManager = { + // 缓存有效期(1小时) + CACHE_DURATION: 3600000, + + // 初始化缓存 + async init() { + const result = await chrome.storage.local.get('translationCache'); + if (!result.translationCache) { + await chrome.storage.local.set({ translationCache: {} }); + } else { + // 清理过期缓存 + await this.cleanExpiredCache(); + } + }, + + // 清理过期缓存 + async cleanExpiredCache() { + const result = await chrome.storage.local.get('translationCache'); + const translationCache = result.translationCache; + let hasExpired = false; + + if (translationCache) { + const now = Date.now(); + for (const key of Object.keys(translationCache)) { + if (now - translationCache[key].timestamp > this.CACHE_DURATION) { + delete translationCache[key]; + hasExpired = true; + } + } + + if (hasExpired) { + await chrome.storage.local.set({ translationCache }); + } + } + }, + + // 生成缓存键 - 简化缓存键的生成方式 + generateCacheKey(url, text, targetLang, type) { + // 使用文本内容的哈希作为缓存键的一部分 + const textHash = this.hashString(text); + return `${url}_${targetLang}_${type}_${textHash}`; + }, + + // 字符串哈希函数 + hashString(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(16); + }, + + // 获取缓存 + async getCache(url, text, targetLang, type) { + const cacheKey = this.generateCacheKey(url, text, targetLang, type); + const result = await chrome.storage.local.get('translationCache'); + const cache = result.translationCache[cacheKey]; + + if (!cache) { + console.log(`[缓存未命中] ${text.slice(0, 30)}...`); + return null; + } + + // 检查缓存是否过期 + if (Date.now() - cache.timestamp > this.CACHE_DURATION) { + console.log(`[缓存过期] ${text.slice(0, 30)}...`); + await this.removeCache(cacheKey); + return null; + } + + console.log(`[缓存命中] ${text.slice(0, 30)}...`); + return cache; + }, + + // 设置缓存 + async setCache(url, text, translation, targetLang, type) { + const cacheKey = this.generateCacheKey(url, text, targetLang, type); + const result = await chrome.storage.local.get('translationCache'); + const translationCache = result.translationCache || {}; + + translationCache[cacheKey] = { + url, + text, + translation, + targetLang, + type, + timestamp: Date.now() + }; + + await chrome.storage.local.set({ translationCache }); + console.log(`[缓存已保存] ${text.slice(0, 30)}...`); + }, + + // 移除缓存 + async removeCache(cacheKey) { + const result = await chrome.storage.local.get('translationCache'); + const translationCache = result.translationCache; + if (translationCache && translationCache[cacheKey]) { + delete translationCache[cacheKey]; + await chrome.storage.local.set({ translationCache }); + } + }, + + // 清除特定网页和目标语言的缓存 + async clearTypeCache(url, targetLang, type) { + const result = await chrome.storage.local.get('translationCache'); + const translationCache = result.translationCache; + let hasCache = false; + + if (translationCache) { + const prefix = `${url}_${targetLang}_${type}_`; + for (const key of Object.keys(translationCache)) { + if (key.startsWith(prefix)) { + delete translationCache[key]; + hasCache = true; + } + } + if (hasCache) { + await chrome.storage.local.set({ translationCache }); + console.log(`[缓存已清除] ${url} ${type}`); + } + } + + return { success: hasCache, empty: !hasCache }; + }, + + // 检查特定网页和目标语言是否有缓存 + async hasCache(url, targetLang, type) { + const result = await chrome.storage.local.get('translationCache'); + const translationCache = result.translationCache; + + if (!translationCache) return false; + + const prefix = `${url}_${targetLang}_${type}_`; + const now = Date.now(); + + return Object.keys(translationCache).some(key => + key.startsWith(prefix) && + now - translationCache[key].timestamp <= this.CACHE_DURATION + ); + }, + + // 获取页面的所有缓存翻译 + async getPageCache(url, targetLang, type) { + const result = await chrome.storage.local.get('translationCache'); + const translationCache = result.translationCache; + const pageCache = {}; + + if (translationCache) { + const prefix = `${url}_${targetLang}_${type}_`; + const now = Date.now(); + + for (const [key, cache] of Object.entries(translationCache)) { + if (key.startsWith(prefix) && + now - cache.timestamp <= this.CACHE_DURATION) { + pageCache[cache.text] = cache.translation; + } + } + } + + return pageCache; + } +}; + +// 初始化缓存管理器 +CacheManager.init(); \ No newline at end of file diff --git a/content/content.css b/content/content.css new file mode 100644 index 0000000..477419c --- /dev/null +++ b/content/content.css @@ -0,0 +1,115 @@ +.ai-translator-popup { + position: absolute; + z-index: 10000; + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 12px; + max-width: 300px; + display: none; + backdrop-filter: blur(5px); + color: #333; +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .ai-translator-popup { + background: rgba(40, 40, 40, 0.95); + border-color: rgba(255, 255, 255, 0.1); + color: #fff; + } +} + +.ai-translator-content { + font-size: 14px; + line-height: 1.5; +} + +.ai-translator-loading { + color: inherit; + opacity: 0.7; + font-style: italic; +} + +.ai-translation { + color: #666; + margin-left: 4px; +} + +.translate-button { + position: absolute; + z-index: 10000; + background: rgba(76, 175, 80, 0.9); + color: white; + border: none; + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + backdrop-filter: blur(5px); + transition: background-color 0.2s; +} + +.translate-button:hover { + background: rgba(69, 160, 73, 0.95); +} + +.translator-panel { + position: fixed; + top: 20px; + right: 20px; + width: 256px; + min-height: 300px; + max-height: 80vh; + height: fit-content; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + z-index: 10000; + display: none; +} + +.panel-container { + padding: 15px; + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.ai-translation-container { + margin-top: 8px; + margin-bottom: 8px; + padding-left: 12px; + border-left: 2px solid var(--theme-color, #4CAF50); + opacity: 0.9; + color: inherit; +} + +.language-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15px; +} + +.arrow { + color: #666; + margin: 0 8px; +} + +.ai-translation-inline { + color: inherit; + opacity: 0.8; + margin-left: 4px; + font-size: 0.95em; +} + +/* 只有当内容超过最大高度时才显示滚动条 */ +@media (max-height: 500px) { + .translator-panel { + overflow-y: auto; + } +} + \ No newline at end of file diff --git a/content/content.js b/content/content.js new file mode 100644 index 0000000..1085d10 --- /dev/null +++ b/content/content.js @@ -0,0 +1,866 @@ +let isTranslated = false; +let translatedNodes = new Map(); + +// 存储检测到的语言 +let detectedLanguage = null; + +// 检测文本语言 +const detectLanguage = async (text) => { + try { + const hasChineseChars = /[\u4e00-\u9fa5]/.test(text); + const hasJapaneseChars = /[\u3040-\u30ff]/.test(text); + const hasKoreanChars = /[\uac00-\ud7af]/.test(text); + + if (hasChineseChars) return 'zh'; + if (hasJapaneseChars) return 'ja'; + if (hasKoreanChars) return 'ko'; + return 'en'; + } catch (error) { + console.error('语言检测失败:', error); + return 'unknown'; + } +}; + +// 判断是否是文本内容节点 +const isTextContentNode = (node) => { + // 检查节点是否有父元素 + if (!node.parentElement) { + return false; + } + + const textTags = ['p', 'article', 'section', 'li', 'dd', 'div']; + const parent = node.parentElement; + + // 检查标签名 + if (textTags.includes(parent.tagName.toLowerCase())) { + // 检查是否是列表项 + if (parent.tagName.toLowerCase() === 'li') return true; + + // 检查是否是主要文本内容 + const text = parent.textContent.trim(); + return text.length > 20 || text.includes('。') || text.includes('.'); + } + + return false; +}; + +// 创建翻译容器 +const createTranslationContainer = (originalNode) => { + // 检查节点是否有父元素 + if (!originalNode.parentElement) { + return null; + } + + const container = document.createElement('div'); + container.className = 'ai-translation-container'; + + // 复制原始节点的样式 + const originalStyle = window.getComputedStyle(originalNode.parentElement); + container.style.fontFamily = originalStyle.fontFamily; + container.style.fontSize = originalStyle.fontSize; + container.style.lineHeight = originalStyle.lineHeight; + container.style.color = originalStyle.color; + + return container; +}; + +// 创建内联翻译元素 +const createInlineTranslation = (originalNode) => { + const span = document.createElement('span'); + span.className = 'ai-translation-inline'; + return span; +}; + +// 获取当前标签页ID +const getCurrentTabId = async () => { + try { + // 如果是在content script中运行 + if (chrome.runtime?.id) { + const response = await chrome.runtime.sendMessage({ action: 'getCurrentTabId' }); + return response.tabId; + } + // 如果是在独立窗口中运行 + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + return tabs[0].id; + } catch (error) { + console.error('获取标签页ID失败:', error); + return null; + } +}; + +// 检测页面主要语言 +const detectPageLanguage = async () => { + const mainContent = document.body.innerText.slice(0, 1000); + detectedLanguage = await detectLanguage(mainContent); + + // 发送检测结果 + const tabId = await getCurrentTabId(); + if (tabId) { + chrome.runtime.sendMessage({ + action: 'updateSourceLanguage', + language: detectedLanguage, + tabId: tabId + }); + } +}; + +// 监听面板创建事件 +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'panelCreated') { + // 如果已经检测到语言,立即发送 + if (detectedLanguage) { + chrome.runtime.sendMessage({ + action: 'updateSourceLanguage', + language: detectedLanguage, + tabId: request.tabId + }); + sendResponse({}); // 立即发送响应 + } else { + // 如果还没有检测结果,立即进行检测 + detectPageLanguage().then(() => { + sendResponse({}); // 检测完成后发送响应 + }); + return true; // 表示将异步发送响应 + } + } else if (request.action === 'translatePage') { + translatePage().then(() => { + sendResponse({}); // 翻译完成后发送响应 + }); + return true; // 表示将异步发送响应 + } else if (request.action === 'replaceTranslate') { + replaceTranslate().then(() => { + sendResponse({}); // 替换翻译完成后发送响应 + }); + return true; // 表示将异步发送响应 + } else if (request.action === 'restoreOriginal') { + restoreOriginal(); + sendResponse({}); // 立即发送响应 + } else if (request.action === 'detectChineseVariant') { + const isTraditional = detectChineseVariant(document.body.innerText); + sendResponse({ isTraditional }); + } +}); + +// 在页面加载完成后立即开始检测 +document.addEventListener('DOMContentLoaded', () => { + detectPageLanguage(); +}); + +// 在页面内容变化时重新检测 +const observer = new MutationObserver(() => { + if (document.body) { + detectPageLanguage(); + observer.disconnect(); + } +}); + +observer.observe(document.documentElement, { + childList: true, + subtree: true +}); + +// 创建翻译按钮 +const createTranslateButton = () => { + const button = document.createElement('button'); + button.className = 'translate-button'; + button.textContent = '翻译'; + return button; +}; + +// 创建翻译弹窗 +const createTranslationPopup = () => { + const popup = document.createElement('div'); + popup.className = 'ai-translator-popup'; + popup.innerHTML = ` +
+
+
翻译中...
+
+ `; + document.body.appendChild(popup); + return popup; +}; + +// 获取选中的文本 +const getSelectedText = () => { + const selection = window.getSelection(); + return selection.toString().trim(); +}; + +// 定位弹窗 +const positionPopup = (element, rect) => { + const top = rect.bottom + window.scrollY; + const left = rect.left + window.scrollX; + + element.style.top = `${top}px`; + element.style.left = `${left}px`; +}; + +// 添加翻译缓存和控制变量 +let translationCache = { + compare: new Map(), + replace: new Map() +}; +let isTranslating = { + compare: false, + replace: false +}; +let shouldStopTranslation = { + compare: false, + replace: false +}; + +// 缓存翻译结果 +const cacheTranslation = (type, text, translation) => { + const cacheKey = `${type}_${text}`; + const cacheData = { + translation, + timestamp: Date.now() + }; + translationCache[type].set(cacheKey, cacheData); +}; + +// 获取缓存的翻译 +const getCachedTranslation = (type, text) => { + const cacheKey = `${type}_${text}`; + const cached = translationCache[type].get(cacheKey); + if (!cached) return null; + + // 检查缓存是否过期(1小时) + if (Date.now() - cached.timestamp > 3600000) { + translationCache[type].delete(cacheKey); + return null; + } + + return cached.translation; +}; + +// 修改清除缓存函数 +const clearCache = async (type) => { + if (isTranslating[type]) { + return { success: false, message: '翻译进行中,无法清除缓存' }; + } + + const url = window.location.href; + const targetLang = await getCurrentTargetLang(); + return await CacheManager.clearTypeCache(url, targetLang, type); +}; + +// 修改检查缓存状态函数 +const checkCache = async () => { + const url = window.location.href; + const targetLang = await getCurrentTargetLang(); + return { + hasCompareCache: await CacheManager.hasCache(url, targetLang, 'compare'), + hasReplaceCache: await CacheManager.hasCache(url, targetLang, 'replace') + }; +}; + +// 监听消息 +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'stopTranslation') { + const type = request.translationType; + if (type) { + stopTranslation(type); + } + sendResponse({}); + } else if (request.action === 'clearCache') { + // 修改为异步处理 + (async () => { + const result = await clearCache(request.cacheType); + sendResponse(result); + })(); + return true; // 保持消息通道打开以等待异步响应 + } else if (request.action === 'checkCache') { + // 修改为异步处理 + (async () => { + const result = await checkCache(); + sendResponse(result); + })(); + return true; // 保持消息通道打开以等待异步响应 + } + // ... 其他消息处理保持不变 ... +}); + +// 获取节点的唯一标识 +const getNodeIdentifier = (node) => { + // 获取节点在其父元素中的索引 + const getNodeIndex = (node) => { + let index = 0; + let sibling = node; + while (sibling.previousSibling) { + if (sibling.previousSibling.nodeType === Node.TEXT_NODE) { + index++; + } + sibling = sibling.previousSibling; + } + return index; + }; + + // 安全地获取类名 + const getClassNames = (element) => { + if (!element) return ''; + if (typeof element.className === 'string') { + return element.className; + } + if (element.className && element.className.baseVal) { + // 处理 SVG 元素的类名 + return element.className.baseVal; + } + // 如果都不是,返回空字符串 + return ''; + }; + + // 构建节点路径 + const buildNodePath = (node) => { + const path = []; + let current = node; + + while (current && current.parentElement) { + const parent = current.parentElement; + const index = getNodeIndex(current); + const tag = parent.tagName.toLowerCase(); + const className = getClassNames(parent); + const classes = className ? `.${className.split(' ').join('.')}` : ''; + const id = parent.id ? `#${parent.id}` : ''; + path.unshift(`${tag}${id}${classes}:nth-text(${index})`); + current = parent; + } + + return path.join(' > '); + }; + + try { + // 返回节点的唯一标识 + return `${buildNodePath(node)}_${node.textContent.trim().length}`; + } catch (error) { + console.error('生成节点标识失败:', error); + // 返回一个基于时间戳的备用标识 + return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +}; + +// 修改翻译函数,添加节点标识 +const translateText = async (text, type = 'compare', nodeId = '') => { + const url = window.location.href; + const targetLang = await getCurrentTargetLang(); + + // 生成缓存键时包含节点标识 + const cacheKey = `${url}_${targetLang}_${type}_${nodeId}`; + + // 检查缓存 + const cached = await CacheManager.getCache(url, text, targetLang, type, nodeId); + if (cached) { + console.log(`[缓存] 使用缓存的翻译结果: ${text.slice(0, 30)}...`); + return cached.translation; + } + + // 如果没有缓存,调用API翻译 + console.log(`[API] 调用API翻译: ${text.slice(0, 30)}...`); + + const response = await chrome.runtime.sendMessage({ + action: 'translate', + text: text, + tabId: await getCurrentTabId() + }); + + // 缓存翻译结果时包含节点标识 + if (response.translation) { + await CacheManager.setCache(url, text, response.translation, targetLang, type, nodeId); + } + + return response.translation; +}; + +// 获取当前目标语言 +const getCurrentTargetLang = async () => { + const tabId = await getCurrentTabId(); + const result = await chrome.storage.local.get(`targetLang_${tabId}`); + return result[`targetLang_${tabId}`] || 'zh-CN'; +}; + +// 检查是否是PDF查看器 +const isPDFViewer = () => { + return window.location.pathname.endsWith('.pdf') || + document.querySelector('embed[type="application/pdf"]') !== null; +}; + +// 处理选中文本事件 +let translateButton = null; +let popup = null; + +document.addEventListener('mouseup', async () => { + const selectedText = getSelectedText(); + + if (!selectedText) { + if (translateButton) { + translateButton.style.display = 'none'; + } + if (popup) { + popup.style.display = 'none'; + } + return; + } + + // 特殊处理PDF查看器 + if (isPDFViewer()) { + if (!popup) { + popup = createTranslationPopup(); + } + + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + popup.style.display = 'block'; + popup.querySelector('.ai-translator-text').textContent = ''; + popup.querySelector('.ai-translator-loading').style.display = 'block'; + positionPopup(popup, rect); + + try { + const translation = await translateText(selectedText); + popup.querySelector('.ai-translator-loading').style.display = 'none'; + popup.querySelector('.ai-translator-text').textContent = translation; + } catch (error) { + popup.querySelector('.ai-translator-loading').style.display = 'none'; + popup.querySelector('.ai-translator-text').textContent = '翻译失败,请重试'; + } + return; + } + + // 普通网页的理逻辑保持不变 + if (!translateButton) { + translateButton = createTranslateButton(); + document.body.appendChild(translateButton); + } + + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + translateButton.style.display = 'block'; + positionPopup(translateButton, rect); + + translateButton.onclick = async () => { + if (!popup) { + popup = createTranslationPopup(); + } + + popup.style.display = 'block'; + popup.querySelector('.ai-translator-text').textContent = ''; + popup.querySelector('.ai-translator-loading').style.display = 'block'; + positionPopup(popup, rect); + + try { + const translation = await translateText(selectedText); + popup.querySelector('.ai-translator-loading').style.display = 'none'; + popup.querySelector('.ai-translator-text').textContent = translation; + } catch (error) { + popup.querySelector('.ai-translator-loading').style.display = 'none'; + popup.querySelector('.ai-translator-text').textContent = '翻译失败,请重试'; + } + }; +}); + +// 更新翻译进度 +const updateProgress = (progress) => { + chrome.runtime.sendMessage({ + action: 'updateProgress', + progress: progress + }); +}; + +// 判断是否是有效的文本内容 +const isValidText = (text, node) => { + // 移除空白字符 + text = text.trim(); + if (!text || text.length < 2) return false; + + // 检查父元素是否是需要排除的元素 + const invalidParents = ['script', 'style', 'noscript', 'code', 'pre']; + let current = node.parentElement; + while (current) { + if (invalidParents.includes(current.tagName.toLowerCase())) { + return false; + } + current = current.parentElement; + } + + // 检查是否是代码或脚本内容 + const codePatterns = [ + /^[{(\[`].*[})\]`]$/, // 代码块 + /^(function|class|const|let|var)\s/, // 代码声明 + /^[a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$]/, // 对象属性访问 + /^self\./, // self引用 + /^window\./, // window引用 + /^document\./, // document引用 + /^[0-9a-f]{32,}$/, // 哈希值 + /^data:/, // Data URLs + /^https?:\/\//, // URLs + /^[0-9.]+$/, // 纯数字 + /^#[0-9a-f]{3,8}$/, // 颜色代码 + ]; + + if (codePatterns.some(pattern => pattern.test(text))) { + return false; + } + + // 检查是否包含有效的文本特征 + const validFeatures = [ + // 标点符号 + /[,。!?;:""''()【】《》、]/, + // 中文字符 + /[\u4e00-\u9fa5]/, + // 英文单词(包括常见缩写) + /[A-Za-z]{2,}/, + // 日文假名 + /[\u3040-\u309F\u30A0-\u30FF]/, + // 韩文 + /[\uAC00-\uD7AF]/, + // 阿拉伯文 + /[\u0600-\u06FF]/, + // 泰文 + /[\u0E00-\u0E7F]/, + // 越南文 + /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/, + // 常见标点 + /[.!?;:]/ + ]; + + // 如果包含任何有效特征,就认为是有效文本 + if (validFeatures.some(pattern => pattern.test(text))) { + return true; + } + + // 检查父元素标签 + const validTags = [ + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'article', 'section', 'div', 'span', 'a', + 'li', 'td', 'th', 'dd', 'dt', 'label', + 'blockquote', 'cite', 'figcaption', 'time', + 'mark', 'strong', 'em', 'b', 'i', 'small', + 'caption', 'title', 'button' + ]; + + const parentTag = node.parentElement?.tagName.toLowerCase(); + if (parentTag && validTags.includes(parentTag)) { + return true; + } + + // 检查节点的角色 + const validRoles = [ + 'heading', 'article', 'paragraph', 'text', + 'contentinfo', 'button', 'link', 'label', + 'listitem', 'cell', 'gridcell', 'tooltip', + 'alert', 'status', 'log', 'note' + ]; + + const role = node.parentElement?.getAttribute('role'); + if (role && validRoles.includes(role)) { + return true; + } + + // 检查是否是列表项或表格单元格的内容 + const isListOrTableContent = node.parentElement?.closest('li, td, th, dt, dd'); + if (isListOrTableContent) { + return true; + } + + // 检查是否是链接文本 + const isLinkText = node.parentElement?.closest('a'); + if (isLinkText) { + return true; + } + + // 检查是否是按钮文本 + const isButtonText = node.parentElement?.closest('button'); + if (isButtonText) { + return true; + } + + // 检查是否是表单标签文本 + const isLabelText = node.parentElement?.closest('label'); + if (isLabelText) { + return true; + } + + return false; +}; + +// 获取节点的XPath +const getXPath = (node) => { + if (!node) return ''; + if (node.nodeType !== Node.ELEMENT_NODE) { + return getXPath(node.parentNode); + } + + let nodeCount = 0; + let hasFollowingSibling = false; + const siblings = node.parentNode.childNodes; + + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i]; + if (sibling === node) { + hasFollowingSibling = true; + } else if (hasFollowingSibling && sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === node.tagName) { + nodeCount++; + } + } + + const prefix = getXPath(node.parentNode); + const tagName = node.tagName.toLowerCase(); + const position = nodeCount > 0 ? `[${nodeCount + 1}]` : ''; + + return `${prefix}/${tagName}${position}`; +}; + +// 修改文本节点遍历逻辑 +const getTextNodes = (root) => { + const nodes = []; + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + // 基本检查 + if (!node || !node.parentElement) { + return NodeFilter.FILTER_REJECT; + } + + // 检查是否是已翻译的内容 + if (node.parentElement.classList.contains('ai-translation-container') || + node.parentElement.classList.contains('ai-translation-inline')) { + return NodeFilter.FILTER_REJECT; + } + + // 检查文本内容 + const text = node.textContent.trim(); + if (!text || !isValidText(text, node)) { + return NodeFilter.FILTER_REJECT; + } + + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + while (walker.nextNode()) { + nodes.push(walker.currentNode); + } + + return nodes; +}; + +// 修改整页翻译功能 +const translatePage = async () => { + isTranslating.compare = true; + shouldStopTranslation.compare = false; + + if (translatedNodes.size > 0) { + restoreOriginal(); + } + + const url = window.location.href; + const targetLang = await getCurrentTargetLang(); + const pageCache = await CacheManager.getPageCache(url, targetLang, 'compare'); + + const textNodes = getTextNodes(document.body); + const totalNodes = textNodes.length; + let processedNodes = 0; + + // 批量处理大小 + const BATCH_SIZE = 10; + // 批处理延迟(毫秒) + const BATCH_DELAY = 50; + + // 分批处理函数 + const processBatch = async (nodes, startIndex) => { + const batch = nodes.slice(startIndex, startIndex + BATCH_SIZE); + + for (const node of batch) { + if (shouldStopTranslation.compare) { + isTranslating.compare = false; + chrome.runtime.sendMessage({ + action: 'translationStopped', + translationType: 'compare' + }); + return; + } + + const text = node.textContent.trim(); + if (!text) continue; + + try { + // 首先检查缓存 + const nodeId = getNodeIdentifier(node); + let translation; + + // 如果有缓存,立即使用 + if (pageCache[text]) { + translation = pageCache[text]; + } else { + // 否则调用API翻译 + translation = await translateText(text, 'compare', nodeId); + } + + if (isTextContentNode(node)) { + const container = createTranslationContainer(node); + if (container) { + container.textContent = translation; + container.setAttribute('data-node-id', nodeId); + + const parent = node.parentElement; + if (parent && parent.parentNode) { + if (parent.nextSibling) { + parent.parentNode.insertBefore(container, parent.nextSibling); + } else { + parent.parentNode.appendChild(container); + } + translatedNodes.set(node, container); + } + } + } else { + const span = createInlineTranslation(node); + if (span && node.parentNode) { + span.textContent = ` (${translation})`; + span.setAttribute('data-node-id', nodeId); + if (node.nextSibling) { + node.parentNode.insertBefore(span, node.nextSibling); + } else { + node.parentNode.appendChild(span); + } + translatedNodes.set(node, span); + } + } + + processedNodes++; + updateProgress(Math.round((processedNodes / totalNodes) * 100)); + } catch (error) { + console.error('翻译失败:', error); + } + } + + // 如果还有未处理的节点,延迟处理下一批 + if (startIndex + BATCH_SIZE < nodes.length && !shouldStopTranslation.compare) { + await new Promise(resolve => setTimeout(resolve, BATCH_DELAY)); + await processBatch(nodes, startIndex + BATCH_SIZE); + } else if (!shouldStopTranslation.compare) { + // 所有节点处理完成 + isTranslating.compare = false; + chrome.runtime.sendMessage({ + action: 'translationComplete', + translationType: 'compare' + }); + } + }; + + // 开始批量处理 + await processBatch(textNodes, 0); +}; + +// 修改替换翻译功能,使用类似的批处理逻辑 +const replaceTranslate = async () => { + isTranslating.replace = true; + shouldStopTranslation.replace = false; + + if (translatedNodes.size > 0) { + restoreOriginal(); + } + + const url = window.location.href; + const targetLang = await getCurrentTargetLang(); + const pageCache = await CacheManager.getPageCache(url, targetLang, 'replace'); + + const textNodes = getTextNodes(document.body); + const totalNodes = textNodes.length; + let processedNodes = 0; + + const BATCH_SIZE = 10; + const BATCH_DELAY = 50; + + const processBatch = async (nodes, startIndex) => { + const batch = nodes.slice(startIndex, startIndex + BATCH_SIZE); + + for (const node of batch) { + if (shouldStopTranslation.replace) { + isTranslating.replace = false; + chrome.runtime.sendMessage({ + action: 'translationStopped', + translationType: 'replace' + }); + return; + } + + const text = node.textContent.trim(); + if (!text) continue; + + try { + const nodeId = getNodeIdentifier(node); + let translation; + + // 优先使用缓存 + if (pageCache[text]) { + translation = pageCache[text]; + } else { + translation = await translateText(text, 'replace', nodeId); + } + + // 保存原始文本 + translatedNodes.set(node, node.textContent); + // 替换文本内容 + node.textContent = translation; + + processedNodes++; + updateProgress(Math.round((processedNodes / totalNodes) * 100)); + } catch (error) { + console.error('翻译失败:', error); + } + } + + if (startIndex + BATCH_SIZE < nodes.length && !shouldStopTranslation.replace) { + await new Promise(resolve => setTimeout(resolve, BATCH_DELAY)); + await processBatch(nodes, startIndex + BATCH_SIZE); + } else if (!shouldStopTranslation.replace) { + isTranslating.replace = false; + chrome.runtime.sendMessage({ + action: 'translationComplete', + translationType: 'replace' + }); + } + }; + + await processBatch(textNodes, 0); +}; + +// 修改恢复原文函数 +const restoreOriginal = () => { + for (const [node, originalText] of translatedNodes) { + // 检查节点是否仍然存在于文档中 + if (node && node.parentElement && document.contains(node)) { + if (originalText instanceof Node) { + // 如果是注入式翻译,检查节点是否仍然存在于文档中 + if (originalText.parentElement && document.contains(originalText)) { + originalText.remove(); + } + } else { + // 如果是替换式翻译,恢复原文 + node.textContent = originalText; + } + } + } + translatedNodes.clear(); + isTranslated = false; + chrome.runtime.sendMessage({ action: 'restorationComplete' }); +}; + +// 检测中文变体 +const detectChineseVariant = (text) => { + const traditionalChars = /[錒-鎛]/; + return traditionalChars.test(text); +}; + +// 修改停止翻译函数 +const stopTranslation = (type) => { + shouldStopTranslation[type] = true; +}; + \ No newline at end of file diff --git a/content/languages.js b/content/languages.js new file mode 100644 index 0000000..c947334 --- /dev/null +++ b/content/languages.js @@ -0,0 +1,222 @@ +// 语言代码和名称的映射 +export const LANGUAGES = { + // 常用语言 + 'common': { + 'zh-CN': { name: '简体中文', native: '简体中文' }, + 'en': { name: '英语', native: 'English' }, + }, + // 其他语言 + 'others': { + 'zh-TW': { name: '繁体中文', native: '繁體中文' }, + 'ja': { name: '日语', native: '日本語' }, + 'ko': { name: '韩语', native: '한국어' }, + 'es': { name: '西班牙语', native: 'Español' }, + 'fr': { name: '法语', native: 'Français' }, + 'de': { name: '德语', native: 'Deutsch' }, + 'ru': { name: '俄语', native: 'Русский' }, + 'it': { name: '意大利语', native: 'Italiano' }, + 'pt': { name: '葡萄牙语', native: 'Português' }, + 'vi': { name: '越南语', native: 'Tiếng Việt' }, + 'th': { name: '泰语', native: 'ไทย' }, + 'ar': { name: '阿拉伯语', native: 'العربية' }, + // ... 可以继续添加更多语言 + } +}; + +// 获取浏览器语言 +export const getBrowserLanguage = () => { + const lang = navigator.language || navigator.userLanguage; + // 处理类似 'zh-CN' 或 'en-US' 的格式 + const baseLang = lang.split('-')[0]; + const region = lang.split('-')[1]; + + // 特殊处理中文 + if (baseLang === 'zh') { + return region === 'TW' || region === 'HK' ? 'zh-TW' : 'zh-CN'; + } + + // 检查是否支持该语言 + const allLanguages = { ...LANGUAGES.common, ...LANGUAGES.others }; + return allLanguages[baseLang] ? baseLang : 'en'; +}; + +// 使用 Google Cloud Translation API 进行语言检测 +export const detectLanguage = async (text, apiKey) => { + try { + const response = await fetch( + `https://translation.googleapis.com/language/translate/v2/detect?key=${apiKey}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + q: text + }) + } + ); + + if (!response.ok) { + throw new Error('Language detection failed'); + } + + const data = await response.json(); + return data.data.detections[0][0].language; + } catch (error) { + console.error('Language detection error:', error); + // 降级到基本检测 + return fallbackDetectLanguage(text); + } +}; + +// 更完善的语言检测规则 +const LANGUAGE_PATTERNS = { + // 东亚语言 + 'zh-CN': { + pattern: /[\u4e00-\u9fa5]/, + threshold: 0.1, + excludePatterns: [/[ぁ-んァ-ン]/, /[가-힣]/] // 排除日文和韩文 + }, + 'zh-TW': { + pattern: /[[\u4e00-\u9fa5][\u3105-\u312F]]/, + threshold: 0.1, + traditionalChars: /[錒-鎛]/ + }, + 'ja': { + pattern: /[ぁ-んァ-ン]/, + threshold: 0.05, + extraPatterns: [/[\u4e00-\u9fa5]/, /[\u30A0-\u30FF]/] // 日文中可能包含汉字和片假名 + }, + 'ko': { + pattern: /[가-힣]/, + threshold: 0.1 + }, + + // 欧洲语言 + 'en': { + pattern: /[a-zA-Z]/, + threshold: 0.5, + wordPatterns: [/\b(the|and|is|in|to|of)\b/i] + }, + 'fr': { + pattern: /[a-zA-Z]/, + threshold: 0.5, + wordPatterns: [/\b(le|la|les|et|est|dans|pour)\b/] + }, + 'de': { + pattern: /[a-zA-Z]/, + threshold: 0.5, + wordPatterns: [/\b(der|die|das|und|ist|in|zu)\b/] + }, + 'es': { + pattern: /[a-zA-Z]/, + threshold: 0.5, + wordPatterns: [/\b(el|la|los|las|es|en|por)\b/] + }, + 'it': { + pattern: /[a-zA-Z]/, + threshold: 0.5, + wordPatterns: [/\b(il|la|le|è|in|per|con)\b/] + }, + 'pt': { + pattern: /[a-zA-Z]/, + threshold: 0.5, + wordPatterns: [/\b(o|a|os|as|é|em|para)\b/] + }, + 'ru': { + pattern: /[а-яА-Я]/, + threshold: 0.3 + }, + + // 其他语系 + 'ar': { + pattern: /[\u0600-\u06FF]/, + threshold: 0.2, + rtl: true + }, + 'hi': { + pattern: /[\u0900-\u097F]/, + threshold: 0.2 + }, + 'th': { + pattern: /[\u0E00-\u0E7F]/, + threshold: 0.2 + }, + 'vi': { + pattern: /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/, + threshold: 0.1 + } +}; + +// 改进的语言检测函数 +const fallbackDetectLanguage = (text) => { + const results = {}; + const totalLength = text.length; + + // 计算每种语言的匹配度 + for (const [lang, config] of Object.entries(LANGUAGE_PATTERNS)) { + let matches = 0; + let isExcluded = false; + + // 检查排除模式 + if (config.excludePatterns) { + for (const excludePattern of config.excludePatterns) { + if (excludePattern.test(text)) { + isExcluded = true; + break; + } + } + } + + if (isExcluded) continue; + + // 基本字符匹配 + matches = (text.match(config.pattern) || []).length; + + // 检查额外模式 + if (config.extraPatterns) { + for (const pattern of config.extraPatterns) { + matches += (text.match(pattern) || []).length; + } + } + + // 检查特定词汇模式 + if (config.wordPatterns) { + const wordMatches = config.wordPatterns.reduce((acc, pattern) => { + return acc + (text.match(pattern) || []).length; + }, 0); + matches += wordMatches * 2; // 给词汇匹配更高的权重 + } + + // 特殊处理繁体中文 + if (lang === 'zh-TW' && config.traditionalChars.test(text)) { + matches *= 1.5; // 增加繁体字的权重 + } + + const ratio = matches / totalLength; + if (ratio >= config.threshold) { + results[lang] = ratio; + } + } + + // 如果没有匹配结果,返回英语 + if (Object.keys(results).length === 0) { + return 'en'; + } + + // 返回匹配度最高的语言 + return Object.entries(results).reduce((a, b) => a[1] > b[1] ? a : b)[0]; +}; + +// 格式化语言显示 +export const formatLanguageDisplay = (langCode) => { + const allLanguages = { ...LANGUAGES.common, ...LANGUAGES.others }; + const lang = allLanguages[langCode]; + return lang ? `${lang.name} (${lang.native})` : langCode; +}; + +// 验证语言代码 +export const isValidLanguageCode = (code) => { + // 检查是否符合 ISO 639-1 或 ISO 639-2 格式 + return /^[a-z]{2,3}(-[A-Z]{2,3})?$/.test(code); +}; \ No newline at end of file diff --git a/content/panel.css b/content/panel.css new file mode 100644 index 0000000..b35b33b --- /dev/null +++ b/content/panel.css @@ -0,0 +1,233 @@ +.panel-container { + padding: 12px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.panel-title { + margin: 0 0 12px 0; + font-size: 16px; + color: #333; + text-align: center; +} + +.language-section { + margin-bottom: 15px; +} + +label { + display: block; + margin-bottom: 8px; + color: #333; +} + +select { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + font-size: 14px; +} + +.action-section { + margin-top: 15px; +} + +button { + width: 100%; + padding: 10px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + margin-bottom: 15px; +} + +button:hover { + background-color: #45a049; +} + +button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.progress-bar { + width: 100%; + height: 6px; + background-color: #f0f0f0; + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; +} + +.progress { + width: 0; + height: 100%; + background-color: #4CAF50; + transition: width 0.3s ease; +} + +.progress-text { + text-align: center; + font-size: 12px; + color: #666; +} + +.language-select-container { + position: relative; +} + +#targetLang { + width: 200px; + padding: 6px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + font-size: 14px; +} + +#customLang { + width: 192px; + padding: 6px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + margin-top: 5px; +} + +.target-language { + display: flex; + flex-direction: column; + gap: 5px; +} + +#customLang.invalid { + border-color: #ff4444; + background-color: #fff8f8; +} + +#customLang.invalid::placeholder { + color: #ff4444; +} + +.language-select-container::after { + content: attr(data-tooltip); + position: absolute; + bottom: -20px; + left: 0; + font-size: 12px; + color: #666; + display: none; +} + +.language-select-container:hover::after { + display: block; +} + +.button-group { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.button-group button { + flex: 1; + padding: 8px 6px; + font-size: 13px; + margin: 0; +} + +button.active { + background-color: #388E3C; +} + +button:disabled { + background-color: #cccccc; + cursor: not-allowed; + opacity: 0.7; +} + +.translator-panel { + height: auto !important; + max-height: none !important; +} + +.cache-controls { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.secondary-button { + background-color: #757575; + font-size: 11px; + padding: 4px 6px; + height: 24px; + border-radius: 3px; + opacity: 0.8; + transition: opacity 0.2s; +} + +.secondary-button:hover { + background-color: #616161; + opacity: 1; +} + +.secondary-button:disabled { + background-color: #bdbdbd; + cursor: not-allowed; + opacity: 0.5; +} + +button.stop-translate { + background-color: #f44336; +} + +button.stop-translate:hover { + background-color: #d32f2f; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.panel-title { + margin: 0; + font-size: 16px; + color: #333; +} + +.settings-link { + color: #4CAF50; + text-decoration: none; + font-size: 13px; + padding: 4px 8px; + border-radius: 3px; + transition: background-color 0.2s; +} + +.settings-link:hover { + background-color: rgba(76, 175, 80, 0.1); +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .panel-title { + color: #fff; + } + + .settings-link { + color: #81c784; + } + + .settings-link:hover { + background-color: rgba(129, 199, 132, 0.1); + } +} + \ No newline at end of file diff --git a/content/panel.html b/content/panel.html new file mode 100644 index 0000000..aa292ec --- /dev/null +++ b/content/panel.html @@ -0,0 +1,46 @@ + + + + + AI 网页翻译 + + + +
+
+

AI 网页翻译

+ 设置 +
+
+
+ 原文语言: 检测中... + +
+ 目标语言: +
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+
0%
+
+
+ + + \ No newline at end of file diff --git a/content/panel.js b/content/panel.js new file mode 100644 index 0000000..636370e --- /dev/null +++ b/content/panel.js @@ -0,0 +1,361 @@ +import { LANGUAGES, getBrowserLanguage, formatLanguageDisplay, isValidLanguageCode } from './languages.js'; + +let targetLang = 'zh-CN'; +let currentTabId = null; + +document.addEventListener('DOMContentLoaded', async () => { + const translateButton = document.getElementById('translatePage'); + const replaceButton = document.getElementById('replaceTranslate'); + const targetLangSelect = document.getElementById('targetLang'); + const customLangInput = document.getElementById('customLang'); + const sourceLanguageSpan = document.getElementById('sourceLanguage'); + const clearCompareCache = document.getElementById('clearCompareCache'); + const clearReplaceCache = document.getElementById('clearReplaceCache'); + + // 填充语言选项 + const fillLanguageOptions = () => { + // 添加常用语言组 + const commonGroup = document.createElement('optgroup'); + commonGroup.label = '常用语言'; + Object.entries(LANGUAGES.common).forEach(([code, lang]) => { + const option = document.createElement('option'); + option.value = code; + option.textContent = `${lang.name} (${lang.native})`; + commonGroup.appendChild(option); + }); + targetLangSelect.appendChild(commonGroup); + + // 添加其他语言组 + const othersGroup = document.createElement('optgroup'); + othersGroup.label = '其他语言'; + Object.entries(LANGUAGES.others).forEach(([code, lang]) => { + const option = document.createElement('option'); + option.value = code; + option.textContent = `${lang.name} (${lang.native})`; + othersGroup.appendChild(option); + }); + targetLangSelect.appendChild(othersGroup); + + // 添加自定义语言选项 + const customOption = document.createElement('option'); + customOption.value = 'custom'; + customOption.textContent = '自定义语言...'; + targetLangSelect.appendChild(customOption); + }; + + fillLanguageOptions(); + + // 获取当前标签页ID + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + currentTabId = tabs[0].id; + + // 为面板添加唯一标识 + document.body.setAttribute('data-tab-id', currentTabId); + + // 设置默认目标语言为浏览器语言 + const browserLang = getBrowserLanguage(); + + // 加载当前标签页的目标语言设置 + chrome.storage.local.get({ [`targetLang_${currentTabId}`]: browserLang }, (items) => { + const savedLang = items[`targetLang_${currentTabId}`]; + if (Object.keys({ ...LANGUAGES.common, ...LANGUAGES.others }).includes(savedLang)) { + targetLangSelect.value = savedLang; + } else { + targetLangSelect.value = 'custom'; + customLangInput.style.display = 'block'; + customLangInput.value = savedLang; + } + targetLang = savedLang; + }); + + // 监听自定义语言输入 + customLangInput.addEventListener('input', (e) => { + const value = e.target.value.trim(); + if (isValidLanguageCode(value)) { + targetLang = value; + customLangInput.classList.remove('invalid'); + chrome.storage.local.set({ [`targetLang_${currentTabId}`]: value }); + } else { + customLangInput.classList.add('invalid'); + } + }); + + // 添加自定义语言输入提示 + customLangInput.placeholder = '输入语言代码 (如: en, zh-CN, ja)'; + customLangInput.title = '请输入符合 ISO 639-1 或 ISO 639-2 标准的语言代码'; + + // 监听语言选择变化 + targetLangSelect.addEventListener('change', (e) => { + if (e.target.value === 'custom') { + customLangInput.style.display = 'block'; + targetLang = customLangInput.value; + } else { + customLangInput.style.display = 'none'; + targetLang = e.target.value; + } + chrome.storage.local.set({ [`targetLang_${currentTabId}`]: targetLang }); + }); + + // 监听源语言更新 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.tabId !== currentTabId) return; + + if (request.action === 'updateSourceLanguage' && request.language) { + // 处理中文显示 + let displayLanguage = request.language; + if (displayLanguage === 'zh') { + // 检测是否为繁体中文 + chrome.tabs.sendMessage(currentTabId, { + action: 'detectChineseVariant' + }, (response) => { + const isTraditional = response && response.isTraditional; + displayLanguage = isTraditional ? 'zh-TW' : 'zh-CN'; + sourceLanguageSpan.textContent = formatLanguageDisplay(displayLanguage); + }); + } else { + sourceLanguageSpan.textContent = formatLanguageDisplay(displayLanguage); + } + + // 自动选择目标语言 + if (request.language === targetLang) { + const defaultTarget = request.language.startsWith('zh') ? 'en' : 'zh-CN'; + if (Object.keys(LANGUAGES).includes(defaultTarget)) { + targetLangSelect.value = defaultTarget; + customLangInput.style.display = 'none'; + } + targetLang = defaultTarget; + chrome.storage.local.set({ [`targetLang_${currentTabId}`]: defaultTarget }); + } + } + }); + + // 禁用另一个按钮的函数 + const disableOtherButton = (activeButton) => { + const otherButton = activeButton === translateButton ? replaceButton : translateButton; + otherButton.disabled = true; + }; + + // 启用所有按钮的函数 + const enableAllButtons = () => { + translateButton.disabled = false; + replaceButton.disabled = false; + }; + + // 禁用缓存清除按钮的函数 + const disableCacheButtons = () => { + clearCompareCache.disabled = true; + clearReplaceCache.disabled = true; + }; + + // 启用缓存清除按钮的函数 + const enableCacheButtons = () => { + clearCompareCache.disabled = false; + clearReplaceCache.disabled = false; + }; + + // 修改翻译按钮点击事件 + translateButton.addEventListener('click', () => { + if (translateButton.textContent === '对比翻译') { + translateButton.textContent = '停止翻译'; + translateButton.classList.add('stop-translate'); + translateButton.disabled = false; + replaceButton.disabled = true; + disableCacheButtons(); + chrome.tabs.sendMessage(currentTabId, { + action: 'translatePage', + targetLang: targetLang + }).catch(error => { + console.error('翻译请求失败:', error); + translateButton.textContent = '对比翻译'; + translateButton.classList.remove('stop-translate'); + enableAllButtons(); + enableCacheButtons(); + }); + } else if (translateButton.textContent === '停止翻译') { + chrome.tabs.sendMessage(currentTabId, { + action: 'stopTranslation', + translationType: 'compare' + }); + } else if (translateButton.textContent === '显示原文') { + translateButton.textContent = '对比翻译'; + replaceButton.textContent = '替换翻译'; + enableAllButtons(); + enableCacheButtons(); + chrome.tabs.sendMessage(currentTabId, { + action: 'restoreOriginal' + }); + } + }); + + // 替换翻译按钮点击事件 + replaceButton.addEventListener('click', () => { + if (replaceButton.textContent === '替换翻译') { + replaceButton.textContent = '停止翻译'; + replaceButton.classList.add('stop-translate'); + replaceButton.disabled = false; + translateButton.disabled = true; + disableCacheButtons(); + chrome.tabs.sendMessage(currentTabId, { + action: 'replaceTranslate', + targetLang: targetLang + }).catch(error => { + console.error('替换翻译失败:', error); + replaceButton.textContent = '替换翻译'; + replaceButton.classList.remove('stop-translate'); + enableAllButtons(); + enableCacheButtons(); + }); + } else if (replaceButton.textContent === '停止翻译') { + chrome.tabs.sendMessage(currentTabId, { + action: 'stopTranslation', + translationType: 'replace' + }); + } else if (replaceButton.textContent === '显示原文') { + replaceButton.textContent = '替换翻译'; + translateButton.textContent = '对比翻译'; + enableAllButtons(); + enableCacheButtons(); + chrome.tabs.sendMessage(currentTabId, { + action: 'restoreOriginal' + }); + } + }); + + // 清除缓存按钮点击事件 + clearCompareCache.addEventListener('click', () => { + chrome.tabs.sendMessage(currentTabId, { + action: 'clearCache', + cacheType: 'compare' + }, (response) => { + if (response.success) { + showCacheClearMessage('对比翻译缓存已清除'); + clearCompareCache.disabled = true; + } else if (response.empty) { + showCacheClearMessage('没有可清除的对比翻译缓存'); + clearCompareCache.disabled = true; + } + }); + }); + + clearReplaceCache.addEventListener('click', () => { + chrome.tabs.sendMessage(currentTabId, { + action: 'clearCache', + cacheType: 'replace' + }, (response) => { + if (response.success) { + showCacheClearMessage('替换翻译缓存已清除'); + clearReplaceCache.disabled = true; + } else if (response.empty) { + showCacheClearMessage('没有可清除的替换翻译缓存'); + clearReplaceCache.disabled = true; + } + }); + }); + + // 显示缓存清除消息 + const showCacheClearMessage = (message) => { + const progressText = document.querySelector('.progress-text'); + progressText.textContent = message; + setTimeout(() => { + progressText.textContent = ''; + }, 2000); + }; + + // 检查缓存状态并更新按钮 + const updateCacheButtons = () => { + chrome.tabs.sendMessage(currentTabId, { action: 'checkCache' }, (response) => { + clearCompareCache.disabled = !response.hasCompareCache; + clearReplaceCache.disabled = !response.hasReplaceCache; + }); + }; + + // 在面板创建和翻译完成时检查缓存状态 + document.addEventListener('DOMContentLoaded', () => { + updateCacheButtons(); + }); + + // 修改消息监听器 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (sender.tab && sender.tab.id !== currentTabId) return; + + const progressBar = document.querySelector('.progress'); + const progressText = document.querySelector('.progress-text'); + + if (request.action === 'updateProgress') { + progressBar.style.width = `${request.progress}%`; + progressText.textContent = `${request.progress}%`; + } else if (request.action === 'translationComplete') { + const translationType = request.translationType; + const activeButton = translationType === 'compare' ? translateButton : replaceButton; + const inactiveButton = translationType === 'compare' ? replaceButton : translateButton; + + activeButton.textContent = '显示原文'; + activeButton.classList.remove('stop-translate'); + activeButton.disabled = false; + + inactiveButton.disabled = true; + inactiveButton.textContent = translationType === 'compare' ? '替换翻译' : '对比翻译'; + inactiveButton.classList.remove('stop-translate'); + + progressBar.style.width = '100%'; + progressText.textContent = '100%'; + enableCacheButtons(); + } else if (request.action === 'translationStopped') { + const translationType = request.translationType; + const activeButton = translationType === 'compare' ? translateButton : replaceButton; + const inactiveButton = translationType === 'compare' ? replaceButton : translateButton; + + activeButton.textContent = translationType === 'compare' ? '对比翻译' : '替换翻译'; + activeButton.classList.remove('stop-translate'); + inactiveButton.textContent = translationType === 'compare' ? '替换翻译' : '对比翻译'; + inactiveButton.classList.remove('stop-translate'); + + progressBar.style.width = '0%'; + progressText.textContent = '已停止'; + enableAllButtons(); + enableCacheButtons(); + } + }); + + // 通知 content script 面板已创建,请求语言检测结果 + chrome.tabs.sendMessage(currentTabId, { + action: 'panelCreated', + tabId: currentTabId + }); + + // 添加设置按钮点击事件 + document.getElementById('openOptions').addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); + }); +}); + +// 监听来自content script的消息 +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + // 只处理当前标签页的消息 + if (sender.tab && sender.tab.id !== currentTabId) return; + + const translateButton = document.getElementById('translatePage'); + const progressBar = document.querySelector('.progress'); + const progressText = document.querySelector('.progress-text'); + + if (request.action === 'updateProgress') { + progressBar.style.width = `${request.progress}%`; + progressText.textContent = `${request.progress}%`; + } else if (request.action === 'translationComplete') { + translateButton.textContent = '显示原文'; + translateButton.disabled = false; + progressBar.style.width = '100%'; + progressText.textContent = '100%'; + } else if (request.action === 'restorationComplete') { + progressBar.style.width = '0%'; + progressText.textContent = '0%'; + } +}); + +// 监听标签页关闭事件,清理存储 +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabId === currentTabId) { + chrome.storage.local.remove(`targetLang_${tabId}`); + } +}); \ No newline at end of file diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..c4432ef --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ad72c36 --- /dev/null +++ b/manifest.json @@ -0,0 +1,46 @@ +{ + "manifest_version": 3, + "name": "AI 极简翻译助手", + "version": "1.0", + "description": "使用在线服务大模型的 API 进行智能网页翻译", + "permissions": [ + "storage", + "activeTab", + "scripting", + "contextMenus", + "tabs" + ], + "action": {}, + "options_page": "options/options.html", + "background": { + "service_worker": "background/background.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": [ + "content/cache-manager.js", + "content/content.js" + ], + "css": ["content/content.css"] + } + ], + "web_accessible_resources": [{ + "resources": [ + "content/panel.html", + "content/panel.css", + "content/panel.js", + "content/languages.js", + "translate/translate.html", + "translate/translate.css", + "translate/translate.js", + "options/options.html", + "options/options.css", + "options/options.js" + ], + "matches": [""] + }], + "host_permissions": [ + "https://api.siliconflow.cn/*" + ] +} \ No newline at end of file diff --git a/options/options.css b/options/options.css new file mode 100644 index 0000000..88f27f8 --- /dev/null +++ b/options/options.css @@ -0,0 +1,39 @@ +.container { + width: 80%; + max-width: 600px; + margin: 20px auto; + padding: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input, select { + width: 100%; + padding: 8px; + margin-bottom: 10px; +} + +button { + background-color: #4CAF50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +button:hover { + background-color: #45a049; +} + +#status { + margin-top: 10px; + padding: 10px; +} \ No newline at end of file diff --git a/options/options.html b/options/options.html new file mode 100644 index 0000000..300c929 --- /dev/null +++ b/options/options.html @@ -0,0 +1,28 @@ + + + + + AI 翻译助手设置 + + + +
+

AI 翻译助手设置

+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + \ No newline at end of file diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..804a01b --- /dev/null +++ b/options/options.js @@ -0,0 +1,38 @@ +document.addEventListener('DOMContentLoaded', () => { + // 加载保存的设置 + chrome.storage.sync.get( + { + apiEndpoint: 'https://api.siliconflow.cn/v1/chat/completions', + apiKey: 'sk-giizgegutggzuhelpcwgqsbfpvmlpjfdxszikbmdzwtpuovu', + model: 'Qwen/Qwen2.5-7B-Instruct' + }, + (items) => { + document.getElementById('apiEndpoint').value = items.apiEndpoint; + document.getElementById('apiKey').value = items.apiKey; + document.getElementById('model').value = items.model; + } + ); + + // 保存设置 + document.getElementById('save').addEventListener('click', () => { + const apiEndpoint = document.getElementById('apiEndpoint').value; + const apiKey = document.getElementById('apiKey').value; + const model = document.getElementById('model').value; + + chrome.storage.sync.set( + { + apiEndpoint, + apiKey, + model + }, + () => { + const status = document.getElementById('status'); + status.textContent = '设置已保存'; + status.style.color = 'green'; + setTimeout(() => { + status.textContent = ''; + }, 2000); + } + ); + }); +}); \ No newline at end of file diff --git a/panel.js b/panel.js new file mode 100644 index 0000000..fe90c5c --- /dev/null +++ b/panel.js @@ -0,0 +1,29 @@ +document.addEventListener('DOMContentLoaded', async () => { + // ... 其他初始化代码 ... + + // 获取当前标签页ID + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + currentTabId = tabs[0].id; + + // 为面板添加唯一标识 + document.body.setAttribute('data-tab-id', currentTabId); + + // 从存储中获取特定标签页的目标语言设置 + chrome.storage.sync.get({ [`targetLang_${currentTabId}`]: 'zh' }, (items) => { + targetLangSelect.value = items[`targetLang_${currentTabId}`]; + targetLang = items[`targetLang_${currentTabId}`]; + }); + + // 监听语言选择变化 + targetLangSelect.addEventListener('change', (e) => { + targetLang = e.target.value; + // 保存特定标签页的目标语言设置 + chrome.storage.sync.set({ [`targetLang_${currentTabId}`]: targetLang }); + }); + + // 通知content script面板已创建 + chrome.tabs.sendMessage(currentTabId, { + action: 'panelCreated', + tabId: currentTabId + }); +}); \ No newline at end of file diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 0000000..7b901db --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,43 @@ +.container { + width: 256px; + padding: 15px; +} + +button { + width: 100%; + padding: 10px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +button:hover { + background-color: #45a049; +} + +.divider { + margin: 15px 0; + border-top: 1px solid #ddd; +} + +.info { + font-size: 12px; +} + +.info ul { + padding-left: 20px; + margin: 5px 0; +} + +.footer { + margin-top: 15px; + text-align: right; +} + +.footer a { + color: #666; + text-decoration: none; + font-size: 12px; +} \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..eb4bab9 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,25 @@ + + + + + AI 翻译助手 + + + +
+ +
+
+

提示:

+
    +
  • 选中文本后自动显示翻译
  • +
  • 点击上方按钮翻译整页
  • +
+
+ +
+ + + \ No newline at end of file diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..4491c90 --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,13 @@ +document.addEventListener('DOMContentLoaded', () => { + // 打开设置页面 + document.getElementById('openOptions').addEventListener('click', () => { + chrome.runtime.openOptionsPage(); + }); + + // 翻译整个页面 + document.getElementById('translatePage').addEventListener('click', () => { + chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { + chrome.tabs.sendMessage(tabs[0].id, {action: 'translatePage'}); + }); + }); +}); \ No newline at end of file diff --git "a/screenshots/pdf\345\217\263\351\224\256.png" "b/screenshots/pdf\345\217\263\351\224\256.png" new file mode 100755 index 0000000..29eb48f Binary files /dev/null and "b/screenshots/pdf\345\217\263\351\224\256.png" differ diff --git "a/screenshots/\345\210\222\350\257\215\347\277\273\350\257\221.png" "b/screenshots/\345\210\222\350\257\215\347\277\273\350\257\221.png" new file mode 100755 index 0000000..5cf1f3f Binary files /dev/null and "b/screenshots/\345\210\222\350\257\215\347\277\273\350\257\221.png" differ diff --git "a/screenshots/\345\257\271\346\257\224\347\277\273\350\257\221\347\244\272\344\276\213\351\241\265\351\235\242.png" "b/screenshots/\345\257\271\346\257\224\347\277\273\350\257\221\347\244\272\344\276\213\351\241\265\351\235\242.png" new file mode 100755 index 0000000..495ad24 Binary files /dev/null and "b/screenshots/\345\257\271\346\257\224\347\277\273\350\257\221\347\244\272\344\276\213\351\241\265\351\235\242.png" differ diff --git "a/screenshots/\346\233\277\346\215\242\347\277\273\350\257\221\347\244\272\344\276\213\351\241\265\351\235\242.png" "b/screenshots/\346\233\277\346\215\242\347\277\273\350\257\221\347\244\272\344\276\213\351\241\265\351\235\242.png" new file mode 100755 index 0000000..a3b7e9d Binary files /dev/null and "b/screenshots/\346\233\277\346\215\242\347\277\273\350\257\221\347\244\272\344\276\213\351\241\265\351\235\242.png" differ diff --git "a/screenshots/\347\202\271\345\207\273\346\217\222\344\273\266\346\214\211\351\222\256\345\207\272\347\216\260\347\232\204\345\265\214\345\205\245\345\274\217\351\241\265\351\235\242.png" "b/screenshots/\347\202\271\345\207\273\346\217\222\344\273\266\346\214\211\351\222\256\345\207\272\347\216\260\347\232\204\345\265\214\345\205\245\345\274\217\351\241\265\351\235\242.png" new file mode 100755 index 0000000..ca88c4a Binary files /dev/null and "b/screenshots/\347\202\271\345\207\273\346\217\222\344\273\266\346\214\211\351\222\256\345\207\272\347\216\260\347\232\204\345\265\214\345\205\245\345\274\217\351\241\265\351\235\242.png" differ diff --git "a/screenshots/\347\213\254\347\253\213\347\277\273\350\257\221\345\274\271\347\252\227.png" "b/screenshots/\347\213\254\347\253\213\347\277\273\350\257\221\345\274\271\347\252\227.png" new file mode 100755 index 0000000..46b7dc7 Binary files /dev/null and "b/screenshots/\347\213\254\347\253\213\347\277\273\350\257\221\345\274\271\347\252\227.png" differ diff --git "a/screenshots/\350\207\252\345\256\232\344\271\211\345\244\247\346\250\241\345\236\213API\345\234\260\345\235\200\345\222\214ak.png" "b/screenshots/\350\207\252\345\256\232\344\271\211\345\244\247\346\250\241\345\236\213API\345\234\260\345\235\200\345\222\214ak.png" new file mode 100755 index 0000000..12501f0 Binary files /dev/null and "b/screenshots/\350\207\252\345\256\232\344\271\211\345\244\247\346\250\241\345\236\213API\345\234\260\345\235\200\345\222\214ak.png" differ diff --git a/translate/translate.css b/translate/translate.css new file mode 100644 index 0000000..24563ff --- /dev/null +++ b/translate/translate.css @@ -0,0 +1,266 @@ +body { + margin: 0; + padding: 0 20px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + min-width: 500px; + min-height: 300px; +} + +.container { + display: grid; + grid-template-rows: auto auto 1fr; + gap: 15px; + height: 100%; +} + +.source-text, +.translated-text { + min-height: 0; +} + +h3 { + margin: 0 0 10px 0; + color: #333; + font-size: 14px; +} + +.content { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + background: #f9f9f9; + white-space: pre-wrap; + word-break: break-word; + font-size: 14px; + line-height: 1.5; +} + +.translation-controls { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; +} + +.language-select { + display: flex; + align-items: center; + gap: 10px; +} + +select { + padding: 6px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + min-width: 200px; +} + +button { + padding: 6px 12px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +button:hover { + background-color: #45a049; +} + +.loading { + color: #666; + font-style: italic; +} + +.edit-hint { + font-size: 12px; + color: #666; + font-weight: normal; +} + +textarea.content { + width: 100%; + min-height: 100px; + resize: vertical; + font-family: inherit; + box-sizing: border-box; + border: 1px solid #ddd; + outline: none; +} + +textarea.content:focus { + border-color: #4CAF50; +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + body { + background: #1e1e1e; + color: #fff; + } + + h3 { + color: #fff; + } + + .content { + background: #2d2d2d; + border-color: #444; + color: #fff; + } + + select { + background: #2d2d2d; + border-color: #444; + color: #fff; + } + + .translation-controls { + border-color: #444; + } + + .edit-hint { + color: #999; + } + + textarea.content { + background: #2d2d2d; + border-color: #444; + color: #fff; + } + + textarea.content:focus { + border-color: #81c784; + } +} + +.main-container { + display: flex; + height: 100vh; + gap: 20px; +} + +.translate-container { + flex: 2; + display: grid; + grid-template-rows: auto auto 1fr; + gap: 15px; +} + +.history-container { + flex: 1; + min-width: 256px; + border-left: 1px solid #ddd; + padding-left: 20px; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.clear-btn { + font-size: 12px; + padding: 4px 8px; + background-color: #f44336; +} + +.clear-btn:hover { + background-color: #d32f2f; +} + +.history-list { + overflow-y: auto; + max-height: calc(100vh - 100px); +} + +.history-item { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + margin-bottom: 10px; + cursor: pointer; + position: relative; +} + +.history-item:hover { + background-color: #f5f5f5; +} + +.history-item .time { + font-size: 12px; + color: #666; + margin-bottom: 5px; +} + +.history-item .source { + font-size: 13px; + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-item .target-lang { + font-size: 12px; + color: #4CAF50; + margin-bottom: 5px; +} + +.history-item .translation { + font-size: 13px; + color: #666; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-item .delete-btn { + position: absolute; + top: 5px; + right: 5px; + padding: 2px 6px; + font-size: 10px; + background-color: transparent; + color: #f44336; + display: none; +} + +.history-item:hover .delete-btn { + display: block; +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .history-container { + border-left-color: #444; + } + + .history-item { + border-color: #444; + background-color: #2d2d2d; + } + + .history-item:hover { + background-color: #383838; + } + + .history-item .time { + color: #999; + } + + .history-item .translation { + color: #bbb; + } + + .history-item .delete-btn { + color: #ff6b6b; + } +} \ No newline at end of file diff --git a/translate/translate.html b/translate/translate.html new file mode 100644 index 0000000..a6a4fed --- /dev/null +++ b/translate/translate.html @@ -0,0 +1,42 @@ + + + + + AI 翻译结果 + + + +
+
+
+

原文 (可编辑)

+ +
+
+
+ + +
+ +
+
+

译文

+
+ +
+
+
+
+
+
+

历史记录

+ +
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/translate/translate.js b/translate/translate.js new file mode 100644 index 0000000..c97c77f --- /dev/null +++ b/translate/translate.js @@ -0,0 +1,197 @@ +import { LANGUAGES, getBrowserLanguage, formatLanguageDisplay, isValidLanguageCode } from '../content/languages.js'; + +let currentText = ''; + +// 历史记录管理 +class TranslationHistory { + constructor() { + this.maxItems = 100; // 最大保存记录数 + } + + async getAll() { + const result = await chrome.storage.local.get('translationHistory'); + return result.translationHistory || []; + } + + async add(item) { + const history = await this.getAll(); + history.unshift({ + ...item, + id: Date.now(), + time: new Date().toLocaleString() + }); + + // 限制最大记录数 + if (history.length > this.maxItems) { + history.pop(); + } + + await chrome.storage.local.set({ translationHistory: history }); + return history; + } + + async remove(id) { + const history = await this.getAll(); + const newHistory = history.filter(item => item.id !== id); + await chrome.storage.local.set({ translationHistory: newHistory }); + return newHistory; + } + + async clear() { + await chrome.storage.local.set({ translationHistory: [] }); + return []; + } +} + +document.addEventListener('DOMContentLoaded', async () => { + const history = new TranslationHistory(); + const sourceContent = document.getElementById('sourceContent'); + const translatedContent = document.getElementById('translatedContent'); + const targetLangSelect = document.getElementById('targetLang'); + const translateBtn = document.getElementById('translateBtn'); + const historyList = document.getElementById('historyList'); + const clearHistoryBtn = document.getElementById('clearHistory'); + const loading = translatedContent.querySelector('.loading'); + const result = translatedContent.querySelector('.result'); + + // 填充语言选项 + const fillLanguageOptions = () => { + // 常用语言组 + const commonGroup = document.createElement('optgroup'); + commonGroup.label = '常用语言'; + Object.entries(LANGUAGES.common).forEach(([code, lang]) => { + const option = document.createElement('option'); + option.value = code; + option.textContent = `${lang.name} (${lang.native})`; + commonGroup.appendChild(option); + }); + targetLangSelect.appendChild(commonGroup); + + // 其他语言组 + const othersGroup = document.createElement('optgroup'); + othersGroup.label = '其他语言'; + Object.entries(LANGUAGES.others).forEach(([code, lang]) => { + const option = document.createElement('option'); + option.value = code; + option.textContent = `${lang.name} (${lang.native})`; + othersGroup.appendChild(option); + }); + targetLangSelect.appendChild(othersGroup); + }; + + fillLanguageOptions(); + + // 获取存储的目标语言 + chrome.storage.local.get({ 'translateWindow_targetLang': 'zh-CN' }, (items) => { + targetLangSelect.value = items.translateWindow_targetLang; + }); + + // 监听语��选择变化 + targetLangSelect.addEventListener('change', (e) => { + chrome.storage.local.set({ 'translateWindow_targetLang': e.target.value }); + }); + + // 监听文本输入 + sourceContent.addEventListener('input', () => { + currentText = sourceContent.value; + }); + + // 渲染历史记录 + const renderHistory = async () => { + const records = await history.getAll(); + historyList.innerHTML = records.map(record => ` +
+
${record.time}
+
${record.sourceText}
+
翻译为:${formatLanguageDisplay(record.targetLang)}
+
${record.translation}
+ +
+ `).join(''); + + // 添加点击事件 + historyList.querySelectorAll('.history-item').forEach(item => { + item.addEventListener('click', (e) => { + if (e.target.classList.contains('delete-btn')) { + e.stopPropagation(); + const id = parseInt(item.dataset.id); + history.remove(id).then(renderHistory); + } else { + const id = parseInt(item.dataset.id); + const record = records.find(r => r.id === id); + if (record) { + sourceContent.value = record.sourceText; + targetLangSelect.value = record.targetLang; + result.textContent = record.translation; + currentText = record.sourceText; + } + } + }); + }); + }; + + // 清除所有历史记录 + clearHistoryBtn.addEventListener('click', async () => { + if (confirm('确定要清除所有历史记录吗?')) { + await history.clear(); + renderHistory(); + } + }); + + // 修改翻译按钮点击事件 + translateBtn.addEventListener('click', async () => { + currentText = sourceContent.value.trim(); + if (!currentText) { + result.textContent = '请输入要翻译的文本'; + return; + } + + loading.style.display = 'block'; + result.textContent = ''; + + try { + const currentTargetLang = targetLangSelect.value; + const response = await chrome.runtime.sendMessage({ + action: 'translate', + text: currentText, + targetLang: currentTargetLang, + isPopupWindow: true + }); + + loading.style.display = 'none'; + const translation = response.translation || '翻译失败'; + result.textContent = translation; + + // 添加到历史记录 + await history.add({ + sourceText: currentText, + targetLang: currentTargetLang, + translation: translation + }); + renderHistory(); + } catch (error) { + loading.style.display = 'none'; + result.textContent = '翻译失败,请重试'; + } + }); + + // 监听来自background的消息 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'updateText') { + currentText = request.text; + sourceContent.value = currentText; + translateBtn.click(); // 自动开始翻译 + } + }); + + // 初始加载历史记录 + renderHistory(); + + // 支持快捷键翻译 + sourceContent.addEventListener('keydown', (e) => { + // Ctrl+Enter 或 Command+Enter 触发翻译 + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + translateBtn.click(); + } + }); +}); \ No newline at end of file