大模型中文翻译助手

选中文本后调用 OpenAI Compatible API 将其翻译为中文,支持历史记录、收藏夹及整页翻译

  1. // ==UserScript==
  2. // @name 大模型中文翻译助手
  3. // @name:en LLM powered WebPage Translator to Chinese
  4. // @namespace http://tampermonkey.net/
  5. // @version 2.3.2
  6. // @description 选中文本后调用 OpenAI Compatible API 将其翻译为中文,支持历史记录、收藏夹及整页翻译
  7. // @description:en Select text and call OpenAI Compatible API to translate it to Chinese, supports history, favorites and full page translation
  8. // @author tzh
  9. // @match *://*/*
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_setValue
  12. // @grant GM_getValue
  13. // @grant GM_registerMenuCommand
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. /**
  21. * Core Application Architecture
  22. *
  23. * This refactored translator script follows a modular architecture with clear separation of concerns:
  24. * 1. Config - Application settings and configuration management
  25. * 2. State - Global state management with a pub/sub pattern
  26. * 3. API - API service for communication with LLM services
  27. * 4. UI - User interface components
  28. * 5. Utils - Utility functions
  29. * 6. Core - Core application logic and workflow
  30. */
  31.  
  32. /**
  33. * Config Module - Manages application settings
  34. */
  35. const Config = (function() {
  36. // Default settings
  37. const defaultSettings = {
  38. apiEndpoint: 'https://api.deepseek.com/v1/chat/completions',
  39. apiKey: '',
  40. model: 'deepseek-chat',
  41. systemPrompt: '你是一个翻译助手。我会为你提供待翻译的文本,以及之前已经翻译过的上下文(如果有)。请参考这些上下文,将文本准确地翻译成中文,保持原文的意思、风格和格式。在充分保留原文意思的情况下使用符合中文习惯的表达。只返回翻译结果,不需要解释。',
  42. wordExplanationPrompt: '你是一个词汇解释助手。请解释我提供的英语单词或短语。如果是单个单词,请提供音标、多种常见意思、词性分类以及每种意思下的例句。对于短语,请解释其含义和用法,并提供例句。所有内容都需要用中文解释,并使用HTML格式化,以便清晰易读。请为每个例句提供简洁的中文翻译,翻译要准确传达原句含义。返回格式示例:<div class="word-header"><h3>单词或短语</h3><div class="phonetic">/音标/</div></div><div class="meanings"><div class="meaning"><span class="part-of-speech">词性</span>: 意思解释<div class="example">例句<div class="example-translation">例句翻译</div></div></div></div>',
  43. useStreaming: false,
  44. temperature: 0.3,
  45. maxHistoryItems: 50,
  46. maxFavoritesItems: 100,
  47. showSourceLanguage: false,
  48. autoDetectLanguage: true,
  49. detectArticleContent: true,
  50. contextSize: 3,
  51. useTranslationContext: true,
  52. fullPageTranslationSelector: 'body',
  53. fullPageMaxSegmentLength: 2000,
  54. excludeSelectors: 'script, style, noscript, iframe, img, svg, canvas',
  55. apiConfigs: [
  56. {
  57. name: 'DeepSeek',
  58. apiEndpoint: 'https://api.deepseek.com/v1/chat/completions',
  59. apiKey: '',
  60. model: 'deepseek-chat',
  61. }
  62. ],
  63. currentApiIndex: 0,
  64. currentTab: 'general',
  65. };
  66.  
  67. // Current settings
  68. let settings = GM_getValue('translatorSettings', defaultSettings);
  69. // Public methods
  70. return {
  71. // Initialize settings
  72. init: () => {
  73. // Reload settings from storage
  74. settings = GM_getValue('translatorSettings', defaultSettings);
  75. // Sync API settings
  76. Config.syncApiSettings();
  77. return settings;
  78. },
  79. getSettings: () => settings,
  80. getSetting: (key) => {
  81. if(settings[key]){
  82. return settings[key]
  83. }else{
  84. settings[key] = defaultSettings[key];
  85. GM_setValue('translatorSettings', settings);
  86. return defaultSettings[key]
  87. }
  88. },
  89. // Updates a specific setting
  90. updateSetting: (key, value) => {
  91. settings[key] = value;
  92. GM_setValue('translatorSettings', settings);
  93. return settings;
  94. },
  95. // Updates multiple settings at once
  96. updateSettings: (newSettings) => {
  97. settings = { ...settings, ...newSettings };
  98. GM_setValue('translatorSettings', settings);
  99. return settings;
  100. },
  101. // Syncs API settings from the current API config
  102. syncApiSettings: () => {
  103. if (settings.apiConfigs && settings.apiConfigs.length > 0 &&
  104. settings.currentApiIndex >= 0 &&
  105. settings.currentApiIndex < settings.apiConfigs.length) {
  106. const currentApi = settings.apiConfigs[settings.currentApiIndex];
  107. settings.apiEndpoint = currentApi.apiEndpoint;
  108. settings.apiKey = currentApi.apiKey;
  109. settings.model = currentApi.model;
  110. GM_setValue('translatorSettings', settings);
  111. }
  112. }
  113. };
  114. })();
  115.  
  116. /**
  117. * State Module - Global state management with pub/sub pattern
  118. */
  119. const State = (function() {
  120. // Private state object
  121. const state = {
  122. translationHistory: GM_getValue('translationHistory', []),
  123. translationFavorites: GM_getValue('translationFavorites', []),
  124. activeTranslateButton: null,
  125. lastSelectedText: '',
  126. lastSelectionRect: null,
  127. isTranslatingFullPage: false,
  128. isTranslationPaused: false,
  129. isStopped: false,
  130. isShowingTranslation: true,
  131. translationSegments: [],
  132. lastTranslatedIndex: -1,
  133. originalTexts: [],
  134. translationCache: GM_getValue('translationCache', {}),
  135. isApplyingCache: false,
  136. cacheApplied: false,
  137. debugMode: true
  138. };
  139. // Store subscribers for each state property
  140. const subscribers = {};
  141. // Store component-specific subscriptions for cleanup
  142. const componentSubscriptions = {};
  143. // Getter for state properties
  144. const get = (key) => {
  145. return state[key];
  146. };
  147. // Setter for state properties
  148. const set = (key, value) => {
  149. // Skip if value hasn't changed
  150. if (state[key] === value) return value;
  151. // Update state
  152. const oldValue = state[key];
  153. state[key] = value;
  154. // Save persistent state to GM storage
  155. if (key === 'translationHistory' || key === 'translationFavorites' || key === 'translationCache') {
  156. GM_setValue(key, value);
  157. }
  158. // Notify subscribers
  159. if (subscribers[key]) {
  160. subscribers[key].forEach(callback => {
  161. try {
  162. callback(value, oldValue);
  163. } catch (err) {
  164. console.error(`Error in state subscriber for ${key}:`, err);
  165. }
  166. });
  167. }
  168. return value;
  169. };
  170. // Subscribe to state changes
  171. const subscribe = (key, callback) => {
  172. if (!subscribers[key]) {
  173. subscribers[key] = [];
  174. }
  175. subscribers[key].push(callback);
  176. // Return unsubscribe function
  177. return () => {
  178. if (subscribers[key]) {
  179. subscribers[key] = subscribers[key].filter(cb => cb !== callback);
  180. }
  181. };
  182. };
  183. // Subscribe to multiple state properties
  184. const subscribeMultiple = (keys, callback) => {
  185. const unsubscribers = keys.map(key => subscribe(key, callback));
  186. // Return a function that unsubscribes from all
  187. return () => {
  188. unsubscribers.forEach(unsubscribe => unsubscribe());
  189. };
  190. };
  191. // Register component subscriptions for easy cleanup
  192. const registerComponent = (componentId) => {
  193. if (!componentSubscriptions[componentId]) {
  194. componentSubscriptions[componentId] = [];
  195. }
  196. return {
  197. subscribe: (key, callback) => {
  198. const unsubscribe = subscribe(key, callback);
  199. componentSubscriptions[componentId].push(unsubscribe);
  200. return unsubscribe;
  201. },
  202. subscribeMultiple: (keys, callback) => {
  203. const unsubscribe = subscribeMultiple(keys, callback);
  204. componentSubscriptions[componentId].push(unsubscribe);
  205. return unsubscribe;
  206. },
  207. cleanup: () => {
  208. if (componentSubscriptions[componentId]) {
  209. componentSubscriptions[componentId].forEach(unsubscribe => unsubscribe());
  210. componentSubscriptions[componentId] = [];
  211. }
  212. }
  213. };
  214. };
  215. // Debug log function
  216. const debugLog = (...args) => {
  217. if (state.debugMode) {
  218. console.log('[Translator]', ...args);
  219. }
  220. };
  221. return {
  222. get,
  223. set,
  224. subscribe,
  225. subscribeMultiple,
  226. registerComponent,
  227. debugLog
  228. };
  229. })();
  230.  
  231. /**
  232. * API Module - Handles communication with LLM services
  233. */
  234. const API = (function() {
  235. // Track API errors and delays
  236. let consecutiveErrors = 0;
  237. let currentDelay = 0;
  238. let defaultDelay = 50; // Default delay between API calls
  239. let maxDelay = 5000; // Maximum delay between API calls
  240. // Reset errors when successful
  241. const resetErrorState = () => {
  242. consecutiveErrors = 0;
  243. currentDelay = defaultDelay;
  244. };
  245. // Handle API errors and adjust delay if needed
  246. const handleApiError = (error) => {
  247. consecutiveErrors++;
  248. // Increase delay after multiple consecutive errors (likely rate limiting)
  249. if (consecutiveErrors >= 3) {
  250. // Exponential backoff - increase delay but cap at maximum
  251. currentDelay = Math.min(maxDelay, currentDelay * 1.5 || defaultDelay * 2);
  252. // Append rate limiting information to error
  253. const delayInSeconds = (currentDelay / 1000).toFixed(1);
  254. error.message += ` (已自动增加延迟至${delayInSeconds}秒以减少API负载)`;
  255. // Notify through State for UI to display
  256. State.set('apiDelay', currentDelay);
  257. }
  258. return error;
  259. };
  260. // Wait for the current delay
  261. const applyDelay = async () => {
  262. if (currentDelay > 0) {
  263. await new Promise(resolve => setTimeout(resolve, currentDelay));
  264. }
  265. };
  266. // Translate text using OpenAI-compatible API
  267. const translateText = async (text, options = {}) => {
  268. // Default options
  269. const defaults = {
  270. isWordExplanationMode: false,
  271. useContext: Config.getSetting('useTranslationContext'),
  272. context: null,
  273. retryWithoutStreaming: false,
  274. onProgress: null
  275. };
  276. // Merge defaults with provided options
  277. const settings = { ...defaults, ...options };
  278. // Get configuration
  279. const apiKey = Config.getSetting('apiKey');
  280. const apiEndpoint = Config.getSetting('apiEndpoint');
  281. const model = Config.getSetting('model');
  282. const temperature = Config.getSetting('temperature');
  283. const useStreaming = settings.retryWithoutStreaming ? false : Config.getSetting('useStreaming');
  284. // Validate API key
  285. if (!apiKey) {
  286. throw new Error('API密钥未设置,请在设置面板中配置API密钥');
  287. }
  288. // Prepare prompt based on mode
  289. const systemPrompt = settings.isWordExplanationMode
  290. ? Config.getSetting('wordExplanationPrompt')
  291. : Config.getSetting('systemPrompt');
  292. // Prepare messages for the API
  293. const messages = [
  294. { role: 'system', content: systemPrompt }
  295. ];
  296. // Add context messages if available and enabled
  297. if (settings.useContext && settings.context && settings.context.length > 0) {
  298. // Add context messages in pairs (original + translation)
  299. settings.context.forEach(item => {
  300. messages.push({ role: 'user', content: item.source });
  301. messages.push({ role: 'assistant', content: item.translation });
  302. });
  303. }
  304. // Add the current text to translate
  305. messages.push({ role: 'user', content: text });
  306. // Prepare request data
  307. const requestData = {
  308. model: model,
  309. messages: messages,
  310. temperature: parseFloat(temperature),
  311. stream: useStreaming
  312. };
  313. State.debugLog('API Request:', {
  314. endpoint: apiEndpoint,
  315. data: requestData,
  316. streaming: useStreaming
  317. });
  318. // Apply delay before API call if needed
  319. await applyDelay();
  320. try {
  321. let result;
  322. // Handle non-streaming response
  323. if (!useStreaming) {
  324. result = await new Promise((resolve, reject) => {
  325. GM_xmlhttpRequest({
  326. method: 'POST',
  327. url: apiEndpoint,
  328. headers: {
  329. 'Content-Type': 'application/json',
  330. 'Authorization': `Bearer ${apiKey}`
  331. },
  332. data: JSON.stringify(requestData),
  333. onload: function(response) {
  334. try {
  335. if (response.status >= 200 && response.status < 300) {
  336. const data = JSON.parse(response.responseText);
  337. if (data.choices && data.choices[0] && data.choices[0].message) {
  338. resolve(data.choices[0].message.content);
  339. } else {
  340. reject(new Error('API响应格式不正确,无法获取翻译结果'));
  341. }
  342. } else {
  343. let errorMsg = '翻译请求失败';
  344. try {
  345. const errorData = JSON.parse(response.responseText);
  346. errorMsg = errorData.error?.message || errorMsg;
  347. } catch (e) {
  348. // If parsing fails, use the status text
  349. errorMsg = `翻译请求失败: ${response.statusText}`;
  350. }
  351. reject(new Error(errorMsg));
  352. }
  353. } catch (e) {
  354. reject(new Error(`处理API响应时出错: ${e.message}`));
  355. }
  356. },
  357. onerror: function(error) {
  358. reject(new Error(`API请求出错: ${error.statusText || '未知错误'}`));
  359. },
  360. ontimeout: function() {
  361. reject(new Error('API请求超时'));
  362. }
  363. });
  364. });
  365. } else {
  366. // Handle streaming response
  367. result = await new Promise((resolve, reject) => {
  368. let translatedText = '';
  369. let isFirstChunk = true;
  370. GM_xmlhttpRequest({
  371. method: 'POST',
  372. url: apiEndpoint,
  373. headers: {
  374. 'Content-Type': 'application/json',
  375. 'Authorization': `Bearer ${apiKey}`
  376. },
  377. data: JSON.stringify(requestData),
  378. onloadstart: function() {
  379. State.debugLog('Streaming request started');
  380. },
  381. onprogress: function(response) {
  382. try {
  383. // Parse SSE data
  384. const chunks = response.responseText.split('\n\n');
  385. let newContent = '';
  386. // Process each chunk
  387. for (let i = 0; i < chunks.length; i++) {
  388. const chunk = chunks[i].trim();
  389. if (!chunk || chunk === 'data: [DONE]') continue;
  390. if (chunk.startsWith('data: ')) {
  391. try {
  392. const data = JSON.parse(chunk.substring(6));
  393. if (data.choices && data.choices[0]) {
  394. const content = data.choices[0].delta?.content || '';
  395. if (content) {
  396. newContent += content;
  397. }
  398. }
  399. } catch (e) {
  400. State.debugLog('Error parsing chunk:', chunk, e);
  401. }
  402. }
  403. }
  404. // Update translated text
  405. translatedText += newContent;
  406. // Call progress callback if provided
  407. if (settings.onProgress && newContent) {
  408. settings.onProgress({
  409. text: translatedText,
  410. isFirstChunk: isFirstChunk
  411. });
  412. isFirstChunk = false;
  413. }
  414. } catch (e) {
  415. State.debugLog('Error processing streaming response:', e);
  416. }
  417. },
  418. onload: function(response) {
  419. if (response.status >= 200 && response.status < 300) {
  420. resolve(translatedText);
  421. } else {
  422. let errorMsg = '翻译请求失败';
  423. try {
  424. const errorData = JSON.parse(response.responseText);
  425. errorMsg = errorData.error?.message || errorMsg;
  426. } catch (e) {
  427. errorMsg = `翻译请求失败: ${response.statusText}`;
  428. }
  429. reject(new Error(errorMsg));
  430. }
  431. },
  432. onerror: function(error) {
  433. reject(new Error(`API请求出错: ${error.statusText || '未知错误'}`));
  434. },
  435. ontimeout: function() {
  436. reject(new Error('API请求超时'));
  437. }
  438. });
  439. });
  440. }
  441. // Reset error state on successful translation
  442. resetErrorState();
  443. return result;
  444. } catch (error) {
  445. // Handle API error and adjust delay
  446. throw handleApiError(error);
  447. }
  448. };
  449. // Retry translation with fallback options
  450. const retryTranslation = async (text, options = {}) => {
  451. try {
  452. return await translateText(text, options);
  453. } catch (error) {
  454. State.debugLog('Translation failed, retrying with fallbacks:', error);
  455. // First fallback: try without streaming if enabled
  456. if (!options.retryWithoutStreaming && Config.getSetting('useStreaming')) {
  457. try {
  458. return await translateText(text, { ...options, retryWithoutStreaming: true });
  459. } catch (streamingError) {
  460. State.debugLog('Retry without streaming failed:', streamingError);
  461. }
  462. }
  463. // Second fallback: try without context if enabled
  464. if (options.useContext && options.context && options.context.length > 0) {
  465. try {
  466. State.debugLog('Retrying without context');
  467. return await translateText(text, {
  468. ...options,
  469. useContext: false,
  470. context: null,
  471. retryWithoutStreaming: true
  472. });
  473. } catch (contextError) {
  474. State.debugLog('Retry without context failed:', contextError);
  475. }
  476. }
  477. // Third fallback: try with a different API if available
  478. const apiConfigs = Config.getSetting('apiConfigs');
  479. const currentApiIndex = Config.getSetting('currentApiIndex');
  480. if (apiConfigs.length > 1) {
  481. // Find an alternative API
  482. const alternativeIndex = (currentApiIndex + 1) % apiConfigs.length;
  483. try {
  484. // Temporarily switch API
  485. Config.updateSetting('currentApiIndex', alternativeIndex);
  486. Config.syncApiSettings();
  487. State.debugLog(`Retrying with alternative API: ${apiConfigs[alternativeIndex].name}`);
  488. // Make the request with new API
  489. const result = await translateText(text, {
  490. ...options,
  491. retryWithoutStreaming: true // Always use non-streaming for fallback
  492. });
  493. // Switch back to the original API
  494. Config.updateSetting('currentApiIndex', currentApiIndex);
  495. Config.syncApiSettings();
  496. return result;
  497. } catch (apiError) {
  498. // Restore original API settings on error
  499. Config.updateSetting('currentApiIndex', currentApiIndex);
  500. Config.syncApiSettings();
  501. State.debugLog('Retry with alternative API failed:', apiError);
  502. }
  503. }
  504. // All retries failed - throw the original error
  505. throw error;
  506. }
  507. };
  508. // Get current API status for monitoring
  509. const getApiStatus = () => {
  510. return {
  511. consecutiveErrors,
  512. currentDelay,
  513. isRateLimited: consecutiveErrors >= 3
  514. };
  515. };
  516. return {
  517. translateText,
  518. retryTranslation,
  519. getApiStatus
  520. };
  521. })();
  522.  
  523. /**
  524. * UI Module - User interface components
  525. */
  526. const UI = (function() {
  527. // UI Components
  528. const components = {
  529. translateButton: {
  530. element: null,
  531. explanationElement: null,
  532. create: (isExplanationMode = false) => {
  533. // Create button if it doesn't exist
  534. if (!components.translateButton.element) {
  535. const button = document.createElement('div');
  536. button.className = 'translate-button';
  537. button.style.cssText = `
  538. position: absolute;
  539. background-color: #4285f4;
  540. color: white;
  541. border-radius: 4px;
  542. padding: 8px 12px;
  543. font-size: 14px;
  544. cursor: pointer;
  545. box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
  546. z-index: 9999;
  547. user-select: none;
  548. display: flex;
  549. align-items: center;
  550. font-family: Arial, sans-serif;
  551. `;
  552. button.innerHTML = `
  553. <span class="translate-icon" style="margin-right: 6px;">🌐</span>
  554. <span class="translate-text">翻译</span>
  555. `;
  556. // Add click event listener
  557. button.addEventListener('click', (e) => {
  558. e.preventDefault();
  559. e.stopPropagation();
  560. const selectedText = State.get('lastSelectedText');
  561. const rect = State.get('lastSelectionRect');
  562. if (selectedText && rect) {
  563. // Set active button
  564. State.set('activeTranslateButton', button);
  565. // Show translation popup
  566. components.translationPopup.show(selectedText, rect, isExplanationMode);
  567. }
  568. });
  569. document.body.appendChild(button);
  570. components.translateButton.element = button;
  571. }
  572. // Update button text based on mode
  573. const textElement = components.translateButton.element.querySelector('.translate-text');
  574. if (textElement) {
  575. textElement.textContent = isExplanationMode ? '解释' : '翻译';
  576. }
  577. return components.translateButton.element;
  578. },
  579. show: (rect) => {
  580. const button = components.translateButton.create();
  581. // Position button near the selection
  582. const scrollX = window.scrollX || window.pageXOffset;
  583. const scrollY = window.scrollY || window.pageYOffset;
  584. // Position at the end of the selection
  585. let left = rect.right + scrollX;
  586. let top = rect.bottom + scrollY;
  587. // Set position
  588. button.style.left = `${left}px`;
  589. button.style.top = `${top}px`;
  590. button.style.display = 'flex';
  591. // Create word explanation button for short English phrases
  592. const text = State.get('lastSelectedText');
  593. if (Utils.isShortEnglishPhrase(text)) {
  594. if (!components.translateButton.explanationElement) {
  595. const explanationBtn = document.createElement('div');
  596. explanationBtn.className = 'translate-button explanation-button';
  597. explanationBtn.style.cssText = `
  598. position: absolute;
  599. background-color: #fbbc05;
  600. color: white;
  601. border-radius: 4px;
  602. padding: 8px 12px;
  603. font-size: 14px;
  604. cursor: pointer;
  605. box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
  606. z-index: 9999;
  607. user-select: none;
  608. display: flex;
  609. align-items: center;
  610. font-family: Arial, sans-serif;
  611. `;
  612. explanationBtn.innerHTML = `
  613. <span class="translate-icon" style="margin-right: 6px;">📚</span>
  614. <span class="translate-text">解释</span>
  615. `;
  616. explanationBtn.addEventListener('click', (e) => {
  617. e.preventDefault();
  618. e.stopPropagation();
  619. // Always get the current selected text when explanation button is clicked
  620. const currentText = State.get('lastSelectedText');
  621. const rect = State.get('lastSelectionRect');
  622. components.translationPopup.show(currentText, rect, true);
  623. });
  624. document.body.appendChild(explanationBtn);
  625. components.translateButton.explanationElement = explanationBtn;
  626. }
  627. // Position the explanation button below the main button
  628. const btnRect = button.getBoundingClientRect();
  629. const explanationBtn = components.translateButton.explanationElement;
  630. explanationBtn.style.left = `${left}px`;
  631. explanationBtn.style.top = `${top + btnRect.height + 5}px`;
  632. explanationBtn.style.display = 'flex';
  633. } else if (components.translateButton.explanationElement) {
  634. components.translateButton.explanationElement.style.display = 'none';
  635. }
  636. },
  637. hide: () => {
  638. if (components.translateButton.element) {
  639. components.translateButton.element.style.display = 'none';
  640. }
  641. if (components.translateButton.explanationElement) {
  642. components.translateButton.explanationElement.style.display = 'none';
  643. }
  644. }
  645. },
  646. translationPopup: {
  647. element: null,
  648. create: () => {
  649. if (!components.translationPopup.element) {
  650. const popup = document.createElement('div');
  651. popup.className = 'translation-popup';
  652. popup.style.cssText = `
  653. position: absolute;
  654. background-color: white;
  655. border-radius: 8px;
  656. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  657. padding: 15px;
  658. z-index: 9998;
  659. max-width: 500px;
  660. min-width: 300px;
  661. max-height: 80vh;
  662. overflow-y: auto;
  663. display: none;
  664. font-family: Arial, sans-serif;
  665. line-height: 1.5;
  666. color: #333;
  667. `;
  668. // Create popup header
  669. const header = document.createElement('div');
  670. header.className = 'popup-header';
  671. header.style.cssText = `
  672. display: flex;
  673. justify-content: space-between;
  674. align-items: center;
  675. margin-bottom: 10px;
  676. padding-bottom: 10px;
  677. border-bottom: 1px solid #eee;
  678. cursor: move;
  679. `;
  680. // Create title
  681. const title = document.createElement('div');
  682. title.className = 'popup-title';
  683. title.style.cssText = 'font-weight: bold; font-size: 16px;';
  684. title.textContent = '翻译结果';
  685. // Create controls
  686. const controls = document.createElement('div');
  687. controls.className = 'popup-controls';
  688. controls.style.cssText = 'display: flex; gap: 8px;';
  689. // Create buttons
  690. const btnStyle = 'background: none; border: none; cursor: pointer; font-size: 16px; padding: 0;';
  691. const favoriteBtn = document.createElement('button');
  692. favoriteBtn.className = 'popup-favorite-btn';
  693. favoriteBtn.innerHTML = '⭐';
  694. favoriteBtn.title = '添加到收藏';
  695. favoriteBtn.style.cssText = btnStyle;
  696. favoriteBtn.addEventListener('click', () => {
  697. const text = components.translationPopup.element.querySelector('.original-text').textContent;
  698. const translation = components.translationPopup.element.querySelector('.translation-text').innerHTML;
  699. Core.favoritesManager.add(text, translation);
  700. favoriteBtn.innerHTML = '✓';
  701. setTimeout(() => { favoriteBtn.innerHTML = '⭐'; }, 1000);
  702. });
  703. const copyBtn = document.createElement('button');
  704. copyBtn.className = 'popup-copy-btn';
  705. copyBtn.innerHTML = '📋';
  706. copyBtn.title = '复制翻译结果';
  707. copyBtn.style.cssText = btnStyle;
  708. copyBtn.addEventListener('click', () => {
  709. const translation = components.translationPopup.element.querySelector('.translation-text').textContent;
  710. navigator.clipboard.writeText(translation);
  711. copyBtn.innerHTML = '✓';
  712. setTimeout(() => { copyBtn.innerHTML = '📋'; }, 1000);
  713. });
  714. const closeBtn = document.createElement('button');
  715. closeBtn.className = 'popup-close-btn';
  716. closeBtn.innerHTML = '✖';
  717. closeBtn.title = '关闭';
  718. closeBtn.style.cssText = btnStyle;
  719. closeBtn.addEventListener('click', () => {
  720. components.translationPopup.hide();
  721. });
  722. // Add buttons to controls
  723. controls.appendChild(favoriteBtn);
  724. controls.appendChild(copyBtn);
  725. controls.appendChild(closeBtn);
  726. // Add title and controls to header
  727. header.appendChild(title);
  728. header.appendChild(controls);
  729. // Create content container
  730. const content = document.createElement('div');
  731. content.className = 'popup-content';
  732. // Create original text area
  733. const originalText = document.createElement('div');
  734. originalText.className = 'original-text';
  735. originalText.style.cssText = `
  736. margin-bottom: 10px;
  737. padding: 10px;
  738. background-color: #f5f5f5;
  739. border-radius: 4px;
  740. font-size: 14px;
  741. white-space: pre-wrap;
  742. word-break: break-word;
  743. display: none;
  744. `;
  745. // Create translation area
  746. const translationText = document.createElement('div');
  747. translationText.className = 'translation-text';
  748. translationText.style.cssText = `
  749. font-size: 16px;
  750. white-space: pre-wrap;
  751. word-break: break-word;
  752. `;
  753. // Create loading animation
  754. const loading = document.createElement('div');
  755. loading.className = 'loading-animation';
  756. loading.style.cssText = 'display: none; text-align: center; padding: 20px 0;';
  757. loading.innerHTML = `
  758. <div style="display: inline-block; width: 30px; height: 30px; border: 3px solid #f3f3f3;
  759. border-top: 3px solid #4285f4; border-radius: 50%; animation: spin 1s linear infinite;"></div>
  760. <style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
  761. `;
  762. // Create error message area
  763. const errorMsg = document.createElement('div');
  764. errorMsg.className = 'error-message';
  765. errorMsg.style.cssText = 'color: #d93025; font-size: 14px; margin-top: 10px; display: none;';
  766. // Add elements to content
  767. content.appendChild(originalText);
  768. content.appendChild(translationText);
  769. content.appendChild(loading);
  770. content.appendChild(errorMsg);
  771. // Add header and content to popup
  772. popup.appendChild(header);
  773. popup.appendChild(content);
  774. // Add popup to document
  775. document.body.appendChild(popup);
  776. components.translationPopup.element = popup;
  777. // Add draggability
  778. components.translationPopup.makeDraggable(popup, header);
  779. }
  780. return components.translationPopup.element;
  781. },
  782. makeDraggable: (element, handle) => {
  783. let isDragging = false;
  784. let startX, startY;
  785. let startLeft, startTop;
  786. // Function to handle the start of dragging
  787. const onMouseDown = (e) => {
  788. // Ignore if clicked on control buttons
  789. if (e.target.closest('.popup-controls')) {
  790. return;
  791. }
  792. e.preventDefault();
  793. // Get initial positions
  794. isDragging = true;
  795. startX = e.clientX;
  796. startY = e.clientY;
  797. // Get current element position
  798. const rect = element.getBoundingClientRect();
  799. startLeft = rect.left;
  800. startTop = rect.top;
  801. // Add move and up listeners
  802. document.addEventListener('mousemove', onMouseMove);
  803. document.addEventListener('mouseup', onMouseUp);
  804. // Change cursor to grabbing
  805. handle.style.cursor = 'grabbing';
  806. };
  807. // Function to handle dragging movement
  808. const onMouseMove = (e) => {
  809. if (!isDragging) return;
  810. e.preventDefault();
  811. // Calculate the new position
  812. const deltaX = e.clientX - startX;
  813. const deltaY = e.clientY - startY;
  814. // Apply the new position (considering scroll)
  815. const scrollX = window.scrollX || window.pageXOffset;
  816. const scrollY = window.scrollY || window.pageYOffset;
  817. const newLeft = startLeft + deltaX;
  818. const newTop = startTop + deltaY;
  819. // Set the new position
  820. element.style.left = `${newLeft + scrollX - startX + startLeft}px`;
  821. element.style.top = `${newTop + scrollY - startY + startTop}px`;
  822. };
  823. // Function to handle the end of dragging
  824. const onMouseUp = () => {
  825. isDragging = false;
  826. document.removeEventListener('mousemove', onMouseMove);
  827. document.removeEventListener('mouseup', onMouseUp);
  828. // Restore cursor
  829. handle.style.cursor = 'move';
  830. };
  831. // Add mouse down listener to handle
  832. handle.addEventListener('mousedown', onMouseDown);
  833. // Return cleanup function
  834. return () => {
  835. handle.removeEventListener('mousedown', onMouseDown);
  836. };
  837. },
  838. show: async (text, rect, isExplanationMode = false) => {
  839. const popup = components.translationPopup.create();
  840. // Set popup title
  841. const title = popup.querySelector('.popup-title');
  842. title.textContent = isExplanationMode ? '词汇解释' : '翻译结果';
  843. // Set original text
  844. const originalTextElem = popup.querySelector('.original-text');
  845. originalTextElem.textContent = text;
  846. // Show original text if enabled
  847. if (Config.getSetting('showSourceLanguage')) {
  848. originalTextElem.style.display = 'block';
  849. } else {
  850. originalTextElem.style.display = 'none';
  851. }
  852. // Clear previous translation
  853. const translationElem = popup.querySelector('.translation-text');
  854. translationElem.innerHTML = '';
  855. // Apply different styles based on mode
  856. if (isExplanationMode) {
  857. translationElem.style.cssText = `
  858. font-size: 14px;
  859. white-space: normal;
  860. word-break: break-word;
  861. line-height: 1.5;
  862. `;
  863. // Add specific styles for explanation mode content
  864. const style = document.createElement('style');
  865. style.textContent = `
  866. .translation-text .word-header {
  867. margin-bottom: 8px;
  868. }
  869. .translation-text .word-header h3 {
  870. margin: 0 0 4px 0;
  871. font-size: 18px;
  872. }
  873. .translation-text .phonetic {
  874. color: #666;
  875. font-style: italic;
  876. margin-bottom: 8px;
  877. }
  878. .translation-text .meanings {
  879. margin-bottom: 8px;
  880. }
  881. .translation-text .meaning {
  882. margin-bottom: 8px;
  883. }
  884. .translation-text .part-of-speech {
  885. font-weight: bold;
  886. color: #333;
  887. }
  888. .translation-text .example {
  889. margin: 4px 0 4px 12px;
  890. color: #555;
  891. font-style: italic;
  892. }
  893. .translation-text .example-translation {
  894. color: #666;
  895. margin-top: 2px;
  896. }
  897. `;
  898. // Only add the style if it doesn't exist yet
  899. if (!document.querySelector('style#explanation-styles')) {
  900. style.id = 'explanation-styles';
  901. document.head.appendChild(style);
  902. }
  903. } else {
  904. translationElem.style.cssText = `
  905. font-size: 16px;
  906. white-space: pre-wrap;
  907. word-break: break-word;
  908. `;
  909. }
  910. // Show loading animation
  911. const loadingElem = popup.querySelector('.loading-animation');
  912. loadingElem.style.display = 'block';
  913. // Hide error message
  914. const errorElem = popup.querySelector('.error-message');
  915. errorElem.style.display = 'none';
  916. // Position popup
  917. const scrollX = window.scrollX || window.pageXOffset;
  918. const scrollY = window.scrollY || window.pageYOffset;
  919. let left = rect.left + scrollX;
  920. let top = rect.bottom + scrollY + 10;
  921. popup.style.left = `${left}px`;
  922. popup.style.top = `${top}px`;
  923. popup.style.display = 'block';
  924. try {
  925. // Simple progress callback
  926. const onProgress = (data) => {
  927. loadingElem.style.display = 'none';
  928. translationElem.innerHTML = data.text;
  929. };
  930. // Call the API to translate
  931. const translation = await API.retryTranslation(text, {
  932. isWordExplanationMode: isExplanationMode,
  933. onProgress: onProgress
  934. });
  935. // Hide loading and show translation
  936. loadingElem.style.display = 'none';
  937. translationElem.innerHTML = translation;
  938. // Adjust the popup height to not exceed screen height
  939. setTimeout(() => {
  940. const viewportHeight = window.innerHeight;
  941. const popupRect = popup.getBoundingClientRect();
  942. if (popupRect.height > viewportHeight * 0.8) {
  943. popup.style.height = `${viewportHeight * 0.8}px`;
  944. translationElem.style.maxHeight = `${viewportHeight * 0.6}px`;
  945. translationElem.style.overflowY = 'auto';
  946. }
  947. }, 100);
  948. // Add to history
  949. Core.historyManager.add(text, translation);
  950. } catch (error) {
  951. // Hide loading animation
  952. loadingElem.style.display = 'none';
  953. // Show error message
  954. errorElem.textContent = `翻译出错: ${error.message}`;
  955. errorElem.style.display = 'block';
  956. State.debugLog('Translation error:', error);
  957. }
  958. },
  959. hide: () => {
  960. if (components.translationPopup.element) {
  961. components.translationPopup.element.style.display = 'none';
  962. }
  963. // Also hide translate button
  964. components.translateButton.hide();
  965. }
  966. },
  967. pageControls: {
  968. element: null,
  969. progressElement: null,
  970. statusElement: null,
  971. stateManager: null,
  972. create: () => {
  973. if (!components.pageControls.element) {
  974. // Create main panel
  975. const panel = document.createElement('div');
  976. panel.className = 'page-translation-controls';
  977. panel.style.cssText = `
  978. position: fixed;
  979. top: 20px;
  980. right: 20px;
  981. background-color: white;
  982. border-radius: 8px;
  983. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  984. padding: 15px;
  985. z-index: 9999;
  986. font-family: Arial, sans-serif;
  987. display: none;
  988. flex-direction: column;
  989. gap: 10px;
  990. min-width: 220px;
  991. `;
  992. // Create header
  993. const header = document.createElement('div');
  994. header.innerHTML = `
  995. <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
  996. <span style="font-weight: bold;">页面翻译</span>
  997. <button style="background:none; border:none; cursor:pointer; font-size: 16px;">✖</button>
  998. </div>
  999. `;
  1000. // Create status element
  1001. const statusElement = document.createElement('div');
  1002. statusElement.className = 'translation-status';
  1003. statusElement.style.cssText = `
  1004. font-size: 13px;
  1005. color: #666;
  1006. margin-bottom: 5px;
  1007. display: none;
  1008. `;
  1009. statusElement.textContent = '准备翻译...';
  1010. // Create progress bar
  1011. const progressBar = document.createElement('div');
  1012. progressBar.style.cssText = 'background:#f0f0f0; height:6px; margin:5px 0; border-radius:3px;';
  1013. const progressIndicator = document.createElement('div');
  1014. progressIndicator.style.cssText = 'background:#4285f4; height:100%; width:0%; transition:width 0.3s;';
  1015. progressBar.appendChild(progressIndicator);
  1016. const progressText = document.createElement('div');
  1017. progressText.innerHTML = '<span>翻译进度</span><span class="progress-percentage">0%</span>';
  1018. progressText.style.cssText = 'display:flex; justify-content:space-between; font-size:12px;';
  1019. // Create buttons
  1020. const buttons = document.createElement('div');
  1021. buttons.style.cssText = 'display:flex; gap:8px; margin-top:10px;';
  1022. const pauseBtn = document.createElement('button');
  1023. pauseBtn.textContent = '暂停';
  1024. pauseBtn.style.cssText = 'flex:1; padding:8px; background:#f5f5f5; border:none; border-radius:4px; cursor:pointer;';
  1025. const stopBtn = document.createElement('button');
  1026. stopBtn.textContent = '停止';
  1027. stopBtn.style.cssText = 'flex:1; padding:8px; background:#ff5252; color:white; border:none; border-radius:4px; cursor:pointer;';
  1028. const restoreBtn = document.createElement('button');
  1029. restoreBtn.textContent = '恢复原文';
  1030. restoreBtn.style.cssText = 'flex:1; padding:8px; background:#f5f5f5; border:none; border-radius:4px; cursor:pointer;';
  1031. buttons.appendChild(pauseBtn);
  1032. buttons.appendChild(stopBtn);
  1033. buttons.appendChild(restoreBtn);
  1034. // Create secondary buttons
  1035. const secondaryButtons = document.createElement('div');
  1036. secondaryButtons.style.cssText = 'display:flex; gap:8px; margin-top:8px;';
  1037. const retranslateBtn = document.createElement('button');
  1038. retranslateBtn.textContent = '重新翻译';
  1039. retranslateBtn.title = '忽略缓存,重新翻译整个页面';
  1040. retranslateBtn.style.cssText = 'flex:1; padding:8px; background:#5cb85c; color:white; border:none; border-radius:4px; cursor:pointer;';
  1041. secondaryButtons.appendChild(retranslateBtn);
  1042. // Create statistics element
  1043. const statsElement = document.createElement('div');
  1044. statsElement.className = 'translation-stats';
  1045. statsElement.style.cssText = `
  1046. font-size: 12px;
  1047. color: #666;
  1048. margin-top: 8px;
  1049. `;
  1050. // Add all elements to panel
  1051. panel.appendChild(header);
  1052. panel.appendChild(statusElement);
  1053. panel.appendChild(progressText);
  1054. panel.appendChild(progressBar);
  1055. panel.appendChild(buttons);
  1056. panel.appendChild(secondaryButtons);
  1057. panel.appendChild(statsElement);
  1058. // Add event listeners
  1059. header.querySelector('button').addEventListener('click', () => {
  1060. Core.restoreOriginalText(true);
  1061. components.pageControls.hide();
  1062. });
  1063. pauseBtn.addEventListener('click', () => {
  1064. const isPaused = State.get('isTranslationPaused');
  1065. State.set('isTranslationPaused', !isPaused);
  1066. });
  1067. stopBtn.addEventListener('click', () => {
  1068. if (State.get('isTranslatingFullPage')) {
  1069. Core.stopTranslation();
  1070. }
  1071. });
  1072. restoreBtn.addEventListener('click', () => {
  1073. const isShowingTranslation = State.get('isShowingTranslation');
  1074. State.set('isShowingTranslation', !isShowingTranslation);
  1075. if (isShowingTranslation) {
  1076. Core.restoreOriginalText(false);
  1077. } else {
  1078. Core.showTranslation();
  1079. }
  1080. });
  1081. retranslateBtn.addEventListener('click', () => {
  1082. if (!State.get('isTranslatingFullPage')) {
  1083. // Show confirmation dialog if we have cached translations
  1084. const segments = State.get('translationSegments');
  1085. const hasCachedTranslations = segments && segments.length > 0 && segments.some(s => s.fromCache);
  1086. if (hasCachedTranslations) {
  1087. if (confirm('确定要忽略缓存重新翻译整个页面吗?这可能需要更长时间。')) {
  1088. Core.translateFullPage({ forceRetranslate: true });
  1089. }
  1090. } else {
  1091. Core.translateFullPage({ forceRetranslate: true });
  1092. }
  1093. }
  1094. });
  1095. // Make panel draggable
  1096. let isDragging = false;
  1097. let dragOffsetX = 0;
  1098. let dragOffsetY = 0;
  1099. const headerElement = header.querySelector('div');
  1100. headerElement.style.cursor = 'move';
  1101. headerElement.addEventListener('mousedown', e => {
  1102. if (e.target.tagName === 'BUTTON') return;
  1103. e.preventDefault();
  1104. isDragging = true;
  1105. const rect = panel.getBoundingClientRect();
  1106. dragOffsetX = e.clientX - rect.left;
  1107. dragOffsetY = e.clientY - rect.top;
  1108. document.addEventListener('mousemove', handleDrag);
  1109. document.addEventListener('mouseup', stopDrag);
  1110. });
  1111. const handleDrag = e => {
  1112. if (!isDragging) return;
  1113. const x = e.clientX - dragOffsetX;
  1114. const y = e.clientY - dragOffsetY;
  1115. panel.style.left = `${x}px`;
  1116. panel.style.top = `${y}px`;
  1117. panel.style.right = 'auto';
  1118. };
  1119. const stopDrag = () => {
  1120. isDragging = false;
  1121. document.removeEventListener('mousemove', handleDrag);
  1122. document.removeEventListener('mouseup', stopDrag);
  1123. };
  1124. // Store references
  1125. components.pageControls.element = panel;
  1126. components.pageControls.progressElement = {
  1127. indicator: progressIndicator,
  1128. percentage: progressText.querySelector('.progress-percentage'),
  1129. pauseButton: pauseBtn,
  1130. stopButton: stopBtn,
  1131. restoreButton: restoreBtn
  1132. };
  1133. components.pageControls.statusElement = statusElement;
  1134. components.pageControls.statsElement = statsElement;
  1135. document.body.appendChild(panel);
  1136. }
  1137. return components.pageControls.element;
  1138. },
  1139. setupStateSubscriptions: () => {
  1140. // Clear any previous subscriptions
  1141. if (components.pageControls.stateManager) {
  1142. components.pageControls.stateManager.cleanup();
  1143. }
  1144. // Create new state manager for this component
  1145. const stateManager = State.registerComponent('pageControls');
  1146. components.pageControls.stateManager = stateManager;
  1147. // Subscribe to translation paused state
  1148. stateManager.subscribe('isTranslationPaused', isPaused => {
  1149. const { pauseButton } = components.pageControls.progressElement;
  1150. const statusElement = components.pageControls.statusElement;
  1151. // Only update if translation is in progress
  1152. if (State.get('isTranslatingFullPage')) {
  1153. if (isPaused) {
  1154. pauseButton.textContent = '继续';
  1155. statusElement.textContent = '翻译已暂停';
  1156. } else {
  1157. pauseButton.textContent = '暂停';
  1158. const index = State.get('lastTranslatedIndex');
  1159. const segments = State.get('translationSegments');
  1160. if (segments && segments.length > 0) {
  1161. statusElement.textContent = `正在翻译 (${index + 1}/${segments.length})`;
  1162. // If paused, resume translation
  1163. if (index >= 0 && index < segments.length - 1) {
  1164. Core.translateNextSegment(index + 1);
  1165. }
  1166. }
  1167. }
  1168. } else {
  1169. // Translation is not in progress, ensure button is in correct state
  1170. pauseButton.disabled = true;
  1171. pauseButton.textContent = '暂停';
  1172. }
  1173. });
  1174. // Subscribe to translation progress
  1175. stateManager.subscribe('lastTranslatedIndex', index => {
  1176. const segments = State.get('translationSegments');
  1177. if (!segments || segments.length === 0) return;
  1178. // 精确计算已翻译的段落,确保包括所有已处理的段落
  1179. const translatedCount = segments.filter(s => s.translation || s.error).length;
  1180. // 如果翻译已经完成,强制显示100%
  1181. let progress, percent;
  1182. if (!State.get('isTranslatingFullPage') && !State.get('isTranslationPaused')) {
  1183. // 翻译已完成状态,显示100%
  1184. progress = 1;
  1185. percent = 100;
  1186. } else {
  1187. // 正常计算进度
  1188. progress = translatedCount / segments.length;
  1189. percent = Math.round(progress * 100);
  1190. }
  1191. // Update progress bar
  1192. const { indicator, percentage } = components.pageControls.progressElement;
  1193. indicator.style.width = `${percent}%`;
  1194. percentage.textContent = `${percent}% (${translatedCount}/${segments.length})`;
  1195. // Update status text based on translated count
  1196. if (!State.get('isTranslationPaused')) {
  1197. if (!State.get('isTranslatingFullPage')) {
  1198. components.pageControls.statusElement.textContent = `翻译完成`;
  1199. } else {
  1200. components.pageControls.statusElement.textContent = `正在翻译 (${translatedCount}/${segments.length})`;
  1201. }
  1202. }
  1203. // Update stats
  1204. components.pageControls.updateStats(segments);
  1205. });
  1206. // Subscribe to translation state changes
  1207. stateManager.subscribe('isTranslatingFullPage', isTranslating => {
  1208. const { pauseButton, stopButton, restoreButton } = components.pageControls.progressElement;
  1209. const statusElement = components.pageControls.statusElement;
  1210. const controlsPanel = components.pageControls.element;
  1211. if (!controlsPanel) return; // Safety check
  1212. const retranslateBtn = controlsPanel.querySelector('button[title="忽略缓存,重新翻译整个页面"]');
  1213. // Update button states
  1214. pauseButton.disabled = !isTranslating;
  1215. stopButton.disabled = !isTranslating;
  1216. if (retranslateBtn) {
  1217. retranslateBtn.disabled = isTranslating;
  1218. retranslateBtn.style.opacity = isTranslating ? '0.5' : '1';
  1219. }
  1220. // If stopping/completing translation
  1221. if (!isTranslating) {
  1222. pauseButton.disabled = true;
  1223. stopButton.disabled = true;
  1224. restoreButton.disabled = false;
  1225. // Reset pause state when translation completes
  1226. if (State.get('isTranslationPaused')) {
  1227. State.set('isTranslationPaused', false);
  1228. }
  1229. if (State.get('isStopped')) {
  1230. statusElement.textContent = '翻译已停止';
  1231. } else {
  1232. statusElement.textContent = '翻译完成';
  1233. statusElement.style.color = '#4CAF50';
  1234. }
  1235. // Final stats update
  1236. const segments = State.get('translationSegments');
  1237. if (segments && segments.length > 0) {
  1238. components.pageControls.updateStats(segments);
  1239. }
  1240. }
  1241. });
  1242. // Subscribe to showing translation state
  1243. stateManager.subscribe('isShowingTranslation', isShowing => {
  1244. const { restoreButton } = components.pageControls.progressElement;
  1245. restoreButton.textContent = isShowing ? '恢复原文' : '显示译文';
  1246. });
  1247. // Subscribe to stopped state
  1248. stateManager.subscribe('isStopped', isStopped => {
  1249. if (isStopped) {
  1250. components.pageControls.statusElement.textContent = '翻译已停止';
  1251. components.pageControls.statusElement.style.color = '';
  1252. }
  1253. });
  1254. // Subscribe to API delay changes
  1255. stateManager.subscribe('apiDelay', delay => {
  1256. if (delay > 0) {
  1257. const delaySeconds = (delay / 1000).toFixed(1);
  1258. components.pageControls.statusElement.textContent =
  1259. `延迟增加至${delaySeconds}秒(API限流保护)`;
  1260. }
  1261. });
  1262. },
  1263. show: () => {
  1264. const panel = components.pageControls.create();
  1265. panel.style.display = 'flex';
  1266. // Reset progress UI elements
  1267. const statusElement = components.pageControls.statusElement;
  1268. statusElement.style.display = 'block';
  1269. statusElement.textContent = '准备翻译...';
  1270. statusElement.style.color = '';
  1271. // Reset progress bar
  1272. const { indicator, percentage, pauseButton, stopButton } = components.pageControls.progressElement;
  1273. indicator.style.width = '0%';
  1274. percentage.textContent = '0% (0/0)';
  1275. // Reset pause and stop button states
  1276. pauseButton.textContent = '暂停';
  1277. pauseButton.disabled = false;
  1278. stopButton.disabled = false;
  1279. // Clear stats
  1280. if (components.pageControls.statsElement) {
  1281. components.pageControls.statsElement.textContent = '';
  1282. }
  1283. // Set up state subscriptions
  1284. components.pageControls.setupStateSubscriptions();
  1285. // Reset translation states in the UI
  1286. State.set('isShowingTranslation', true);
  1287. },
  1288. hide: () => {
  1289. if (components.pageControls.element) {
  1290. components.pageControls.element.style.display = 'none';
  1291. // Clean up subscriptions to prevent memory leaks
  1292. if (components.pageControls.stateManager) {
  1293. components.pageControls.stateManager.cleanup();
  1294. }
  1295. }
  1296. },
  1297. updateStats: segments => {
  1298. if (!components.pageControls.statsElement) return;
  1299. // Count successes, errors, and pending
  1300. let success = 0;
  1301. let error = 0;
  1302. let pending = 0;
  1303. let cached = 0;
  1304. segments.forEach(segment => {
  1305. if (segment.translation && !segment.error) {
  1306. if (segment.fromCache) {
  1307. cached++;
  1308. } else {
  1309. success++;
  1310. }
  1311. } else if (segment.error) {
  1312. error++;
  1313. } else {
  1314. // 段落无翻译也无错误时,视为等待中
  1315. if (State.get('isTranslatingFullPage') && !State.get('isStopped')) {
  1316. pending++;
  1317. }
  1318. }
  1319. });
  1320. // 确保显示总数的准确性
  1321. const total = success + cached + error + pending;
  1322. // Only show non-zero values
  1323. let stats = [];
  1324. if (success) stats.push(`${success} 翻译成功`);
  1325. if (cached) stats.push(`${cached} 来自缓存`);
  1326. if (error) stats.push(`${error} 失败`);
  1327. if (pending) stats.push(`${pending} 等待中`);
  1328. // 添加完成比例
  1329. if (segments.length > 0) {
  1330. const completedPercent = Math.round((success + cached + error) / segments.length * 100);
  1331. stats.push(`总完成率 ${completedPercent}%`);
  1332. }
  1333. // If translation is complete/stopped but no stats, show a default message
  1334. if (stats.length === 0 && !State.get('isTranslatingFullPage')) {
  1335. stats.push('翻译已完成');
  1336. }
  1337. components.pageControls.statsElement.textContent = stats.join(' · ');
  1338. }
  1339. },
  1340. settingsPanel: {
  1341. element: null,
  1342. apiForm: null,
  1343. create: () => {
  1344. if (!components.settingsPanel.element) {
  1345. // Create panel
  1346. const panel = document.createElement('div');
  1347. panel.className = 'translator-settings-panel';
  1348. panel.style.cssText = `
  1349. position: fixed;
  1350. top: 50%;
  1351. left: 50%;
  1352. transform: translate(-50%, -50%);
  1353. width: 500px;
  1354. max-width: 90%;
  1355. background: white;
  1356. box-shadow: 0 0 20px rgba(0,0,0,0.3);
  1357. border-radius: 8px;
  1358. z-index: 10000;
  1359. font-family: Arial, sans-serif;
  1360. display: none;
  1361. flex-direction: column;
  1362. max-height: 90vh;
  1363. overflow: hidden;
  1364. `;
  1365. // Create tabs
  1366. const tabsContainer = document.createElement('div');
  1367. tabsContainer.style.cssText = 'display: flex; border-bottom: 1px solid #eee;';
  1368. const generalTab = document.createElement('button');
  1369. generalTab.textContent = '翻译设置';
  1370. generalTab.dataset.tab = 'general';
  1371. generalTab.style.cssText = 'flex: 1; padding: 12px; border: none; background: none; cursor: pointer; border-bottom: 2px solid #4285f4; color: #4285f4;';
  1372. const apiTab = document.createElement('button');
  1373. apiTab.textContent = 'API 管理';
  1374. apiTab.dataset.tab = 'api';
  1375. apiTab.style.cssText = 'flex: 1; padding: 12px; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent;';
  1376. tabsContainer.appendChild(generalTab);
  1377. tabsContainer.appendChild(apiTab);
  1378. panel.appendChild(tabsContainer);
  1379. // Create content container
  1380. const contentContainer = document.createElement('div');
  1381. contentContainer.style.cssText = 'flex: 1; overflow-y: auto;';
  1382. // Create general settings content
  1383. const generalContent = document.createElement('div');
  1384. generalContent.dataset.tabContent = 'general';
  1385. generalContent.style.cssText = 'display: block; padding: 20px;';
  1386. generalContent.innerHTML = `
  1387. <h3 style="margin-top: 0; margin-bottom: 15px;">通用设置</h3>
  1388. <div style="margin-bottom: 15px;">
  1389. <label style="display: block; margin-bottom: 5px; font-weight: bold;">系统提示词:</label>
  1390. <textarea id="setting-systemPrompt" style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit;"></textarea>
  1391. <div style="font-size: 12px; color: #666; margin-top: 5px;">用于指导翻译模型如何翻译文本</div>
  1392. </div>
  1393. <div style="margin-bottom: 15px;">
  1394. <label style="display: block; margin-bottom: 5px; font-weight: bold;">单词解释提示词:</label>
  1395. <textarea id="setting-wordExplanationPrompt" style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit;"></textarea>
  1396. <div style="font-size: 12px; color: #666; margin-top: 5px;">用于指导如何解释单词或短语</div>
  1397. </div>
  1398. <div style="margin-bottom: 15px;">
  1399. <label style="display: flex; align-items: center;">
  1400. <input type="checkbox" id="setting-showSourceLanguage">
  1401. <span style="margin-left: 8px;">显示原文</span>
  1402. </label>
  1403. <div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">启用后将在翻译结果上方显示原文</div>
  1404. </div>
  1405. <div style="margin-bottom: 15px;">
  1406. <label style="display: flex; align-items: center;">
  1407. <input type="checkbox" id="setting-useStreaming">
  1408. <span style="margin-left: 8px;">启用流式响应(实时显示翻译)</span>
  1409. </label>
  1410. <div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">如果遇到翻译失败问题,可以尝试关闭此选项</div>
  1411. </div>
  1412. <div style="margin-bottom: 15px;">
  1413. <label style="display: flex; align-items: center;">
  1414. <input type="checkbox" id="setting-useTranslationContext">
  1415. <span style="margin-left: 8px;">启用翻译上下文</span>
  1416. </label>
  1417. <div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">启用后将使用之前翻译过的内容作为上下文,提高翻译连贯性</div>
  1418. </div>
  1419. <div style="margin-bottom: 15px;">
  1420. <label style="display: block; margin-bottom: 5px; font-weight: bold;">上下文数量:</label>
  1421. <input type="number" id="setting-contextSize" min="1" max="10" style="width: 60px; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;">
  1422. <div style="font-size: 12px; color: #666; margin-top: 5px;">使用前面已翻译段落作为上下文提升翻译连贯性,建议设置1-5之间</div>
  1423. </div>
  1424. <div style="margin-bottom: 15px;">
  1425. <label style="display: block; margin-bottom: 5px; font-weight: bold;">随机性(Temperature):</label>
  1426. <div style="display: flex; align-items: center;">
  1427. <input type="range" id="setting-temperature" min="0" max="1" step="0.1" style="flex: 1;">
  1428. <span id="temperature-value" style="margin-left: 10px; min-width: 30px; text-align: right;"></span>
  1429. </div>
  1430. <div style="font-size: 12px; color: #666; margin-top: 5px;">值越低翻译越准确,值越高结果越有创意</div>
  1431. </div>
  1432. <h3 style="margin-top: 25px; margin-bottom: 15px;">整页翻译设置</h3>
  1433. <div style="margin-bottom: 15px;">
  1434. <label style="display: flex; align-items: center;">
  1435. <input type="checkbox" id="setting-detectArticleContent">
  1436. <span style="margin-left: 8px;">智能识别文章主体内容</span>
  1437. </label>
  1438. <div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 24px;">启用后将自动识别文章主要内容区域,避免翻译无关内容</div>
  1439. </div>
  1440. <div style="margin-bottom: 15px;">
  1441. <label style="display: block; margin-bottom: 5px; font-weight: bold;">整页翻译选择器:</label>
  1442. <input type="text" id="setting-fullPageTranslationSelector" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
  1443. <div style="font-size: 12px; color: #666; margin-top: 5px;">CSS选择器,用于指定翻译哪些区域的内容</div>
  1444. </div>
  1445. <div style="margin-bottom: 15px;">
  1446. <label style="display: block; margin-bottom: 5px; font-weight: bold;">排除翻译的元素:</label>
  1447. <input type="text" id="setting-excludeSelectors" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
  1448. <div style="font-size: 12px; color: #666; margin-top: 5px;">CSS选择器,指定要排除翻译的元素</div>
  1449. </div>
  1450. `;
  1451. // Create API settings content
  1452. const apiContent = document.createElement('div');
  1453. apiContent.dataset.tabContent = 'api';
  1454. apiContent.style.cssText = 'display: none; padding: 20px;';
  1455. apiContent.innerHTML = '<h3 style="margin-top: 0;">API 设置</h3>';
  1456. // Create API list container
  1457. const apiListContainer = document.createElement('div');
  1458. apiListContainer.id = 'api-list-container';
  1459. // Create "Add API" button
  1460. const addApiButton = document.createElement('button');
  1461. addApiButton.textContent = '+ 添加新API';
  1462. addApiButton.style.cssText = 'width: 100%; padding: 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 15px;';
  1463. addApiButton.addEventListener('click', () => {
  1464. components.settingsPanel.showApiForm();
  1465. });
  1466. apiContent.appendChild(addApiButton);
  1467. apiContent.appendChild(apiListContainer);
  1468. // Add content to container
  1469. contentContainer.appendChild(generalContent);
  1470. contentContainer.appendChild(apiContent);
  1471. panel.appendChild(contentContainer);
  1472. // Create footer with buttons
  1473. const footer = document.createElement('div');
  1474. footer.style.cssText = 'padding: 15px 20px; border-top: 1px solid #eee; text-align: right;';
  1475. const cancelButton = document.createElement('button');
  1476. cancelButton.textContent = '取消';
  1477. cancelButton.style.cssText = 'margin-right: 10px; padding: 8px 16px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;';
  1478. cancelButton.addEventListener('click', () => {
  1479. components.settingsPanel.hide();
  1480. });
  1481. const saveButton = document.createElement('button');
  1482. saveButton.textContent = '保存';
  1483. saveButton.style.cssText = 'padding: 8px 16px; background: #4285f4; color: white; border: none; border-radius: 4px; cursor: pointer;';
  1484. saveButton.addEventListener('click', () => {
  1485. // Get form values from general tab
  1486. const newSettings = {
  1487. systemPrompt: generalContent.querySelector('#setting-systemPrompt').value,
  1488. wordExplanationPrompt: generalContent.querySelector('#setting-wordExplanationPrompt').value,
  1489. showSourceLanguage: generalContent.querySelector('#setting-showSourceLanguage').checked,
  1490. useStreaming: generalContent.querySelector('#setting-useStreaming').checked,
  1491. useTranslationContext: generalContent.querySelector('#setting-useTranslationContext').checked,
  1492. contextSize: parseInt(generalContent.querySelector('#setting-contextSize').value) || 3,
  1493. temperature: parseFloat(generalContent.querySelector('#setting-temperature').value),
  1494. detectArticleContent: generalContent.querySelector('#setting-detectArticleContent').checked,
  1495. fullPageTranslationSelector: generalContent.querySelector('#setting-fullPageTranslationSelector').value,
  1496. excludeSelectors: generalContent.querySelector('#setting-excludeSelectors').value
  1497. };
  1498. // Update settings
  1499. Config.updateSettings(newSettings);
  1500. // Sync API settings if needed
  1501. Config.syncApiSettings();
  1502. // Hide panel
  1503. components.settingsPanel.hide();
  1504. });
  1505. footer.appendChild(cancelButton);
  1506. footer.appendChild(saveButton);
  1507. panel.appendChild(footer);
  1508. // Add tab switching event listeners
  1509. [generalTab, apiTab].forEach(tab => {
  1510. tab.addEventListener('click', () => {
  1511. const tabName = tab.dataset.tab;
  1512. // Update tab styling
  1513. [generalTab, apiTab].forEach(t => {
  1514. if (t.dataset.tab === tabName) {
  1515. t.style.borderBottom = '2px solid #4285f4';
  1516. t.style.color = '#4285f4';
  1517. } else {
  1518. t.style.borderBottom = '2px solid transparent';
  1519. t.style.color = 'inherit';
  1520. }
  1521. });
  1522. // Show/hide content
  1523. contentContainer.querySelectorAll('[data-tab-content]').forEach(content => {
  1524. if (content.dataset.tabContent === tabName) {
  1525. content.style.display = 'block';
  1526. } else {
  1527. content.style.display = 'none';
  1528. }
  1529. });
  1530. // Update API list if showing API tab
  1531. if (tabName === 'api') {
  1532. components.settingsPanel.updateApiList();
  1533. }
  1534. });
  1535. });
  1536. // Temperature slider
  1537. const temperatureSlider = generalContent.querySelector('#setting-temperature');
  1538. const temperatureValue = generalContent.querySelector('#temperature-value');
  1539. temperatureSlider.addEventListener('input', () => {
  1540. temperatureValue.textContent = temperatureSlider.value;
  1541. });
  1542. // Store reference
  1543. components.settingsPanel.element = panel;
  1544. document.body.appendChild(panel);
  1545. }
  1546. return components.settingsPanel.element;
  1547. },
  1548. createApiForm: () => {
  1549. if (!components.settingsPanel.apiForm) {
  1550. const form = document.createElement('div');
  1551. form.className = 'api-form';
  1552. form.style.cssText = `
  1553. position: absolute;
  1554. top: 0;
  1555. left: 0;
  1556. width: 100%;
  1557. height: 100%;
  1558. background: white;
  1559. z-index: 1;
  1560. display: none;
  1561. flex-direction: column;
  1562. `;
  1563. // Form header
  1564. const header = document.createElement('div');
  1565. header.style.cssText = 'padding: 15px 20px; border-bottom: 1px solid #eee;';
  1566. const title = document.createElement('h3');
  1567. title.id = 'api-form-title';
  1568. title.textContent = '添加API';
  1569. title.style.margin = '0';
  1570. header.appendChild(title);
  1571. form.appendChild(header);
  1572. // Form content
  1573. const content = document.createElement('div');
  1574. content.style.cssText = 'flex: 1; overflow-y: auto; padding: 20px;';
  1575. // Hidden index field for editing
  1576. const indexField = document.createElement('input');
  1577. indexField.type = 'hidden';
  1578. indexField.id = 'api-form-index';
  1579. indexField.value = '-1';
  1580. // Form fields
  1581. content.innerHTML = `
  1582. <div style="margin-bottom: 15px;">
  1583. <label style="display: block; margin-bottom: 5px; font-weight: bold;">API 名称:</label>
  1584. <input type="text" id="api-name" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="例如:OpenAI、Azure、DeepSeek">
  1585. </div>
  1586. <div style="margin-bottom: 15px;">
  1587. <label style="display: block; margin-bottom: 5px; font-weight: bold;">API 端点:</label>
  1588. <input type="text" id="api-endpoint" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="例如:https://api.openai.com/v1/chat/completions">
  1589. </div>
  1590. <div style="margin-bottom: 15px;">
  1591. <label style="display: block; margin-bottom: 5px; font-weight: bold;">API 密钥:</label>
  1592. <input type="password" id="api-key" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="输入您的API密钥">
  1593. <div style="font-size: 12px; color: #666; margin-top: 5px;">编辑现有API时,如不需要修改密钥请留空</div>
  1594. </div>
  1595. <div style="margin-bottom: 15px;">
  1596. <label style="display: block; margin-bottom: 5px; font-weight: bold;">模型名称:</label>
  1597. <input type="text" id="api-model" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="例如:gpt-3.5-turbo">
  1598. </div>
  1599. `;
  1600. content.insertBefore(indexField, content.firstChild);
  1601. form.appendChild(content);
  1602. // Form footer
  1603. const footer = document.createElement('div');
  1604. footer.style.cssText = 'padding: 15px 20px; border-top: 1px solid #eee; text-align: right;';
  1605. const cancelButton = document.createElement('button');
  1606. cancelButton.textContent = '取消';
  1607. cancelButton.style.cssText = 'margin-right: 10px; padding: 8px 16px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;';
  1608. cancelButton.addEventListener('click', () => {
  1609. components.settingsPanel.hideApiForm();
  1610. });
  1611. const saveButton = document.createElement('button');
  1612. saveButton.textContent = '保存';
  1613. saveButton.style.cssText = 'padding: 8px 16px; background: #4285f4; color: white; border: none; border-radius: 4px; cursor: pointer;';
  1614. saveButton.addEventListener('click', () => {
  1615. // Get form values
  1616. const index = parseInt(indexField.value);
  1617. const name = content.querySelector('#api-name').value.trim();
  1618. const endpoint = content.querySelector('#api-endpoint').value.trim();
  1619. const key = content.querySelector('#api-key').value.trim();
  1620. const model = content.querySelector('#api-model').value.trim();
  1621. // Validate inputs
  1622. if (!name || !endpoint || !model) {
  1623. alert('请填写所有必填字段');
  1624. return;
  1625. }
  1626. // Get current API configs
  1627. const apiConfigs = Config.getSetting('apiConfigs');
  1628. // Create new API config
  1629. const apiConfig = {
  1630. name,
  1631. apiEndpoint: endpoint,
  1632. model
  1633. };
  1634. // Only update API key if provided
  1635. if (key) {
  1636. apiConfig.apiKey = key;
  1637. } else if (index !== -1) {
  1638. // Keep existing key when editing
  1639. apiConfig.apiKey = apiConfigs[index].apiKey;
  1640. } else {
  1641. // New API must have a key
  1642. alert('请提供API密钥');
  1643. return;
  1644. }
  1645. // Add or update API config
  1646. if (index === -1) {
  1647. // Add new API
  1648. apiConfigs.push(apiConfig);
  1649. } else {
  1650. // Update existing API
  1651. apiConfigs[index] = apiConfig;
  1652. }
  1653. // Update settings
  1654. Config.updateSetting('apiConfigs', apiConfigs);
  1655. // Hide form
  1656. components.settingsPanel.hideApiForm();
  1657. // Update API list
  1658. components.settingsPanel.updateApiList();
  1659. });
  1660. footer.appendChild(cancelButton);
  1661. footer.appendChild(saveButton);
  1662. form.appendChild(footer);
  1663. // Store reference
  1664. components.settingsPanel.apiForm = form;
  1665. components.settingsPanel.element.appendChild(form);
  1666. }
  1667. return components.settingsPanel.apiForm;
  1668. },
  1669. show: () => {
  1670. const panel = components.settingsPanel.create();
  1671. // Get current settings
  1672. const settings = Config.getSettings();
  1673. // Update general settings form
  1674. const generalContent = panel.querySelector('[data-tab-content="general"]');
  1675. generalContent.querySelector('#setting-systemPrompt').value = settings.systemPrompt;
  1676. generalContent.querySelector('#setting-wordExplanationPrompt').value = settings.wordExplanationPrompt;
  1677. generalContent.querySelector('#setting-showSourceLanguage').checked = settings.showSourceLanguage;
  1678. generalContent.querySelector('#setting-useStreaming').checked = settings.useStreaming;
  1679. generalContent.querySelector('#setting-useTranslationContext').checked = settings.useTranslationContext;
  1680. generalContent.querySelector('#setting-contextSize').value = settings.contextSize;
  1681. generalContent.querySelector('#setting-temperature').value = settings.temperature;
  1682. generalContent.querySelector('#temperature-value').textContent = settings.temperature;
  1683. generalContent.querySelector('#setting-detectArticleContent').checked = settings.detectArticleContent;
  1684. generalContent.querySelector('#setting-fullPageTranslationSelector').value = settings.fullPageTranslationSelector;
  1685. generalContent.querySelector('#setting-excludeSelectors').value = settings.excludeSelectors;
  1686. // Update API list
  1687. components.settingsPanel.updateApiList();
  1688. // Show panel
  1689. panel.style.display = 'flex';
  1690. },
  1691. hide: () => {
  1692. if (components.settingsPanel.element) {
  1693. components.settingsPanel.element.style.display = 'none';
  1694. }
  1695. // Also hide API form if open
  1696. components.settingsPanel.hideApiForm();
  1697. },
  1698. showApiForm: (editIndex = -1) => {
  1699. // Create API form if it doesn't exist
  1700. const form = components.settingsPanel.createApiForm();
  1701. // Set form title
  1702. const title = form.querySelector('#api-form-title');
  1703. title.textContent = editIndex === -1 ? '添加API' : '编辑API';
  1704. // Set hidden index field
  1705. const indexField = form.querySelector('#api-form-index');
  1706. indexField.value = editIndex;
  1707. // Clear form fields
  1708. form.querySelector('#api-name').value = '';
  1709. form.querySelector('#api-endpoint').value = '';
  1710. form.querySelector('#api-key').value = '';
  1711. form.querySelector('#api-model').value = '';
  1712. // Fill form fields if editing
  1713. if (editIndex !== -1) {
  1714. const apiConfigs = Config.getSetting('apiConfigs');
  1715. const api = apiConfigs[editIndex];
  1716. form.querySelector('#api-name').value = api.name;
  1717. form.querySelector('#api-endpoint').value = api.apiEndpoint;
  1718. form.querySelector('#api-model').value = api.model;
  1719. }
  1720. // Show form
  1721. form.style.display = 'flex';
  1722. },
  1723. hideApiForm: () => {
  1724. if (components.settingsPanel.apiForm) {
  1725. components.settingsPanel.apiForm.style.display = 'none';
  1726. }
  1727. },
  1728. updateApiList: () => {
  1729. const panel = components.settingsPanel.element;
  1730. if (!panel) return;
  1731. const apiListContainer = panel.querySelector('#api-list-container');
  1732. if (!apiListContainer) return;
  1733. // Clear existing content
  1734. apiListContainer.innerHTML = '';
  1735. // Get API configs
  1736. const apiConfigs = Config.getSetting('apiConfigs');
  1737. const currentApiIndex = Config.getSetting('currentApiIndex');
  1738. // No APIs
  1739. if (apiConfigs.length === 0) {
  1740. apiListContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无API配置</div>';
  1741. return;
  1742. }
  1743. // Create API items
  1744. apiConfigs.forEach((api, index) => {
  1745. const isActive = index === currentApiIndex;
  1746. const item = document.createElement('div');
  1747. item.className = 'api-item';
  1748. item.style.cssText = `
  1749. margin-bottom: 15px;
  1750. padding: 15px;
  1751. border: 1px solid ${isActive ? '#4285f4' : '#ddd'};
  1752. border-radius: 4px;
  1753. position: relative;
  1754. background-color: ${isActive ? '#f0f8ff' : 'white'};
  1755. `;
  1756. // API info
  1757. item.innerHTML = `
  1758. <div style="margin-bottom: 8px;"><strong>名称:</strong> <span>${api.name}</span></div>
  1759. <div style="margin-bottom: 8px;"><strong>端点:</strong> <span>${api.apiEndpoint}</span></div>
  1760. <div style="margin-bottom: 8px;"><strong>密钥:</strong> <span>${api.apiKey ? '******' + api.apiKey.substring(api.apiKey.length - 4) : '未设置'}</span></div>
  1761. <div><strong>模型:</strong> <span>${api.model}</span></div>
  1762. `;
  1763. // Buttons container
  1764. const buttons = document.createElement('div');
  1765. buttons.style.cssText = 'position: absolute; top: 15px; right: 15px;';
  1766. // Add button for setting as active (if not already active)
  1767. if (!isActive) {
  1768. const useButton = document.createElement('button');
  1769. useButton.textContent = '使用';
  1770. useButton.style.cssText = 'margin-right: 8px; padding: 4px 8px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer;';
  1771. useButton.addEventListener('click', () => {
  1772. Config.updateSetting('currentApiIndex', index);
  1773. Config.syncApiSettings();
  1774. components.settingsPanel.updateApiList();
  1775. });
  1776. buttons.appendChild(useButton);
  1777. } else {
  1778. const activeLabel = document.createElement('span');
  1779. activeLabel.textContent = '✓ 当前使用';
  1780. activeLabel.style.cssText = 'color: #4CAF50; font-weight: 500; margin-right: 8px;';
  1781. buttons.appendChild(activeLabel);
  1782. }
  1783. // Edit button
  1784. const editButton = document.createElement('button');
  1785. editButton.textContent = '编辑';
  1786. editButton.style.cssText = 'margin-right: 8px; padding: 4px 8px; background-color: #2196F3; color: white; border: none; border-radius: 3px; cursor: pointer;';
  1787. editButton.addEventListener('click', () => {
  1788. components.settingsPanel.showApiForm(index);
  1789. });
  1790. buttons.appendChild(editButton);
  1791. // Delete button (only if there are multiple APIs)
  1792. if (apiConfigs.length > 1) {
  1793. const deleteButton = document.createElement('button');
  1794. deleteButton.textContent = '删除';
  1795. deleteButton.style.cssText = 'padding: 4px 8px; background-color: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer;';
  1796. deleteButton.addEventListener('click', () => {
  1797. if (confirm('确定要删除此API配置吗?')) {
  1798. // Remove API config
  1799. apiConfigs.splice(index, 1);
  1800. // Update current index if needed
  1801. if (currentApiIndex >= apiConfigs.length) {
  1802. Config.updateSetting('currentApiIndex', apiConfigs.length - 1);
  1803. } else if (index === currentApiIndex) {
  1804. Config.updateSetting('currentApiIndex', 0);
  1805. }
  1806. // Update settings
  1807. Config.updateSetting('apiConfigs', apiConfigs);
  1808. Config.syncApiSettings();
  1809. // Update API list
  1810. components.settingsPanel.updateApiList();
  1811. }
  1812. });
  1813. buttons.appendChild(deleteButton);
  1814. }
  1815. item.appendChild(buttons);
  1816. apiListContainer.appendChild(item);
  1817. });
  1818. }
  1819. },
  1820. historyPanel: {
  1821. element: null,
  1822. visible: false,
  1823. create: () => {
  1824. if (!components.historyPanel.element) {
  1825. // Create panel
  1826. const panel = document.createElement('div');
  1827. panel.className = 'translator-history-panel';
  1828. panel.style.cssText = `
  1829. position: fixed;
  1830. top: 50%;
  1831. left: 50%;
  1832. transform: translate(-50%, -50%);
  1833. width: 500px;
  1834. max-width: 90%;
  1835. max-height: 90vh;
  1836. background: white;
  1837. border-radius: 8px;
  1838. box-shadow: 0 0 20px rgba(0,0,0,0.3);
  1839. z-index: 10000;
  1840. display: none;
  1841. flex-direction: column;
  1842. font-family: Arial, sans-serif;
  1843. overflow: hidden;
  1844. `;
  1845. // Header
  1846. const header = document.createElement('div');
  1847. header.style.cssText = `
  1848. padding: 15px;
  1849. border-bottom: 1px solid #eee;
  1850. display: flex;
  1851. justify-content: space-between;
  1852. align-items: center;
  1853. `;
  1854. const title = document.createElement('h3');
  1855. title.textContent = '翻译历史';
  1856. title.style.margin = '0';
  1857. const closeBtn = document.createElement('button');
  1858. closeBtn.innerHTML = '✖';
  1859. closeBtn.style.cssText = 'background: none; border: none; font-size: 16px; cursor: pointer;';
  1860. closeBtn.addEventListener('click', () => components.historyPanel.hide());
  1861. header.appendChild(title);
  1862. header.appendChild(closeBtn);
  1863. panel.appendChild(header);
  1864. // Content
  1865. const content = document.createElement('div');
  1866. content.className = 'history-items';
  1867. content.style.cssText = 'flex: 1; overflow-y: auto; padding: 0 15px; max-height: 70vh;';
  1868. panel.appendChild(content);
  1869. // Footer
  1870. const footer = document.createElement('div');
  1871. footer.style.cssText = 'padding: 10px 15px; border-top: 1px solid #eee; text-align: right;';
  1872. const clearBtn = document.createElement('button');
  1873. clearBtn.textContent = '清空历史';
  1874. clearBtn.style.cssText = 'padding: 8px 16px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;';
  1875. clearBtn.addEventListener('click', () => {
  1876. if (confirm('确定要清空所有历史记录吗?')) {
  1877. Core.historyManager.clear();
  1878. components.historyPanel.update();
  1879. }
  1880. });
  1881. footer.appendChild(clearBtn);
  1882. panel.appendChild(footer);
  1883. // Add to document
  1884. document.body.appendChild(panel);
  1885. components.historyPanel.element = panel;
  1886. }
  1887. return components.historyPanel.element;
  1888. },
  1889. show: () => {
  1890. const panel = components.historyPanel.create();
  1891. components.historyPanel.update();
  1892. panel.style.display = 'flex';
  1893. components.historyPanel.visible = true;
  1894. },
  1895. hide: () => {
  1896. if (components.historyPanel.element) {
  1897. components.historyPanel.element.style.display = 'none';
  1898. components.historyPanel.visible = false;
  1899. }
  1900. },
  1901. isVisible: () => components.historyPanel.visible,
  1902. update: () => {
  1903. const panel = components.historyPanel.element;
  1904. if (!panel) return;
  1905. const content = panel.querySelector('.history-items');
  1906. if (!content) return;
  1907. // Clear content
  1908. content.innerHTML = '';
  1909. // Get history
  1910. const history = State.get('translationHistory');
  1911. if (history.length === 0) {
  1912. content.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无历史记录</div>';
  1913. return;
  1914. }
  1915. // Create items in reverse order (newest first)
  1916. for (let i = history.length - 1; i >= 0; i--) {
  1917. const item = history[i];
  1918. const date = new Date(item.timestamp);
  1919. const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
  1920. const historyItem = document.createElement('div');
  1921. historyItem.className = 'history-item';
  1922. historyItem.style.cssText = 'padding: 15px 0; border-bottom: 1px solid #eee; position: relative;';
  1923. historyItem.innerHTML = `
  1924. <div style="color: #999; font-size: 12px; margin-bottom: 5px;">${dateStr}</div>
  1925. <div style="margin-bottom: 8px; font-weight: bold;">${item.source}</div>
  1926. <div>${item.translation}</div>
  1927. `;
  1928. // Add to favorites button
  1929. const favButton = document.createElement('button');
  1930. favButton.innerHTML = '⭐';
  1931. favButton.title = '添加到收藏';
  1932. favButton.style.cssText = 'position: absolute; top: 15px; right: 0; background: none; border: none; font-size: 16px; cursor: pointer;';
  1933. favButton.addEventListener('click', () => {
  1934. Core.favoritesManager.add(item.source, item.translation);
  1935. favButton.innerHTML = '✓';
  1936. setTimeout(() => { favButton.innerHTML = '⭐'; }, 1000);
  1937. });
  1938. historyItem.appendChild(favButton);
  1939. content.appendChild(historyItem);
  1940. }
  1941. }
  1942. },
  1943. favoritesPanel: {
  1944. element: null,
  1945. visible: false,
  1946. create: () => {
  1947. if (!components.favoritesPanel.element) {
  1948. // Create panel
  1949. const panel = document.createElement('div');
  1950. panel.className = 'translator-favorites-panel';
  1951. panel.style.cssText = `
  1952. position: fixed;
  1953. top: 50%;
  1954. left: 50%;
  1955. transform: translate(-50%, -50%);
  1956. width: 500px;
  1957. max-width: 90%;
  1958. max-height: 90vh;
  1959. background: white;
  1960. border-radius: 8px;
  1961. box-shadow: 0 0 20px rgba(0,0,0,0.3);
  1962. z-index: 10000;
  1963. display: none;
  1964. flex-direction: column;
  1965. font-family: Arial, sans-serif;
  1966. overflow: hidden;
  1967. `;
  1968. // Header
  1969. const header = document.createElement('div');
  1970. header.style.cssText = `
  1971. padding: 15px;
  1972. border-bottom: 1px solid #eee;
  1973. display: flex;
  1974. justify-content: space-between;
  1975. align-items: center;
  1976. `;
  1977. const title = document.createElement('h3');
  1978. title.textContent = '收藏夹';
  1979. title.style.margin = '0';
  1980. const closeBtn = document.createElement('button');
  1981. closeBtn.innerHTML = '✖';
  1982. closeBtn.style.cssText = 'background: none; border: none; font-size: 16px; cursor: pointer;';
  1983. closeBtn.addEventListener('click', () => components.favoritesPanel.hide());
  1984. header.appendChild(title);
  1985. header.appendChild(closeBtn);
  1986. panel.appendChild(header);
  1987. // Content
  1988. const content = document.createElement('div');
  1989. content.className = 'favorite-items';
  1990. content.style.cssText = 'flex: 1; overflow-y: auto; padding: 0 15px; max-height: 70vh;';
  1991. panel.appendChild(content);
  1992. // Footer
  1993. const footer = document.createElement('div');
  1994. footer.style.cssText = 'padding: 10px 15px; border-top: 1px solid #eee; text-align: right;';
  1995. const clearBtn = document.createElement('button');
  1996. clearBtn.textContent = '清空收藏';
  1997. clearBtn.style.cssText = 'padding: 8px 16px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;';
  1998. clearBtn.addEventListener('click', () => {
  1999. if (confirm('确定要清空所有收藏吗?')) {
  2000. Core.favoritesManager.clear();
  2001. components.favoritesPanel.update();
  2002. }
  2003. });
  2004. footer.appendChild(clearBtn);
  2005. panel.appendChild(footer);
  2006. // Add to document
  2007. document.body.appendChild(panel);
  2008. components.favoritesPanel.element = panel;
  2009. }
  2010. return components.favoritesPanel.element;
  2011. },
  2012. show: () => {
  2013. const panel = components.favoritesPanel.create();
  2014. components.favoritesPanel.update();
  2015. panel.style.display = 'flex';
  2016. components.favoritesPanel.visible = true;
  2017. },
  2018. hide: () => {
  2019. if (components.favoritesPanel.element) {
  2020. components.favoritesPanel.element.style.display = 'none';
  2021. components.favoritesPanel.visible = false;
  2022. }
  2023. },
  2024. isVisible: () => components.favoritesPanel.visible,
  2025. update: () => {
  2026. const panel = components.favoritesPanel.element;
  2027. if (!panel) return;
  2028. const content = panel.querySelector('.favorite-items');
  2029. if (!content) return;
  2030. // Clear content
  2031. content.innerHTML = '';
  2032. // Get favorites
  2033. const favorites = State.get('translationFavorites');
  2034. if (favorites.length === 0) {
  2035. content.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无收藏</div>';
  2036. return;
  2037. }
  2038. // Create items in reverse order (newest first)
  2039. for (let i = favorites.length - 1; i >= 0; i--) {
  2040. const item = favorites[i];
  2041. const date = new Date(item.timestamp);
  2042. const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
  2043. const favoriteItem = document.createElement('div');
  2044. favoriteItem.className = 'favorite-item';
  2045. favoriteItem.style.cssText = 'padding: 15px 0; border-bottom: 1px solid #eee; position: relative;';
  2046. favoriteItem.innerHTML = `
  2047. <div style="color: #999; font-size: 12px; margin-bottom: 5px;">${dateStr}</div>
  2048. <div style="margin-bottom: 8px; font-weight: bold;">${item.source}</div>
  2049. <div>${item.translation}</div>
  2050. `;
  2051. // Remove from favorites button
  2052. const removeButton = document.createElement('button');
  2053. removeButton.innerHTML = '✖';
  2054. removeButton.title = '移除收藏';
  2055. removeButton.style.cssText = 'position: absolute; top: 15px; right: 0; background: none; border: none; font-size: 16px; cursor: pointer;';
  2056. removeButton.addEventListener('click', () => {
  2057. Core.favoritesManager.remove(item.source);
  2058. components.favoritesPanel.update();
  2059. });
  2060. favoriteItem.appendChild(removeButton);
  2061. content.appendChild(favoriteItem);
  2062. }
  2063. }
  2064. },
  2065. // Bottom page buttons
  2066. bottomButtons: {
  2067. element: null,
  2068. stateManager: null,
  2069. create: () => {
  2070. if (!components.bottomButtons.element) {
  2071. // Create container
  2072. const container = document.createElement('div');
  2073. container.className = 'translator-bottom-buttons';
  2074. container.style.cssText = `
  2075. position: fixed;
  2076. bottom: 20px;
  2077. right: 20px;
  2078. display: flex;
  2079. flex-direction: column;
  2080. gap: 10px;
  2081. z-index: 9995;
  2082. `;
  2083. // Settings button
  2084. const settingsBtn = document.createElement('button');
  2085. settingsBtn.innerHTML = '⚙️';
  2086. settingsBtn.title = '设置';
  2087. settingsBtn.style.cssText = `
  2088. width: 50px;
  2089. height: 50px;
  2090. border-radius: 50%;
  2091. background-color: white;
  2092. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  2093. border: none;
  2094. font-size: 20px;
  2095. cursor: pointer;
  2096. display: flex;
  2097. align-items: center;
  2098. justify-content: center;
  2099. `;
  2100. settingsBtn.addEventListener('click', () => {
  2101. UI.components.settingsPanel.show();
  2102. });
  2103. // History button
  2104. const historyBtn = document.createElement('button');
  2105. historyBtn.innerHTML = '📜';
  2106. historyBtn.title = '翻译历史';
  2107. historyBtn.style.cssText = `
  2108. width: 50px;
  2109. height: 50px;
  2110. border-radius: 50%;
  2111. background-color: white;
  2112. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  2113. border: none;
  2114. font-size: 20px;
  2115. cursor: pointer;
  2116. display: flex;
  2117. align-items: center;
  2118. justify-content: center;
  2119. `;
  2120. historyBtn.addEventListener('click', () => {
  2121. UI.components.historyPanel.show();
  2122. });
  2123. // Favorites button
  2124. const favoritesBtn = document.createElement('button');
  2125. favoritesBtn.innerHTML = '⭐';
  2126. favoritesBtn.title = '收藏夹';
  2127. favoritesBtn.style.cssText = `
  2128. width: 50px;
  2129. height: 50px;
  2130. border-radius: 50%;
  2131. background-color: white;
  2132. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  2133. border: none;
  2134. font-size: 20px;
  2135. cursor: pointer;
  2136. display: flex;
  2137. align-items: center;
  2138. justify-content: center;
  2139. `;
  2140. favoritesBtn.addEventListener('click', () => {
  2141. UI.components.favoritesPanel.show();
  2142. });
  2143. // Translate page button
  2144. const translatePageBtn = document.createElement('button');
  2145. translatePageBtn.innerHTML = '🌐';
  2146. translatePageBtn.title = '翻译整页 (长按重新翻译)';
  2147. translatePageBtn.style.cssText = `
  2148. width: 50px;
  2149. height: 50px;
  2150. border-radius: 50%;
  2151. background-color: white;
  2152. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  2153. border: none;
  2154. font-size: 20px;
  2155. cursor: pointer;
  2156. display: flex;
  2157. align-items: center;
  2158. justify-content: center;
  2159. `;
  2160. // Track press duration for the long press
  2161. let pressTimer;
  2162. let isLongPress = false;
  2163. translatePageBtn.addEventListener('mousedown', () => {
  2164. isLongPress = false;
  2165. pressTimer = setTimeout(() => {
  2166. isLongPress = true;
  2167. // Visual feedback
  2168. translatePageBtn.style.backgroundColor = '#5cb85c';
  2169. translatePageBtn.style.color = 'white';
  2170. }, 800); // Long press threshold: 800ms
  2171. });
  2172. translatePageBtn.addEventListener('mouseup', () => {
  2173. clearTimeout(pressTimer);
  2174. // Reset style if it was changed
  2175. if (isLongPress) {
  2176. translatePageBtn.style.backgroundColor = 'white';
  2177. translatePageBtn.style.color = 'inherit';
  2178. }
  2179. if (!State.get('isTranslatingFullPage')) {
  2180. if (isLongPress) {
  2181. // Long press - force re-translation
  2182. const segments = State.get('translationSegments');
  2183. const hasCachedTranslations = segments && segments.length > 0 && segments.some(s => s.fromCache);
  2184. if (hasCachedTranslations) {
  2185. if (confirm('确定要忽略缓存重新翻译整个页面吗?这可能需要更长时间。')) {
  2186. Core.translateFullPage({ forceRetranslate: true }).catch(error => {
  2187. alert(`翻译整页失败: ${error.message}`);
  2188. });
  2189. }
  2190. } else {
  2191. Core.translateFullPage({ forceRetranslate: true }).catch(error => {
  2192. alert(`翻译整页失败: ${error.message}`);
  2193. });
  2194. }
  2195. } else {
  2196. // Normal click - regular translation
  2197. Core.translateFullPage().catch(error => {
  2198. alert(`翻译整页失败: ${error.message}`);
  2199. });
  2200. }
  2201. }
  2202. });
  2203. // Cancel long press if mouse leaves the button
  2204. translatePageBtn.addEventListener('mouseout', () => {
  2205. clearTimeout(pressTimer);
  2206. // Reset style if needed
  2207. if (isLongPress) {
  2208. translatePageBtn.style.backgroundColor = 'white';
  2209. translatePageBtn.style.color = 'inherit';
  2210. isLongPress = false;
  2211. }
  2212. });
  2213. // Add buttons to container
  2214. container.appendChild(translatePageBtn);
  2215. container.appendChild(historyBtn);
  2216. container.appendChild(favoritesBtn);
  2217. container.appendChild(settingsBtn);
  2218. // Store element references
  2219. components.bottomButtons.element = container;
  2220. components.bottomButtons.translateButton = translatePageBtn;
  2221. // Add to document
  2222. document.body.appendChild(container);
  2223. }
  2224. return components.bottomButtons.element;
  2225. },
  2226. setupStateSubscriptions: () => {
  2227. // Clean up existing subscriptions
  2228. if (components.bottomButtons.stateManager) {
  2229. components.bottomButtons.stateManager.cleanup();
  2230. }
  2231. // Create state manager for this component
  2232. const stateManager = State.registerComponent('bottomButtons');
  2233. components.bottomButtons.stateManager = stateManager;
  2234. // Subscribe to translation state
  2235. stateManager.subscribe('isTranslatingFullPage', isTranslating => {
  2236. const translateBtn = components.bottomButtons.translateButton;
  2237. if (translateBtn) {
  2238. translateBtn.disabled = isTranslating;
  2239. translateBtn.style.opacity = isTranslating ? '0.5' : '1';
  2240. translateBtn.style.cursor = isTranslating ? 'not-allowed' : 'pointer';
  2241. }
  2242. });
  2243. },
  2244. show: () => {
  2245. const buttons = components.bottomButtons.create();
  2246. buttons.style.display = 'flex';
  2247. // Set up state subscriptions
  2248. components.bottomButtons.setupStateSubscriptions();
  2249. },
  2250. hide: () => {
  2251. if (components.bottomButtons.element) {
  2252. components.bottomButtons.element.style.display = 'none';
  2253. // Clean up subscriptions
  2254. if (components.bottomButtons.stateManager) {
  2255. components.bottomButtons.stateManager.cleanup();
  2256. }
  2257. }
  2258. }
  2259. }
  2260. };
  2261. // Initialize UI
  2262. const init = () => {
  2263. // Setup selection event listeners
  2264. document.addEventListener('mouseup', (e) => {
  2265. // Don't show translate button if clicked in a popup
  2266. if (e.target.closest('.translation-popup')) {
  2267. return;
  2268. }
  2269. // Get selected text
  2270. const selection = window.getSelection();
  2271. const text = selection.toString().trim();
  2272. // Hide translate button if no text is selected
  2273. if (text.length === 0) {
  2274. components.translateButton.hide();
  2275. return;
  2276. }
  2277. // Get selection rectangle
  2278. const range = selection.getRangeAt(0);
  2279. const rect = range.getBoundingClientRect();
  2280. // Update state
  2281. State.set('lastSelectedText', text);
  2282. State.set('lastSelectionRect', rect);
  2283. // Show translate button
  2284. components.translateButton.show(rect);
  2285. });
  2286. // Hide translate button when clicking outside
  2287. document.addEventListener('mousedown', (e) => {
  2288. // Don't hide if clicked on translate button or popup
  2289. if (e.target.closest('.translate-button') || e.target.closest('.translation-popup')) {
  2290. return;
  2291. }
  2292. components.translateButton.hide();
  2293. });
  2294. // Show bottom buttons
  2295. components.bottomButtons.show();
  2296. };
  2297. return {
  2298. init,
  2299. components
  2300. };
  2301. })();
  2302.  
  2303. /**
  2304. * Utils Module - Utility functions
  2305. */
  2306. const Utils = (function() {
  2307. // Language detection
  2308. const detectLanguage = (text) => {
  2309. return new Promise((resolve) => {
  2310. const chineseRegex = /[\u4e00-\u9fa5]/;
  2311. const englishRegex = /[a-zA-Z]/;
  2312. const japaneseRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
  2313. const koreanRegex = /[\uAC00-\uD7AF\u1100-\u11FF]/;
  2314.  
  2315. if (chineseRegex.test(text)) resolve('中文');
  2316. else if (englishRegex.test(text)) resolve('英语');
  2317. else if (japaneseRegex.test(text)) resolve('日语');
  2318. else if (koreanRegex.test(text)) resolve('韩语');
  2319. else resolve('未知');
  2320. });
  2321. };
  2322. // HTML utilities
  2323. const escapeHtml = (text) => {
  2324. const div = document.createElement('div');
  2325. div.textContent = text;
  2326. return div.innerHTML;
  2327. };
  2328. const decodeHtmlEntities = (text) => {
  2329. const div = document.createElement('div');
  2330. div.innerHTML = text;
  2331. return div.textContent;
  2332. };
  2333. // Text processing utilities
  2334. const isShortEnglishPhrase = (text) => {
  2335. // Check if the text is a short English phrase (for word explanation mode)
  2336. const trimmedText = text.trim();
  2337. const words = trimmedText.split(/\s+/);
  2338. // Short phrase has at most 5 words and is less than 30 characters
  2339. return (
  2340. /^[a-zA-Z\s.,;:'"-?!()]+$/.test(trimmedText) &&
  2341. words.length <= 5 &&
  2342. trimmedText.length < 30
  2343. );
  2344. };
  2345. // Text node extraction for page content
  2346. const extractTextNodesFromElement = (element, textSegments = [], depth = 0, excludeSelectors = null) => {
  2347. // Skip if element is null or invalid
  2348. if (!element) return textSegments;
  2349. // Skip excluded elements
  2350. if (excludeSelectors && element.matches && element.matches(excludeSelectors)) {
  2351. return textSegments;
  2352. }
  2353. try {
  2354. // For element nodes
  2355. if (element.nodeType === Node.ELEMENT_NODE) {
  2356. // Skip hidden elements and non-content elements
  2357. try {
  2358. const style = window.getComputedStyle(element);
  2359. if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
  2360. return textSegments;
  2361. }
  2362. } catch (e) {
  2363. // Ignore style errors
  2364. }
  2365. // Skip script, style, and other non-content elements
  2366. if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(element.tagName)) {
  2367. return textSegments;
  2368. }
  2369. // Special handling for headings to keep their structure
  2370. if (/^H[1-6]$/.test(element.tagName) && element.textContent.trim()) {
  2371. textSegments.push({
  2372. node: element,
  2373. text: element.textContent.trim(),
  2374. depth: depth,
  2375. isHeading: true,
  2376. element: element
  2377. });
  2378. return textSegments;
  2379. }
  2380. // Process paragraphs and blocks that contain simple content
  2381. // Check if this is a simple text block with basic formatting
  2382. if (['P', 'DIV', 'LI', 'TD', 'SPAN'].includes(element.tagName) &&
  2383. element.textContent.trim() &&
  2384. !element.querySelector('div, p, section, article, h1, h2, h3, h4, h5, h6, ul, ol, table, img, figure')) {
  2385. textSegments.push({
  2386. node: element,
  2387. text: element.textContent.trim(),
  2388. depth: depth,
  2389. isFormattedElement: true,
  2390. element: element
  2391. });
  2392. return textSegments;
  2393. }
  2394. // Process img elements - skip img elements completely
  2395. if (element.tagName === 'IMG') {
  2396. return textSegments;
  2397. }
  2398. // Recursively process all child nodes
  2399. for (let i = 0; i < element.childNodes.length; i++) {
  2400. const child = element.childNodes[i];
  2401. extractTextNodesFromElement(child, textSegments, depth + 1, excludeSelectors);
  2402. }
  2403. }
  2404. // Process text nodes
  2405. else if (element.nodeType === Node.TEXT_NODE) {
  2406. const text = element.textContent.trim();
  2407. if (text) {
  2408. textSegments.push({
  2409. node: element,
  2410. text: text,
  2411. depth: depth,
  2412. parent: element.parentElement
  2413. });
  2414. }
  2415. }
  2416. } catch (error) {
  2417. console.warn("Error processing element:", error);
  2418. }
  2419. return textSegments;
  2420. };
  2421. // Merge text segments into manageable chunks
  2422. const mergeTextSegments = (textSegments, maxLength = 2000) => {
  2423. if (!textSegments || textSegments.length === 0) {
  2424. return [];
  2425. }
  2426. const merged = [];
  2427. let currentSegment = {
  2428. nodes: [],
  2429. text: '',
  2430. translation: null,
  2431. error: null
  2432. };
  2433. // Sort segments by document position when possible
  2434. textSegments.sort((a, b) => {
  2435. // If both are regular nodes, compare document position
  2436. if (a.node && b.node && !a.isHeading && !b.isHeading && !a.isFormattedElement && !b.isFormattedElement) {
  2437. return a.node.compareDocumentPosition(b.node) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
  2438. }
  2439. // Special handling for headings and formatted elements - preserve their order
  2440. return 0;
  2441. });
  2442. for (const segment of textSegments) {
  2443. // Start a new segment for headings and formatted elements
  2444. if (segment.isHeading || segment.isFormattedElement ||
  2445. (currentSegment.text.length + segment.text.length > maxLength && currentSegment.nodes.length > 0)) {
  2446. if (currentSegment.nodes.length > 0) {
  2447. merged.push(currentSegment);
  2448. }
  2449. // Create a dedicated segment for headings and formatted elements
  2450. if (segment.isHeading || segment.isFormattedElement) {
  2451. merged.push({
  2452. nodes: [segment],
  2453. text: segment.text,
  2454. translation: null,
  2455. error: null,
  2456. isHeading: segment.isHeading,
  2457. isFormattedElement: segment.isFormattedElement
  2458. });
  2459. // Start fresh for next segment
  2460. currentSegment = {
  2461. nodes: [],
  2462. text: '',
  2463. translation: null,
  2464. error: null
  2465. };
  2466. continue;
  2467. } else {
  2468. // Regular new segment
  2469. currentSegment = {
  2470. nodes: [],
  2471. text: '',
  2472. translation: null,
  2473. error: null
  2474. };
  2475. }
  2476. }
  2477. // Add the segment to the current merged segment
  2478. currentSegment.nodes.push(segment);
  2479. // Add a space if the current segment is not empty
  2480. if (currentSegment.text.length > 0) {
  2481. currentSegment.text += ' ';
  2482. }
  2483. currentSegment.text += segment.text;
  2484. }
  2485. // Push the last segment if it's not empty
  2486. if (currentSegment.nodes.length > 0) {
  2487. merged.push(currentSegment);
  2488. }
  2489. return merged;
  2490. };
  2491. // Extract page content for translation
  2492. const extractPageContent = () => {
  2493. const selector = Config.getSetting('fullPageTranslationSelector');
  2494. const excludeSelectors = Config.getSetting('excludeSelectors');
  2495. const maxLength = Config.getSetting('fullPageMaxSegmentLength');
  2496. let elements = [];
  2497. try {
  2498. elements = document.querySelectorAll(selector);
  2499. } catch (e) {
  2500. throw new Error(`选择器语法错误: ${e.message}`);
  2501. }
  2502. if (elements.length === 0) {
  2503. throw new Error(`未找到匹配选择器 "${selector}" 的元素`);
  2504. }
  2505. // Extract text nodes from all matching elements
  2506. let allTextNodes = [];
  2507. elements.forEach(element => {
  2508. const textNodes = extractTextNodesFromElement(element, [], 0, excludeSelectors);
  2509. allTextNodes = allTextNodes.concat(textNodes);
  2510. });
  2511. // If no text nodes found
  2512. if (allTextNodes.length === 0) {
  2513. throw new Error('未找到可翻译的文本内容');
  2514. }
  2515. // Merge text nodes into segments
  2516. return mergeTextSegments(allTextNodes, maxLength);
  2517. };
  2518. // Detect main content of the page
  2519. const detectMainContent = () => {
  2520. // Try to find the main content area of the page
  2521. const possibleSelectors = [
  2522. 'article', 'main', '.article', '.post', '.content', '#content',
  2523. '[role="main"]', '.main-content', '#main-content', '.post-content',
  2524. '.entry-content', '.article-content', '.story', '.body'
  2525. ];
  2526. // Check if any of the selectors exist on the page
  2527. for (const selector of possibleSelectors) {
  2528. const elements = document.querySelectorAll(selector);
  2529. if (elements.length > 0) {
  2530. // Find the element with the most text content
  2531. let bestElement = null;
  2532. let maxTextLength = 0;
  2533. elements.forEach(element => {
  2534. const textLength = element.textContent.trim().length;
  2535. if (textLength > maxTextLength) {
  2536. maxTextLength = textLength;
  2537. bestElement = element;
  2538. }
  2539. });
  2540. if (bestElement && maxTextLength > 500) {
  2541. return bestElement;
  2542. }
  2543. }
  2544. }
  2545. // If no specific content area found, analyze paragraphs
  2546. const paragraphs = document.querySelectorAll('p');
  2547. if (paragraphs.length > 5) {
  2548. // Group nearby paragraphs to find content clusters
  2549. const clusters = [];
  2550. let currentCluster = null;
  2551. let lastRect = null;
  2552. paragraphs.forEach(p => {
  2553. const rect = p.getBoundingClientRect();
  2554. const text = p.textContent.trim();
  2555. // Skip empty paragraphs
  2556. if (text.length < 20) return;
  2557. // Start a new cluster if needed
  2558. if (!currentCluster || !lastRect || Math.abs(rect.top - lastRect.bottom) > 100) {
  2559. if (currentCluster) {
  2560. clusters.push(currentCluster);
  2561. }
  2562. currentCluster = {
  2563. elements: [p],
  2564. textLength: text.length
  2565. };
  2566. } else {
  2567. // Add to current cluster
  2568. currentCluster.elements.push(p);
  2569. currentCluster.textLength += text.length;
  2570. }
  2571. lastRect = rect;
  2572. });
  2573. // Add the last cluster
  2574. if (currentCluster) {
  2575. clusters.push(currentCluster);
  2576. }
  2577. // Find the cluster with the most text
  2578. let bestCluster = null;
  2579. let maxClusterTextLength = 0;
  2580. clusters.forEach(cluster => {
  2581. if (cluster.textLength > maxClusterTextLength) {
  2582. maxClusterTextLength = cluster.textLength;
  2583. bestCluster = cluster;
  2584. }
  2585. });
  2586. if (bestCluster && bestCluster.elements.length > 0) {
  2587. // Find common ancestor of elements in best cluster
  2588. const firstElement = bestCluster.elements[0];
  2589. let commonAncestor = firstElement.parentElement;
  2590. // Go up the DOM tree to find an ancestor that contains at least 80% of the cluster's elements
  2591. while (commonAncestor && commonAncestor !== document.body) {
  2592. let containedCount = 0;
  2593. bestCluster.elements.forEach(el => {
  2594. if (commonAncestor.contains(el)) {
  2595. containedCount++;
  2596. }
  2597. });
  2598. if (containedCount >= bestCluster.elements.length * 0.8) {
  2599. return commonAncestor;
  2600. }
  2601. commonAncestor = commonAncestor.parentElement;
  2602. }
  2603. // Fallback to the first paragraph's parent if no good common ancestor
  2604. return firstElement.parentElement;
  2605. }
  2606. }
  2607. // Default to body if nothing better found
  2608. return document.body;
  2609. };
  2610. return {
  2611. detectLanguage,
  2612. escapeHtml,
  2613. decodeHtmlEntities,
  2614. isShortEnglishPhrase,
  2615. extractTextNodesFromElement,
  2616. mergeTextSegments,
  2617. extractPageContent,
  2618. detectMainContent
  2619. };
  2620. })();
  2621.  
  2622. /**
  2623. * Core Module - Main application logic
  2624. */
  2625. const Core = (function() {
  2626. // Private cache for tracking initialization
  2627. let isInitialized = false;
  2628. // Translation favorites and history management
  2629. const historyManager = {
  2630. add: (source, translation) => {
  2631. // Add to history
  2632. const history = State.get('translationHistory');
  2633. // Create history item
  2634. const item = {
  2635. source,
  2636. translation,
  2637. timestamp: Date.now()
  2638. };
  2639. // Add to the beginning
  2640. const newHistory = [item, ...history.filter(h => h.source !== source)];
  2641. // Limit history size
  2642. const maxHistorySize = Config.getSetting('historySize');
  2643. if (newHistory.length > maxHistorySize) {
  2644. newHistory.length = maxHistorySize;
  2645. }
  2646. // Update state
  2647. State.set('translationHistory', newHistory);
  2648. },
  2649. clear: () => {
  2650. State.set('translationHistory', []);
  2651. }
  2652. };
  2653. // Translation favorites management
  2654. const favoritesManager = {
  2655. add: (source, translation) => {
  2656. const favorites = State.get('translationFavorites');
  2657. // Create favorite item
  2658. const item = {
  2659. source,
  2660. translation,
  2661. timestamp: Date.now()
  2662. };
  2663. // Add to the beginning if not already exists
  2664. const newFavorites = [item, ...favorites.filter(f => f.source !== source)];
  2665. // Update state
  2666. State.set('translationFavorites', newFavorites);
  2667. },
  2668. remove: (source) => {
  2669. const favorites = State.get('translationFavorites');
  2670. // Filter out the item
  2671. const newFavorites = favorites.filter(f => f.source !== source);
  2672. // Update state
  2673. State.set('translationFavorites', newFavorites);
  2674. },
  2675. clear: () => {
  2676. State.set('translationFavorites', []);
  2677. },
  2678. isFavorite: (source) => {
  2679. const favorites = State.get('translationFavorites');
  2680. return favorites.some(f => f.source === source);
  2681. }
  2682. };
  2683. // Translation cache management
  2684. const cacheManager = {
  2685. add: (source, translation) => {
  2686. // 修改缓存策略:对于短文本(小于3字符)的特殊处理
  2687. // 1) 如果文本太长,仍然不缓存
  2688. if (source.length > 10000) return;
  2689. // 2) 对于极短文本,我们缓存它但添加特殊标记
  2690. const isShortText = source.length < 3;
  2691. State.debugLog('Adding to cache:', source, translation, isShortText ? '(短文本)' : '');
  2692. // Get existing cache
  2693. const cache = State.get('translationCache');
  2694. // Add to cache with timestamp
  2695. cache[source] = {
  2696. translation,
  2697. timestamp: Date.now(),
  2698. isShortText // 标记是否为短文本
  2699. };
  2700. // Prune cache if it's too large
  2701. cacheManager.prune();
  2702. // Update state
  2703. State.set('translationCache', cache);
  2704. },
  2705. get: (source) => {
  2706. const cache = State.get('translationCache');
  2707. return cache[source] ? cache[source].translation : null;
  2708. },
  2709. clear: () => {
  2710. State.set('translationCache', {});
  2711. },
  2712. prune: () => {
  2713. const cache = State.get('translationCache');
  2714. const maxCacheSize = Config.getSetting('maxCacheSize');
  2715. const maxCacheAge = Config.getSetting('maxCacheAge') * 24 * 60 * 60 * 1000; // Convert days to milliseconds
  2716. // If cache is not too large, just return
  2717. if (Object.keys(cache).length <= maxCacheSize) return;
  2718. // Get all entries with timestamps
  2719. const entries = Object.entries(cache).map(([source, data]) => ({
  2720. source,
  2721. timestamp: data.timestamp || 0
  2722. }));
  2723. // Remove old entries beyond max age
  2724. const now = Date.now();
  2725. const recentEntries = entries.filter(e => now - e.timestamp <= maxCacheAge);
  2726. // If we're still over the limit, remove least recently used
  2727. if (recentEntries.length > maxCacheSize) {
  2728. // Sort by timestamp (oldest first)
  2729. recentEntries.sort((a, b) => a.timestamp - b.timestamp);
  2730. // Keep only the newest entries
  2731. recentEntries.length = maxCacheSize;
  2732. }
  2733. // Create new cache with only the entries we want to keep
  2734. const newCache = {};
  2735. recentEntries.forEach(e => {
  2736. newCache[e.source] = cache[e.source];
  2737. });
  2738. // Update state
  2739. State.set('translationCache', newCache);
  2740. },
  2741. // Apply cached translations to current segments
  2742. apply: async () => {
  2743. const segments = State.get('translationSegments');
  2744. if (!segments || segments.length === 0) return false;
  2745. // Set cache application state
  2746. State.set('isApplyingCache', true);
  2747. State.set('isStopped', false);
  2748. State.set('isTranslatingFullPage', true); // Ensure we're in translating state
  2749. let appliedCount = 0;
  2750. // Try to apply cached translations
  2751. for (let i = 0; i < segments.length; i++) {
  2752. const segment = segments[i];
  2753. const cachedTranslation = cacheManager.get(segment.text);
  2754. if (cachedTranslation) {
  2755. segment.translation = cachedTranslation;
  2756. segment.fromCache = true;
  2757. appliedCount++;
  2758. // Apply translation to DOM immediately for this segment
  2759. applyTranslationToSegment(segment);
  2760. // 更新进度,在每个缓存段落应用后触发进度更新
  2761. State.set('lastTranslatedIndex', i);
  2762. }
  2763. }
  2764. // Done applying cache - set state to finished
  2765. State.set('isApplyingCache', false);
  2766. State.set('cacheApplied', appliedCount > 0);
  2767. // 完成缓存应用后,再次更新进度以确保UI正确反映当前状态
  2768. if (appliedCount > 0) {
  2769. State.set('lastTranslatedIndex', segments.findIndex(s => !s.translation && !s.error));
  2770. // If we applied all segments from cache, mark as complete
  2771. const allSegmentsTranslated = segments.every(s => s.translation || s.error);
  2772. if (allSegmentsTranslated) {
  2773. // All segments are translated from cache, stop the translation process
  2774. State.set('isTranslatingFullPage', false);
  2775. // Ensure UI shows complete status
  2776. const statusElement = UI.components.pageControls.statusElement;
  2777. if (statusElement) {
  2778. statusElement.textContent = '翻译完成 (全部来自缓存)';
  2779. statusElement.style.color = '#4CAF50';
  2780. }
  2781. }
  2782. }
  2783. return appliedCount > 0;
  2784. }
  2785. };
  2786. // Initialize the application
  2787. const init = () => {
  2788. if (isInitialized) return;
  2789. // Initialize all modules
  2790. Config.init();
  2791. UI.init();
  2792. // Register menu commands
  2793. GM_registerMenuCommand('翻译设置', () => {
  2794. UI.components.settingsPanel.show();
  2795. });
  2796. GM_registerMenuCommand('翻译历史', () => {
  2797. UI.components.historyPanel.show();
  2798. });
  2799. GM_registerMenuCommand('翻译收藏夹', () => {
  2800. UI.components.favoritesPanel.show();
  2801. });
  2802. GM_registerMenuCommand('翻译整页', () => {
  2803. Core.translateFullPage().catch(error => {
  2804. alert(`翻译整页失败: ${error.message}`);
  2805. });
  2806. });
  2807. // Set up global state change handlers
  2808. State.subscribe('translationHistory', () => {
  2809. if (UI.components.historyPanel) {
  2810. UI.components.historyPanel.update();
  2811. }
  2812. });
  2813. State.subscribe('translationFavorites', () => {
  2814. if (UI.components.favoritesPanel) {
  2815. UI.components.favoritesPanel.update();
  2816. }
  2817. });
  2818. isInitialized = true;
  2819. State.debugLog('Translator initialized');
  2820. };
  2821. // Translation functionality
  2822. const translateSelectedText = async (text, rect, isExplanationMode = false) => {
  2823. if (!text || text.trim().length === 0) return;
  2824. try {
  2825. // Get translation context if enabled
  2826. let context = null;
  2827. if (Config.getSetting('useTranslationContext')) {
  2828. const history = State.get('translationHistory');
  2829. const contextSize = Config.getSetting('contextSize');
  2830. context = history.slice(-contextSize).map(item => ({
  2831. source: item.source,
  2832. translation: item.translation
  2833. }));
  2834. }
  2835. // Perform translation
  2836. const translation = await API.retryTranslation(text, {
  2837. isWordExplanationMode: isExplanationMode,
  2838. context
  2839. });
  2840. // Add to history
  2841. historyManager.add(text, translation);
  2842. // Add to cache
  2843. cacheManager.add(text, translation);
  2844. return translation;
  2845. } catch (error) {
  2846. State.debugLog('Translation error:', error);
  2847. throw error;
  2848. }
  2849. };
  2850. const translateFullPage = async (options = {}) => {
  2851. // Default options
  2852. const defaultOptions = {
  2853. forceRetranslate: false, // Whether to force re-translation even when cache is available
  2854. };
  2855. const opts = {...defaultOptions, ...options};
  2856. // If translation is already in progress, don't start a new one
  2857. if (State.get('isTranslatingFullPage')) {
  2858. return;
  2859. }
  2860. // If we have previously translated segments, check whether to restart
  2861. const existingSegments = State.get('translationSegments');
  2862. if (existingSegments && existingSegments.length > 0) {
  2863. // We're restarting a translation - reset everything
  2864. restoreOriginalText(true);
  2865. }
  2866. try {
  2867. // Set translation state
  2868. State.set('isTranslatingFullPage', true);
  2869. State.set('isTranslationPaused', false);
  2870. State.set('isStopped', false);
  2871. State.set('lastTranslatedIndex', -1);
  2872. State.set('isShowingTranslation', true);
  2873. // Extract content for translation
  2874. let segments;
  2875. if (Config.getSetting('detectArticleContent')) {
  2876. // Detect main content area
  2877. const mainContent = Utils.detectMainContent();
  2878. // Override selector temporarily to target the main content
  2879. const originalSelector = Config.getSetting('fullPageTranslationSelector');
  2880. // Create a unique selector for the detected element
  2881. let tempId = 'translator-detected-content-' + Date.now();
  2882. mainContent.id = tempId;
  2883. Config.updateSetting('fullPageTranslationSelector', '#' + tempId);
  2884. segments = Utils.extractPageContent();
  2885. // Restore original selector
  2886. Config.updateSetting('fullPageTranslationSelector', originalSelector);
  2887. // Remove temporary ID
  2888. mainContent.removeAttribute('id');
  2889. } else {
  2890. // Use configured selector
  2891. segments = Utils.extractPageContent();
  2892. }
  2893. // Store segments and original texts
  2894. State.set('translationSegments', segments);
  2895. State.set('originalTexts', segments.map(s => s.text));
  2896. // Show translation controls
  2897. UI.components.pageControls.show();
  2898. // Check if we should apply cache or force re-translation
  2899. if (!opts.forceRetranslate) {
  2900. // Attempt to apply translations from cache
  2901. const cacheApplied = await cacheManager.apply();
  2902. // Start translating uncached segments if needed
  2903. if (cacheApplied) {
  2904. // Start translating from where cache left off
  2905. const untranslatedIndex = segments.findIndex(s => !s.translation && !s.error);
  2906. if (untranslatedIndex !== -1) {
  2907. await translateNextSegment(untranslatedIndex);
  2908. }
  2909. } else {
  2910. // No cache applied, start from beginning
  2911. await translateNextSegment(0);
  2912. }
  2913. } else {
  2914. // Force re-translation - ignore cache and start from beginning
  2915. segments.forEach(segment => {
  2916. // Clear previous translations but keep the text
  2917. segment.translation = null;
  2918. segment.error = null;
  2919. segment.fromCache = false;
  2920. segment.pending = false;
  2921. });
  2922. // Start translating from beginning
  2923. await translateNextSegment(0);
  2924. }
  2925. return true;
  2926. } catch (error) {
  2927. State.set('isTranslatingFullPage', false);
  2928. State.debugLog('Full page translation error:', error);
  2929. throw error;
  2930. }
  2931. };
  2932. // Translate the next segment in a full page translation
  2933. const translateNextSegment = async (index) => {
  2934. const segments = State.get('translationSegments');
  2935. // Check if index is valid
  2936. if (index < 0 || index >= segments.length) {
  2937. // 处理无效索引的情况,通常意味着已经翻译完成所有段落
  2938. // 更新最后翻译的索引为最大值,确保进度为100%
  2939. State.set('lastTranslatedIndex', segments.length - 1);
  2940. State.set('isTranslatingFullPage', false);
  2941. // 更新状态为完成
  2942. const statusElement = UI.components.pageControls.statusElement;
  2943. if (statusElement) {
  2944. statusElement.textContent = '翻译完成';
  2945. statusElement.style.color = '#4CAF50';
  2946. }
  2947. // 手动强制更新进度显示为100%
  2948. const { indicator, percentage } = UI.components.pageControls.progressElement;
  2949. indicator.style.width = '100%';
  2950. percentage.textContent = `100% (${segments.length}/${segments.length})`;
  2951. // Final stats update
  2952. UI.components.pageControls.updateStats(segments);
  2953. return;
  2954. }
  2955. // Check if translation is paused or stopped
  2956. if (State.get('isTranslationPaused') || State.get('isStopped')) {
  2957. return;
  2958. }
  2959. try {
  2960. const segment = segments[index];
  2961. // Skip already translated segments
  2962. if (segment.translation || segment.error) {
  2963. // Continue with next segment
  2964. if (index < segments.length - 1) {
  2965. translateNextSegment(index + 1);
  2966. } else {
  2967. // Translation complete
  2968. // 确保最后一个段落也被计入进度
  2969. State.set('lastTranslatedIndex', segments.length - 1);
  2970. State.set('isTranslatingFullPage', false);
  2971. // Update status when translation is actually complete
  2972. const statusElement = UI.components.pageControls.statusElement;
  2973. if (statusElement) {
  2974. statusElement.textContent = '翻译完成';
  2975. statusElement.style.color = '#4CAF50';
  2976. }
  2977. // 手动强制更新进度显示为100%
  2978. const { indicator, percentage } = UI.components.pageControls.progressElement;
  2979. indicator.style.width = '100%';
  2980. percentage.textContent = `100% (${segments.length}/${segments.length})`;
  2981. // Final stats update
  2982. UI.components.pageControls.updateStats(segments);
  2983. }
  2984. return;
  2985. }
  2986. // Update progress
  2987. State.set('lastTranslatedIndex', index);
  2988. // Get context from previous segments if enabled
  2989. let context = null;
  2990. if (Config.getSetting('useTranslationContext')) {
  2991. const contextSize = Config.getSetting('contextSize');
  2992. context = [];
  2993. // Get context from previous segments
  2994. for (let i = Math.max(0, index - contextSize); i < index; i++) {
  2995. if (segments[i].translation) {
  2996. context.push({
  2997. source: segments[i].text,
  2998. translation: segments[i].translation
  2999. });
  3000. }
  3001. }
  3002. }
  3003. // Translate segment
  3004. const translation = await API.retryTranslation(segment.text, { context });
  3005. // Update segment with translation
  3006. segment.translation = translation;
  3007. // Explicitly mark as not pending
  3008. segment.pending = false;
  3009. // Add or update cache
  3010. if (segment.fromCache) {
  3011. // This was previously from cache but now we have a new translation
  3012. segment.fromCache = false;
  3013. }
  3014. cacheManager.add(segment.text, translation);
  3015. // Apply translations to DOM if we're showing them
  3016. if (State.get('isShowingTranslation')) {
  3017. applyTranslationToSegment(segment);
  3018. }
  3019. // Continue with next segment
  3020. if (index < segments.length - 1) {
  3021. translateNextSegment(index + 1);
  3022. } else {
  3023. // Translation complete
  3024. // 确保最后一个段落也被计入进度
  3025. State.set('lastTranslatedIndex', segments.length - 1);
  3026. State.set('isTranslatingFullPage', false);
  3027. // Update status when translation is actually complete
  3028. const statusElement = UI.components.pageControls.statusElement;
  3029. if (statusElement) {
  3030. statusElement.textContent = '翻译完成';
  3031. statusElement.style.color = '#4CAF50';
  3032. }
  3033. // 手动强制更新进度显示为100%
  3034. const { indicator, percentage } = UI.components.pageControls.progressElement;
  3035. indicator.style.width = '100%';
  3036. percentage.textContent = `100% (${segments.length}/${segments.length})`;
  3037. // Final stats update
  3038. UI.components.pageControls.updateStats(segments);
  3039. }
  3040. } catch (error) {
  3041. // Mark segment as having an error
  3042. segments[index].error = error.message;
  3043. // Explicitly mark as not pending
  3044. segments[index].pending = false;
  3045. // Continue with next segment
  3046. if (index < segments.length - 1) {
  3047. translateNextSegment(index + 1);
  3048. } else {
  3049. // Translation complete even with errors
  3050. // 确保最后一个段落也被计入进度计算
  3051. State.set('lastTranslatedIndex', segments.length - 1);
  3052. State.set('isTranslatingFullPage', false);
  3053. // Update status when translation is actually complete
  3054. const statusElement = UI.components.pageControls.statusElement;
  3055. if (statusElement) {
  3056. statusElement.textContent = '翻译完成';
  3057. statusElement.style.color = '#4CAF50';
  3058. }
  3059. // 手动强制更新进度显示为100%
  3060. const { indicator, percentage } = UI.components.pageControls.progressElement;
  3061. indicator.style.width = '100%';
  3062. percentage.textContent = `100% (${segments.length}/${segments.length})`;
  3063. // Final stats update
  3064. UI.components.pageControls.updateStats(segments);
  3065. }
  3066. }
  3067. };
  3068. // Stop translation but preserve the progress
  3069. const stopTranslation = () => {
  3070. State.set('isStopped', true);
  3071. State.set('isTranslatingFullPage', false);
  3072. // Reset pause state when translation is stopped
  3073. if (State.get('isTranslationPaused')) {
  3074. State.set('isTranslationPaused', false);
  3075. }
  3076. // Update status
  3077. const statusElement = UI.components.pageControls.statusElement;
  3078. if (statusElement) {
  3079. statusElement.textContent = '翻译已停止';
  3080. statusElement.style.color = '';
  3081. }
  3082. // 确保已翻译部分的进度正确反映
  3083. const segments = State.get('translationSegments');
  3084. if (segments && segments.length > 0) {
  3085. // 设置最后翻译索引以触发进度更新
  3086. const lastTranslated = segments.reduce((max, segment, idx) =>
  3087. (segment.translation || segment.error) ? idx : max, -1);
  3088. if (lastTranslated >= 0) {
  3089. State.set('lastTranslatedIndex', lastTranslated);
  3090. }
  3091. // Final stats update
  3092. UI.components.pageControls.updateStats(segments);
  3093. }
  3094. };
  3095. // Apply translation to a segment
  3096. const applyTranslationToSegment = (segment) => {
  3097. if (!segment.translation) return;
  3098. if (segment.isHeading || segment.isFormattedElement) {
  3099. // For headings and formatted elements
  3100. const firstNode = segment.nodes[0];
  3101. const element = firstNode.element;
  3102. if (element) {
  3103. if (Config.getSetting('showSourceLanguage')) {
  3104. // Style the original
  3105. element.style.color = '#999';
  3106. element.style.fontStyle = 'italic';
  3107. element.style.marginBottom = '5px';
  3108. // Find or create translation element
  3109. let translationElement = element.nextSibling;
  3110. if (!translationElement || !translationElement.classList || !translationElement.classList.contains('translated-text')) {
  3111. // Create new element
  3112. translationElement = document.createElement('div');
  3113. translationElement.className = 'translated-text';
  3114. translationElement.style.cssText = 'color: #333; font-style: normal;';
  3115. // Clone the element to preserve its structure but with translated text
  3116. const clonedElement = element.cloneNode(true);
  3117. // Replace all text nodes in the clone with translated text
  3118. replaceTextInElement(clonedElement, segment.translation);
  3119. translationElement.innerHTML = clonedElement.innerHTML;
  3120. element.parentNode.insertBefore(translationElement, element.nextSibling);
  3121. } else {
  3122. // Show existing translation
  3123. translationElement.style.display = '';
  3124. }
  3125. } else {
  3126. // Store original HTML
  3127. if (!element.getAttribute('data-original-text')) {
  3128. element.setAttribute('data-original-text', element.innerHTML);
  3129. }
  3130. // Replace all text nodes in the element with translated text
  3131. // This preserves HTML structure including links
  3132. replaceTextInElement(element, segment.translation);
  3133. }
  3134. }
  3135. } else {
  3136. // Apply translation to each individual text node
  3137. segment.nodes.forEach(nodeInfo => {
  3138. const originalNode = nodeInfo.node;
  3139. // Create the translation span
  3140. const translationSpan = document.createElement('span');
  3141. translationSpan.className = 'translated-text';
  3142. translationSpan.style.cssText = 'color: #333; font-style: normal;';
  3143. translationSpan.textContent = segment.translation;
  3144. // Replace original text with translation
  3145. if (originalNode && originalNode.parentNode) {
  3146. if (Config.getSetting('showSourceLanguage')) {
  3147. // Create original text span
  3148. const originalSpan = document.createElement('span');
  3149. originalSpan.className = 'original-text';
  3150. originalSpan.style.cssText = 'color: #999; font-style: italic; margin-right: 5px;';
  3151. originalSpan.textContent = originalNode.textContent;
  3152. // Insert original and translation
  3153. originalNode.parentNode.insertBefore(translationSpan, originalNode);
  3154. originalNode.parentNode.insertBefore(originalSpan, translationSpan);
  3155. } else {
  3156. // Just insert translation
  3157. originalNode.parentNode.insertBefore(translationSpan, originalNode);
  3158. }
  3159. // Hide original node
  3160. if (originalNode.style) {
  3161. originalNode.style.display = 'none';
  3162. }
  3163. }
  3164. });
  3165. }
  3166. };
  3167. // Helper function to replace text in an element while preserving structure
  3168. const replaceTextInElement = (element, translation) => {
  3169. const textNodes = [];
  3170. // Extract all text nodes from the element
  3171. const extractTextNodes = (node) => {
  3172. if (node.nodeType === Node.TEXT_NODE) {
  3173. if (node.textContent.trim()) {
  3174. textNodes.push(node);
  3175. }
  3176. } else if (node.nodeType === Node.ELEMENT_NODE) {
  3177. Array.from(node.childNodes).forEach(extractTextNodes);
  3178. }
  3179. };
  3180. extractTextNodes(element);
  3181. // If there's only one text node, directly replace it
  3182. if (textNodes.length === 1) {
  3183. textNodes[0].textContent = translation;
  3184. return;
  3185. }
  3186. // For multiple text nodes, distribute translation proportionally
  3187. const totalOriginalLength = textNodes.reduce(
  3188. (sum, node) => sum + node.textContent.trim().length, 0);
  3189. if (totalOriginalLength > 0) {
  3190. let startPos = 0;
  3191. for (let i = 0; i < textNodes.length; i++) {
  3192. const node = textNodes[i];
  3193. const nodeText = node.textContent.trim();
  3194. if (nodeText.length > 0) {
  3195. // Calculate ratio for this node
  3196. const ratio = nodeText.length / totalOriginalLength;
  3197. // Calculate text length for this node
  3198. const chunkLength = Math.round(translation.length * ratio);
  3199. // Extract portion of translation
  3200. let chunk;
  3201. if (i === textNodes.length - 1) {
  3202. // Last node gets remainder
  3203. chunk = translation.substring(startPos);
  3204. } else {
  3205. // Other nodes get proportional amount
  3206. chunk = translation.substring(startPos, startPos + chunkLength);
  3207. startPos += chunkLength;
  3208. }
  3209. // Update node text
  3210. node.textContent = chunk;
  3211. }
  3212. }
  3213. } else {
  3214. // Fallback: put all translation in first text node if found
  3215. if (textNodes.length > 0) {
  3216. textNodes[0].textContent = translation;
  3217. for (let i = 1; i < textNodes.length; i++) {
  3218. textNodes[i].textContent = '';
  3219. }
  3220. }
  3221. }
  3222. };
  3223. // Toggle to show translations (opposite of restoreOriginalText)
  3224. const showTranslation = (removeControls = false) => {
  3225. const segments = State.get('translationSegments');
  3226. if (!segments || segments.length === 0) {
  3227. return;
  3228. }
  3229. // Show each segment's translation
  3230. segments.forEach(segment => {
  3231. if (!segment.translation) return;
  3232. if (segment.isHeading || segment.isFormattedElement) {
  3233. // For headings and formatted elements
  3234. const firstNode = segment.nodes[0];
  3235. const element = firstNode.element;
  3236. if (element) {
  3237. const originalText = element.getAttribute('data-original-text');
  3238. if (Config.getSetting('showSourceLanguage')) {
  3239. // Style the original
  3240. element.style.color = '#999';
  3241. element.style.fontStyle = 'italic';
  3242. element.style.marginBottom = '5px';
  3243. // Find or create the translation element
  3244. let translationElement = element.nextSibling;
  3245. if (!translationElement || !translationElement.classList || !translationElement.classList.contains('translated-text')) {
  3246. // Translation element doesn't exist, create it
  3247. translationElement = document.createElement('div');
  3248. translationElement.className = 'translated-text';
  3249. translationElement.style.cssText = 'color: #333; font-style: normal;';
  3250. // Clone the element to preserve its structure but with translated text
  3251. const clonedElement = element.cloneNode(true);
  3252. // Replace all text nodes in the clone with translated text
  3253. replaceTextInElement(clonedElement, segment.translation);
  3254. translationElement.innerHTML = clonedElement.innerHTML;
  3255. element.parentNode.insertBefore(translationElement, element.nextSibling);
  3256. } else {
  3257. // Show existing translation
  3258. translationElement.style.display = '';
  3259. }
  3260. } else {
  3261. // Replace content even if originalText is not set yet
  3262. if (!originalText) {
  3263. // Store original content if not already stored
  3264. element.setAttribute('data-original-text', element.innerHTML);
  3265. }
  3266. // Replace all text nodes in the element with translated text
  3267. // This preserves HTML structure including links
  3268. replaceTextInElement(element, segment.translation);
  3269. }
  3270. }
  3271. } else {
  3272. // For regular text nodes
  3273. if (!segment.nodes) return;
  3274. segment.nodes.forEach(nodeInfo => {
  3275. if (!nodeInfo) return;
  3276. const originalNode = nodeInfo.node;
  3277. if (originalNode && originalNode.parentNode) {
  3278. // Show original node
  3279. if (originalNode.style) {
  3280. originalNode.style.display = '';
  3281. }
  3282. // Remove or hide translation elements
  3283. let sibling = originalNode.previousSibling;
  3284. while (sibling) {
  3285. const prevSibling = sibling.previousSibling;
  3286. if (sibling.classList &&
  3287. (sibling.classList.contains('translated-text') ||
  3288. sibling.classList.contains('original-text'))) {
  3289. if (removeControls && sibling.parentNode) {
  3290. sibling.parentNode.removeChild(sibling);
  3291. } else if (sibling.style) {
  3292. sibling.style.display = 'none';
  3293. }
  3294. }
  3295. sibling = prevSibling;
  3296. }
  3297. }
  3298. });
  3299. }
  3300. });
  3301. };
  3302. // Restore original text for a full page translation
  3303. const restoreOriginalText = (removeControls = false) => {
  3304. const segments = State.get('translationSegments');
  3305. if (!segments || segments.length === 0) {
  3306. return;
  3307. }
  3308. // Restore each segment
  3309. segments.forEach(segment => {
  3310. if (segment.isHeading || segment.isFormattedElement) {
  3311. // For headings and formatted elements
  3312. const firstNode = segment.nodes[0];
  3313. const element = firstNode.element;
  3314. if (element) {
  3315. // Restore original style
  3316. if (element.style) {
  3317. element.style.color = '';
  3318. element.style.fontStyle = '';
  3319. element.style.marginBottom = '';
  3320. }
  3321. // Restore original content if replaced
  3322. const originalText = element.getAttribute('data-original-text');
  3323. if (originalText) {
  3324. element.innerHTML = originalText;
  3325. element.removeAttribute('data-original-text');
  3326. }
  3327. // Hide translation element if it was added separately
  3328. if (Config.getSetting('showSourceLanguage')) {
  3329. const nextSibling = element.nextSibling;
  3330. if (nextSibling && nextSibling.className === 'translated-text' && nextSibling.style) {
  3331. nextSibling.style.display = 'none';
  3332. }
  3333. }
  3334. }
  3335. } else {
  3336. // For regular text nodes
  3337. if (!segment.nodes) return;
  3338. segment.nodes.forEach(nodeInfo => {
  3339. if (!nodeInfo) return;
  3340. const originalNode = nodeInfo.node;
  3341. if (originalNode && originalNode.parentNode) {
  3342. // Show original node
  3343. if (originalNode.style) {
  3344. originalNode.style.display = '';
  3345. }
  3346. // Remove or hide translation elements
  3347. let sibling = originalNode.previousSibling;
  3348. while (sibling) {
  3349. const prevSibling = sibling.previousSibling;
  3350. if (sibling.classList &&
  3351. (sibling.classList.contains('translated-text') ||
  3352. sibling.classList.contains('original-text'))) {
  3353. if (removeControls && sibling.parentNode) {
  3354. sibling.parentNode.removeChild(sibling);
  3355. } else if (sibling.style) {
  3356. sibling.style.display = 'none';
  3357. }
  3358. }
  3359. sibling = prevSibling;
  3360. }
  3361. }
  3362. });
  3363. }
  3364. });
  3365. // Update state
  3366. State.set('isShowingTranslation', false);
  3367. // Remove page controls if requested
  3368. if (removeControls) {
  3369. UI.components.pageControls.hide();
  3370. }
  3371. };
  3372. return {
  3373. init,
  3374. translateSelectedText,
  3375. translateFullPage,
  3376. translateNextSegment,
  3377. stopTranslation,
  3378. showTranslation,
  3379. restoreOriginalText,
  3380. applyTranslationToSegment,
  3381. historyManager,
  3382. favoritesManager,
  3383. cacheManager
  3384. };
  3385. })();
  3386.  
  3387. // Initialize the application
  3388. Core.init();
  3389. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址