Linux Do Translate

对回复进行翻译

  1. // ==UserScript==
  2. // @name Linux Do Translate
  3. // @namespace linux-do-translate
  4. // @version 0.2.4
  5. // @author delph1s
  6. // @license MIT
  7. // @description 对回复进行翻译
  8. // @match https://linux.do/t/topic/*
  9. // @connect *
  10. // @icon https://cdn.linux.do/uploads/default/original/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994.png
  11. // @grant unsafeWindow
  12. // @grant window.close
  13. // @grant window.focus
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant GM_xmlhttpRequest
  17. // @run-at document-end
  18. // ==/UserScript==
  19.  
  20. (function () {
  21. 'use strict';
  22. const REQUIRED_CHARS = 6;
  23. const SPACE_PRESS_COUNT = 3; // 连按次数
  24. const SPACE_PRESS_TIMEOUT = 1500; // 连续按键的最大时间间隔(毫秒)
  25. const TRANSLATE_PROVIDERS = [
  26. {
  27. text: 'LinuxDo Deeplx',
  28. value: 'deeplx-linuxdo',
  29. },
  30. {
  31. text: 'Deeplx',
  32. value: 'deeplx',
  33. },
  34. {
  35. text: 'Deepl',
  36. value: 'deepl',
  37. },
  38. {
  39. text: 'OpenAI',
  40. value: 'oai',
  41. },
  42. {
  43. text: 'OpenAI Proxy',
  44. value: 'oai-proxy',
  45. },
  46. ];
  47. const NOT_CUSTOM_URL_PROVIDERS = ['oai', 'deepl', 'deeplx-linuxdo'];
  48. const TRANSLATE_TARGET_LANG = {
  49. EN: { oai: 'English', deepl: 'EN' },
  50. ZH: { oai: 'Chinese', deepl: 'ZH' },
  51. };
  52. const TRANSLATE_TARGET_LANG_OPTIONS = [
  53. {
  54. text: 'English(英文)',
  55. value: 'EN',
  56. },
  57. {
  58. text: '中文(Chinese)',
  59. value: 'ZH',
  60. },
  61. ];
  62. const DEFAULT_CONFIG = {
  63. maxRetryTimes: 5,
  64. customUrl: '',
  65. authKey: '',
  66. enableTranslate: false,
  67. translateSourceLang: 'ZH',
  68. translateTargetLang: 'EN',
  69. translateProvider: 'deeplx-linuxdo',
  70. translateModel: 'gpt-4o',
  71. translateLayout: 'top',
  72. translateSize: 80,
  73. translateItalics: true,
  74. translateBold: false,
  75. translateReference: false,
  76. closeConfigAfterSave: true,
  77. };
  78.  
  79. const uiIDs = {
  80. replyControl: 'reply-control',
  81. configButton: 'multi-lang-say-config-button',
  82. configPanel: 'multi-lang-say-config-panel',
  83. customUrlInput: 'custom-url-input',
  84. authKeyInput: 'auth-key-input',
  85. enableTranslateSwitch: 'enable-translate-switch',
  86. translateSourceLangSelect: 'translate-source-lang-select',
  87. translateTargetLangSelect: 'translate-target-lang-select',
  88. translateProviderSelect: 'translate-provider-select',
  89. translateModelInput: 'translate-model-input',
  90. translateLayoutSelect: 'translate-layout-select',
  91. translateSizeInput: 'translate-size-input',
  92. translateItalicsSwitch: 'translate-italics-switch',
  93. translateBoldSwitch: 'translate-bold-switch',
  94. translateReferenceSwitch: 'translate-reference-switch',
  95. closeConfigAfterSaveSwitch: 'close-after-save-switch',
  96. };
  97.  
  98. let config = {
  99. maxRetryTimes: GM_getValue('maxRetryTimes', DEFAULT_CONFIG.maxRetryTimes),
  100. customUrl: GM_getValue('customUrl', DEFAULT_CONFIG.customUrl),
  101. authKey: GM_getValue('authKey', DEFAULT_CONFIG.authKey),
  102. enableTranslate: GM_getValue('enableTranslate', DEFAULT_CONFIG.enableTranslate),
  103. translateSourceLang: GM_getValue('translateSourceLang', DEFAULT_CONFIG.translateSourceLang),
  104. translateTargetLang: GM_getValue('translateTargetLang', DEFAULT_CONFIG.translateTargetLang),
  105. translateProvider: GM_getValue('translateProvider', DEFAULT_CONFIG.translateProvider),
  106. translateModel: GM_getValue('translateModel', DEFAULT_CONFIG.translateModel),
  107. translateLayout: GM_getValue('translateLayout', DEFAULT_CONFIG.translateLayout),
  108. translateSize: GM_getValue('translateSize', DEFAULT_CONFIG.translateSize),
  109. translateItalics: GM_getValue('translateItalics', DEFAULT_CONFIG.translateItalics),
  110. translateBold: GM_getValue('translateBold', DEFAULT_CONFIG.translateBold),
  111. translateReference: GM_getValue('translateReference', DEFAULT_CONFIG.translateReference),
  112. closeConfigAfterSave: GM_getValue('closeConfigAfterSave', DEFAULT_CONFIG.closeConfigAfterSave),
  113. };
  114.  
  115. const genFormatDateTime = d => {
  116. return d.toLocaleString('zh-CN', {
  117. year: 'numeric',
  118. month: '2-digit',
  119. day: '2-digit',
  120. hour: '2-digit',
  121. minute: '2-digit',
  122. second: '2-digit',
  123. hour12: false,
  124. });
  125. };
  126.  
  127. const genFormatNow = () => {
  128. return genFormatDateTime(new Date());
  129. };
  130.  
  131. /**
  132. * 获取随机整数
  133. *
  134. * @param {number} start 范围开始
  135. * @param {number} end 范围结束
  136. * @returns
  137. */
  138. const randInt = (start, end) => {
  139. return Math.floor(Math.random() * (end - start + 1)) + start;
  140. };
  141.  
  142. /**
  143. * 随机睡眠(毫秒)
  144. *
  145. * @param {number} start 范围开始
  146. * @param {number} end 范围结束
  147. */
  148. const randSleep = async (start = 2000, end = 3000) => {
  149. // 生成随机整数 randSleepTime,范围在 start 到 end 之间
  150. const randSleepTime = getRandomInt(start, end);
  151. // 睡眠时间
  152. return await new Promise(resolve => setTimeout(resolve, randSleepTime));
  153. };
  154.  
  155. /**
  156. * 是否相同
  157. *
  158. * @param a
  159. * @param b
  160. * @returns
  161. */
  162. const isEqual = (a, b) => {
  163. if (a === null || a === undefined || b === null || b === undefined) {
  164. return a === b;
  165. }
  166.  
  167. if (typeof a !== typeof b) {
  168. return false;
  169. }
  170.  
  171. if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') {
  172. return a === b;
  173. }
  174.  
  175. if (Array.isArray(a) && Array.isArray(b)) {
  176. if (a.length !== b.length) {
  177. return false;
  178. }
  179.  
  180. return a.every((item, index) => isEqual(item, b[index]));
  181. }
  182.  
  183. if (typeof a === 'object' && typeof b === 'object') {
  184. const keysA = Object.keys(a || {});
  185. const keysB = Object.keys(b || {});
  186.  
  187. if (keysA.length !== keysB.length) {
  188. return false;
  189. }
  190.  
  191. return keysA.every(key => isEqual(a[key], b[key]));
  192. }
  193.  
  194. return false;
  195. };
  196.  
  197. /**
  198. * 判断字符串中是否包含中文字符
  199. * @param {string} text
  200. * @returns {boolean}
  201. */
  202. const containsChinese = text => {
  203. return /[\u4e00-\u9fa5]/.test(text);
  204. };
  205.  
  206. const getInvertColor = hex => {
  207. // 去掉前面的“#”字符
  208. hex = hex.replace('#', '');
  209.  
  210. // 如果输入的是3位的hex值,转换为6位的
  211. if (hex.length === 3) {
  212. hex = hex
  213. .split('')
  214. .map(c => c + c)
  215. .join('');
  216. }
  217.  
  218. // 计算相反的颜色
  219. const r = (255 - parseInt(hex.slice(0, 2), 16)).toString(16).padStart(2, '0');
  220. const g = (255 - parseInt(hex.slice(2, 4), 16)).toString(16).padStart(2, '0');
  221. const b = (255 - parseInt(hex.slice(4, 6), 16)).toString(16).padStart(2, '0');
  222.  
  223. return `#${r}${g}${b}`;
  224. };
  225.  
  226. const deeplxReq = text => {
  227. return {
  228. url: config.authKey ? `${config.customUrl}?token=${config.authKey}` : config.customUrl,
  229. headers: {
  230. 'Content-Type': 'application/json',
  231. },
  232. data: JSON.stringify({
  233. text: text,
  234. target_lang: TRANSLATE_TARGET_LANG[config.translateTargetLang].deepl,
  235. source_lang: 'auto',
  236. }),
  237. responseType: 'json',
  238. };
  239. };
  240.  
  241. const deeplxLinuxdoReq = text => {
  242. return {
  243. url: `https://api.deeplx.org/${config.authKey}/translate`,
  244. headers: {
  245. 'Content-Type': 'application/json',
  246. },
  247. data: JSON.stringify({
  248. text: text,
  249. target_lang: TRANSLATE_TARGET_LANG[config.translateTargetLang].deepl,
  250. source_lang: 'auto',
  251. }),
  252. responseType: 'json',
  253. };
  254. };
  255.  
  256. const deeplReq = text => {
  257. const authKey = config.authKey;
  258. const params = new URLSearchParams();
  259. params.append('text', text);
  260. params.append('target_lang', TRANSLATE_TARGET_LANG[config.translateTargetLang].deepl);
  261. params.append('source_lang', TRANSLATE_TARGET_LANG[config.translateSourceLang].deepl);
  262. return {
  263. url: 'https://api.deepl.com/v2/translate', // DeepL Pro API
  264. headers: {
  265. Authorization: `DeepL-Auth-Key ${authKey}`,
  266. 'Content-Type': 'application/x-www-form-urlencoded',
  267. },
  268. data: params.toString(),
  269. responseType: 'json',
  270. };
  271. };
  272.  
  273. const deeplRes = res => {
  274. return res?.translations?.[0]?.text;
  275. };
  276.  
  277. const oaiReq = (
  278. text,
  279. model = 'gpt-3.5-turbo',
  280. url = 'https://api.openai.com/v1/chat/completions',
  281. temperature = 0.5,
  282. maxTokens = 32000
  283. ) => {
  284. const authKey = config.authKey;
  285. return {
  286. url: url,
  287. headers: {
  288. 'Content-Type': 'application/json',
  289. Authorization: `Bearer ${authKey}`,
  290. },
  291. data: JSON.stringify({
  292. model: model, // 或者您订阅的其他模型,例如 'gpt-4'
  293. messages: [
  294. {
  295. role: 'system',
  296. content:
  297. 'You are a highly skilled translation engine. Your function is to translate texts accurately into the target {{to}}, maintaining the original format, technical terms, and abbreviations. Do not add any explanations or annotations to the translated text.',
  298. },
  299. {
  300. role: 'user',
  301. content: `Translate the following source text to ${
  302. TRANSLATE_TARGET_LANG[config.translateTargetLang].oai
  303. }, Output translation directly without any additional text.\nSource Text: ${text}\nTranslated Text:`,
  304. },
  305. ],
  306. temperature: temperature, // 控制生成内容的随机性,范围是 0 到 1
  307. max_tokens: maxTokens, // 响应的最大标记数
  308. }),
  309. responseType: 'json',
  310. };
  311. };
  312.  
  313. const oaiRes = res => {
  314. return res.choices[0].message.content.trim();
  315. };
  316.  
  317. const translateText = text => {
  318. const isDeepl = config.translateProvider === 'deepl';
  319. const isOAI = config.translateProvider === 'oai' || config.translateProvider === 'oai-proxy';
  320.  
  321. let reqData;
  322.  
  323. if (!config.authKey) {
  324. if (!config.customUrl) return '';
  325. if (config.translateProvider === 'deeplx') {
  326. reqData = deeplxReq(text);
  327. } else {
  328. return '';
  329. }
  330. } else if (isDeepl) {
  331. reqData = deeplReq(text);
  332. } else if (isOAI) {
  333. reqData = oaiReq(
  334. text,
  335. config.translateModel,
  336. NOT_CUSTOM_URL_PROVIDERS.includes(config.translateProvider)
  337. ? 'https://api.openai.com/v1/chat/completions'
  338. : config.customUrl,
  339. 0.5,
  340. 1600
  341. );
  342. } else {
  343. reqData = deeplxLinuxdoReq(text);
  344. }
  345.  
  346. return new Promise((resolve, reject) => {
  347. GM_xmlhttpRequest({
  348. method: 'POST',
  349. url: reqData.url,
  350. headers: reqData.headers,
  351. data: reqData.data,
  352. responseType: reqData.responseType,
  353. onload: function (res) {
  354. console.log('Translation response:', res);
  355. console.log('Request details:', reqData);
  356.  
  357. if (res.status === 200) {
  358. try {
  359. const response = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
  360. console.log('Parsed response:', response);
  361.  
  362. let translation;
  363.  
  364. if (isDeepl) {
  365. // Pro API 返回格式
  366. translation = deeplRes(response);
  367. console.log('DeepL translation:', translation);
  368. } else if (isOAI) {
  369. translation = oaiRes(response);
  370. console.log('OAI translation:', translation);
  371. } else {
  372. translation = response?.data;
  373. console.log('DeepLX translation:', translation);
  374. }
  375.  
  376. resolve(translation || '');
  377. } catch (error) {
  378. console.error('Error parsing response:', error);
  379. resolve('');
  380. }
  381. } else {
  382. console.error('Translation failed:', {
  383. status: res.status,
  384. statusText: res.statusText,
  385. response: res.response,
  386. responseText: res.responseText,
  387. finalUrl: res.finalUrl,
  388. headers: res.responseHeaders,
  389. });
  390. resolve('');
  391. }
  392. },
  393. onerror: function (err) {
  394. console.error('Translation error details:', {
  395. error: err,
  396. errorText: err.toString(),
  397. status: err.status,
  398. statusText: err.statusText,
  399. responseText: err.responseText,
  400. });
  401. resolve('');
  402. },
  403. });
  404. });
  405. };
  406.  
  407. const processTranslateText = async text => {
  408. // 定义需要保护的块的正则表达式
  409. const protectedBlocks = [
  410. // Markdown 代码块
  411. {
  412. regex: /```[\s\S]*?```/g,
  413. type: 'code',
  414. },
  415. // BBCode 标签块 (处理嵌套标签)
  416. {
  417. regex: /\[(size|spoiler|center|color|grid).*?\][\s\S]*?\[\/\1\]/g,
  418. type: 'bbcode',
  419. },
  420. // 已有的 ruby 标签
  421. {
  422. regex: /<ruby>[\s\S]*?<\/ruby>/g,
  423. type: 'ruby',
  424. },
  425. // HTML 标签块
  426. {
  427. regex: /<[^>]+>[\s\S]*?<\/[^>]+>/g,
  428. type: 'html',
  429. },
  430. // 图片标签
  431. {
  432. regex: /!\[image\]\(.*?\)/g,
  433. type: 'image',
  434. },
  435. ];
  436.  
  437. // 创建占位符映射
  438. let placeholders = new Map();
  439. let placeholderCounter = 0;
  440.  
  441. // 保护特殊块
  442. let processedText = text;
  443. for (const block of protectedBlocks) {
  444. processedText = processedText.replace(block.regex, match => {
  445. const placeholder = `__PROTECTED_${block.type}_${placeholderCounter++}__`;
  446. placeholders.set(placeholder, match);
  447. return placeholder;
  448. });
  449. }
  450.  
  451. // 处理剩余文本
  452. const segments = processedText.split(/(\n)/);
  453. let translatedSegments = [];
  454.  
  455. for (const segment of segments) {
  456. if (!segment.trim() || segment === '\n') {
  457. translatedSegments.push(segment);
  458. continue;
  459. }
  460.  
  461. // 检查是否是占位符
  462. if (segment.startsWith('__PROTECTED_')) {
  463. translatedSegments.push(placeholders.get(segment));
  464. continue;
  465. }
  466.  
  467. // 翻译普通文本
  468. let segmentTranslate = await translateText(segment);
  469. if (segmentTranslate === '') {
  470. return segmentTranslate;
  471. }
  472.  
  473. if (config.translateItalics) {
  474. segmentTranslate = `[i]${segmentTranslate}[/i]`;
  475. }
  476.  
  477. if (config.translateBold) {
  478. segmentTranslate = `[b]${segmentTranslate}[/b]`;
  479. }
  480.  
  481. if (config.translateReference) {
  482. segmentTranslate = `> [size=${config.translateSize}]${segmentTranslate}[/size]`;
  483. } else {
  484. segmentTranslate = `[size=${config.translateSize}]${segmentTranslate}[/size]`;
  485. }
  486.  
  487. if (config.translateLayout === 'bottom') {
  488. translatedSegments.push(`${segment}\n${config.translateReference ? "\n" : ""}${segmentTranslate}`);
  489. } else if (config.translateLayout === 'top') {
  490. translatedSegments.push(
  491. `${segmentTranslate}\n${config.translateReference ? "\n" : ""}${segment}`
  492. );
  493. }
  494. }
  495.  
  496. // 合并结果
  497. return translatedSegments.join('');
  498. };
  499.  
  500. const processTextArea = () => {
  501. let textarea = document.querySelector(`#${uiIDs.replyControl} textarea`);
  502. let text = textarea.value.trim();
  503. let originalLength = text.length;
  504.  
  505. if (text.length !== 0 && originalLength >= REQUIRED_CHARS) {
  506. // 检查是否已存在拼音
  507. // const rubyRegex = /(<ruby>[\s\S]*?<\/ruby>)/g;
  508.  
  509. // 为中文加入翻译
  510. if (config.enableTranslate) {
  511. textarea.value = '开始翻译...';
  512.  
  513. processTranslateText(text).then(res => {
  514. textarea.value = res;
  515.  
  516. // 创建并触发 input 事件
  517. const inputEvent = new Event('input', {
  518. bubbles: true,
  519. cancelable: true,
  520. });
  521. // 触发事件
  522. textarea.dispatchEvent(inputEvent);
  523. });
  524. return;
  525. }
  526.  
  527. textarea.value = text;
  528.  
  529. // 创建并触发 input 事件
  530. const inputEvent = new Event('input', {
  531. bubbles: true,
  532. cancelable: true,
  533. });
  534. // 触发事件
  535. textarea.dispatchEvent(inputEvent);
  536. }
  537. };
  538.  
  539. const handleClick = event => {
  540. // 修复翻译两次的 BUG
  541. if (config.enableTranslate) {
  542. return;
  543. }
  544.  
  545. if (event.target && event.target.closest('button.create')) {
  546. processTextArea();
  547. }
  548. };
  549.  
  550. let spacePresses = 0;
  551. let lastKeyTime = 0;
  552. let timeoutHandle = null;
  553.  
  554. const handleKeydown = event => {
  555. // console.log(`KeyboardEvent: key='${event.key}' | code='${event.code}'`);
  556.  
  557. if (event.ctrlKey && event.key === 'Enter') {
  558. processTextArea();
  559. return;
  560. }
  561.  
  562. // 使用 Alt+D 触发翻译
  563. if (event.altKey && event.keyCode === 68) {
  564. event.preventDefault(); // 阻止默认行为
  565. processTextArea();
  566. return;
  567. }
  568.  
  569. const currentTime = Date.now();
  570. if (event.code === 'Space') {
  571. // 如果时间间隔太长,重置计数
  572. if (currentTime - lastKeyTime > SPACE_PRESS_TIMEOUT) {
  573. spacePresses = 1;
  574. } else {
  575. spacePresses += 1;
  576. }
  577.  
  578. lastKeyTime = currentTime;
  579.  
  580. // 清除之前的定时器
  581. if (timeoutHandle) {
  582. clearTimeout(timeoutHandle);
  583. }
  584.  
  585. // 设置新的定时器,如果在 SPACE_PRESS_TIMEOUT 毫秒内没有新的按键,则重置计数
  586. timeoutHandle = setTimeout(() => {
  587. spacePresses = 0;
  588. }, SPACE_PRESS_TIMEOUT);
  589.  
  590. // 检查是否达到了按键次数
  591. if (spacePresses === SPACE_PRESS_COUNT) {
  592. spacePresses = 0; // 重置计数
  593.  
  594. // 执行翻译操作
  595. processTextArea();
  596. }
  597. } else {
  598. // 如果按下了其他键,重置计数
  599. spacePresses = 0;
  600. if (timeoutHandle) {
  601. clearTimeout(timeoutHandle);
  602. timeoutHandle = null;
  603. }
  604. }
  605. };
  606.  
  607. const saveConfig = () => {
  608. const customUrlInput = document.getElementById(uiIDs.customUrlInput);
  609. config.customUrl = customUrlInput.value.trim();
  610. const authKeyInput = document.getElementById(uiIDs.authKeyInput);
  611. config.authKey = authKeyInput.value.trim();
  612. const translateModelInput = document.getElementById(uiIDs.translateModelInput);
  613. config.translateModel = translateModelInput.value;
  614. const transalteSizeInput = document.getElementById(uiIDs.translateSizeInput);
  615. config.translateSize = transalteSizeInput.value;
  616. console.log(config);
  617.  
  618. GM_setValue('customUrl', config.customUrl);
  619. GM_setValue('authKey', config.authKey);
  620. GM_setValue('enableTranslate', config.enableTranslate);
  621. GM_setValue('translateModel', config.translateModel);
  622. GM_setValue('translateSize', config.translateSize);
  623. GM_setValue('translateItalics', config.translateItalics);
  624. GM_setValue('translateBold', config.translateBold);
  625. GM_setValue('translateReference', config.translateReference);
  626. GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
  627.  
  628. if (config.closeConfigAfterSave) {
  629. const panel = document.getElementById(uiIDs.configPanel);
  630. toggleConfigPanelAnimation(panel);
  631. }
  632. };
  633.  
  634. const restoreDefaults = () => {
  635. if (confirm('确定要将所有设置恢复为默认值吗?')) {
  636. config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  637. GM_setValue('maxRetryTimes', config.maxRetryTimes);
  638. GM_setValue('customUrl', config.customUrl);
  639. GM_setValue('authKey', config.authKey);
  640. GM_setValue('enableTranslate', config.enableTranslate);
  641. GM_setValue('translateSourceLang', config.translateSourceLang);
  642. GM_setValue('translateTargetLang', config.translateTargetLang);
  643. GM_setValue('translateModel', config.translateModel);
  644. GM_setValue('translateLayout', config.translateLayout);
  645. GM_setValue('translateSize', config.translateSize);
  646. GM_setValue('translateItalics', config.translateItalics);
  647. GM_setValue('translateBold', config.translateBold);
  648. GM_setValue('translateReference', config.translateReference);
  649. GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
  650.  
  651. const panel = document.getElementById(uiIDs.configPanel);
  652. if (panel) {
  653. updateConfigPanelContent(panel);
  654. }
  655. }
  656. };
  657.  
  658. const createFormGroup = (labelText, element) => {
  659. const group = document.createElement('div');
  660. group.className = 'form-group';
  661.  
  662. const label = document.createElement('label');
  663. label.className = 'form-label';
  664. label.textContent = labelText;
  665.  
  666. group.appendChild(label);
  667. group.appendChild(element);
  668.  
  669. return group;
  670. };
  671.  
  672. const createSelect = (eleId, configId, options, defaultValue, onChange = undefined) => {
  673. const select = document.createElement('select');
  674. select.className = 'modern-select';
  675. select.id = eleId;
  676.  
  677. options.forEach(option => {
  678. const optionElement = document.createElement('option');
  679. optionElement.value = option.value;
  680. optionElement.textContent = option.text;
  681. select.appendChild(optionElement);
  682. });
  683.  
  684. select.value = defaultValue;
  685.  
  686. if (onChange !== undefined) {
  687. select.addEventListener('change', e => onChange(e));
  688. } else {
  689. select.addEventListener('change', e => {
  690. config[configId] = e.target.value;
  691. console.log(`[存储配置] ${configId}: ${config[configId]}`);
  692. GM_setValue(configId, config[configId]);
  693. });
  694. }
  695.  
  696. return select;
  697. };
  698.  
  699. const createInput = (eleId, value, type = 'text', placeholder = '') => {
  700. const input = document.createElement('input');
  701. input.className = 'modern-input';
  702. input.id = eleId;
  703. input.type = type;
  704. input.value = value;
  705. input.placeholder = placeholder;
  706. return input;
  707. };
  708.  
  709. const createSwitch = (eleId, configId, checked, labelText) => {
  710. const container = document.createElement('div');
  711. container.className = 'switch-container';
  712.  
  713. const label = document.createElement('span');
  714. label.className = 'form-label';
  715. label.style.margin = '0';
  716. label.textContent = labelText;
  717.  
  718. const switchEl = document.createElement('div');
  719. switchEl.id = eleId;
  720. switchEl.className = `modern-switch${checked ? ' active' : ''}`;
  721. switchEl.addEventListener('click', () => {
  722. switchEl.classList.toggle('active');
  723. config[configId] = switchEl.classList.contains('active');
  724. console.log(`[存储配置] ${configId}: ${config[configId]}`);
  725. GM_setValue(configId, config[configId]);
  726. });
  727.  
  728. container.appendChild(label);
  729. container.appendChild(switchEl);
  730. return container;
  731. };
  732.  
  733. const createButton = (text, onClick, variant = 'secondary') => {
  734. const button = document.createElement('button');
  735. button.className = `modern-button ${variant}`;
  736. button.textContent = text;
  737. button.addEventListener('click', onClick);
  738. return button;
  739. };
  740.  
  741. // const createTextArea = (id, value, labelText, placeholder) => {
  742. // const container = document.createElement('div');
  743. // container.style.marginBottom = '15px';
  744.  
  745. // const label = document.createElement('label');
  746. // label.textContent = labelText;
  747. // label.style.display = 'block';
  748. // label.style.marginBottom = '5px';
  749. // container.appendChild(label);
  750.  
  751. // const textarea = document.createElement('textarea');
  752. // textarea.id = id;
  753. // if (typeof value === 'string') {
  754. // textarea.value = value;
  755. // } else {
  756. // textarea.value = JSON.stringify(value, null, 2);
  757. // }
  758. // textarea.placeholder = placeholder;
  759. // textarea.rows = 5;
  760. // textarea.style.width = '100%';
  761. // textarea.style.padding = '5px';
  762. // textarea.style.border = '1px solid var(--panel-border)';
  763. // textarea.style.borderRadius = '4px';
  764. // textarea.style.backgroundColor = 'var(--panel-bg)';
  765. // textarea.style.color = 'var(--panel-text)';
  766. // container.appendChild(textarea);
  767.  
  768. // return [container, textarea];
  769. // };
  770.  
  771. const updateConfigPanelContent = (panel, panelContent) => {
  772. panelContent.innerHTML = '';
  773.  
  774. // 添加表单元素
  775. const translateProviderSelect = createSelect(
  776. uiIDs.translateProviderSelect,
  777. 'translateProvider',
  778. TRANSLATE_PROVIDERS,
  779. config.translateProvider,
  780. e => {
  781. config.translateProvider = e.target.value;
  782.  
  783. const notCustomUrl = NOT_CUSTOM_URL_PROVIDERS.includes(config.translateProvider);
  784. const urlInput = document.getElementById(uiIDs.customUrlInput);
  785. if (notCustomUrl) {
  786. if (urlInput) {
  787. urlInput.disabled = true;
  788. }
  789. } else {
  790. if (urlInput) {
  791. urlInput.disabled = false;
  792. }
  793. }
  794. console.log(`[存储配置] translateProvider: ${config.translateProvider}`);
  795.  
  796. GM_setValue('translateProvider', config.translateProvider);
  797. }
  798. );
  799. panelContent.appendChild(createFormGroup('翻译服务商(Provider)', translateProviderSelect));
  800.  
  801. const customUrlInput = createInput(uiIDs.customUrlInput, config.customUrl, 'text', '填写自定义请求地址');
  802. const notCustomUrl = NOT_CUSTOM_URL_PROVIDERS.includes(config.translateProvider);
  803. if (notCustomUrl) {
  804. customUrlInput.disabled = true;
  805. }
  806. panelContent.appendChild(createFormGroup('自定义链接(Custom URL)', customUrlInput));
  807.  
  808. const authKeyInput = createInput(uiIDs.authKeyInput, config.authKey, 'password', '输入认证密钥');
  809. panelContent.appendChild(createFormGroup('认证密钥(Auth Key)', authKeyInput));
  810.  
  811. const modelInput = createInput(uiIDs.translateModelInput, config.translateModel, 'text', '输入翻译模型');
  812. panelContent.appendChild(createFormGroup('翻译模型(AI Model)', modelInput));
  813.  
  814. const targetSourceSelect = createSelect(
  815. uiIDs.translateSourceLangSelect,
  816. 'translateSourceLang',
  817. TRANSLATE_TARGET_LANG_OPTIONS,
  818. config.translateSourceLang
  819. );
  820. const targetLangSelect = createSelect(
  821. uiIDs.translateTargetLangSelect,
  822. 'translateTargetLang',
  823. TRANSLATE_TARGET_LANG_OPTIONS,
  824. config.translateTargetLang
  825. );
  826. panelContent.appendChild(createFormGroup('源语言(Source Language)', targetSourceSelect));
  827. panelContent.appendChild(createFormGroup('目标语言(Target Language)', targetLangSelect));
  828.  
  829. const sizeInput = createInput(
  830. uiIDs.translateSizeInput,
  831. config.translateSize,
  832. 'number',
  833. '默认值为150(字体大小为原始的150%)'
  834. );
  835. panelContent.appendChild(createFormGroup('翻译字体大小(百分比)', sizeInput));
  836.  
  837. const layoutSelect = createSelect(
  838. uiIDs.translateLayoutSelect,
  839. 'translateLayout',
  840. [
  841. { text: '翻译在上(Translation On Top)', value: 'top' },
  842. { text: '翻译在下(Translation On Bottom)', value: 'bottom' },
  843. ],
  844. config.translateLayout
  845. );
  846. panelContent.appendChild(createFormGroup('翻译布局(Layout)', layoutSelect));
  847. TRANSLATE_TARGET_LANG_OPTIONS;
  848. // 添加开关
  849. panelContent.appendChild(
  850. createSwitch(uiIDs.enableTranslateSwitch, 'enableTranslate', config.enableTranslate, '启用翻译(Enable Translate)')
  851. );
  852. panelContent.appendChild(
  853. createSwitch(uiIDs.translateItalicsSwitch, 'translateItalics', config.translateItalics, '启用斜体(Enable Italic)')
  854. );
  855. panelContent.appendChild(
  856. createSwitch(uiIDs.translateBoldSwitch, 'translateBold', config.translateBold, '启用粗体(Enable Bold)')
  857. );
  858. panelContent.appendChild(
  859. createSwitch(uiIDs.translateReferenceSwitch, 'translateReference', config.translateReference, '转为引用(Convert to Quote)')
  860. );
  861.  
  862. panelContent.appendChild(
  863. createSwitch(
  864. uiIDs.closeConfigAfterSaveSwitch,
  865. 'closeConfigAfterSave',
  866. config.closeConfigAfterSave,
  867. '保存后自动关闭(Close Panel After Save)'
  868. )
  869. );
  870.  
  871. // 创建按钮组
  872. const buttonGroup = document.createElement('div');
  873. buttonGroup.className = 'button-group';
  874.  
  875. buttonGroup.appendChild(createButton('恢复默认', restoreDefaults));
  876. buttonGroup.appendChild(createButton('保存设置', saveConfig, 'primary'));
  877. buttonGroup.appendChild(
  878. createButton(
  879. '翻译(Translate)',
  880. processTextArea, // Call translate function directly
  881. 'primary'
  882. )
  883. );
  884. buttonGroup.appendChild(
  885. createButton(
  886. '关闭',
  887. () => {
  888. toggleConfigPanelAnimation(panel);
  889. },
  890. 'ghost'
  891. )
  892. );
  893.  
  894. panelContent.appendChild(buttonGroup);
  895. };
  896.  
  897. const createConfigPanel = () => {
  898. // 获取页面的 <meta name="theme-color"> 标签
  899. const themeColorMeta = document.querySelector('meta[name="theme-color"]');
  900. let themeColor = '#DDDDDD'; // 默认白色背景
  901. let invertedColor = '#222222'; // 默认黑色字体
  902.  
  903. if (themeColorMeta) {
  904. themeColor = themeColorMeta.getAttribute('content');
  905. invertedColor = getInvertColor(themeColor); // 计算相反颜色
  906. }
  907.  
  908. // 设置样式变量
  909. const style = document.createElement('style');
  910. style.textContent = `
  911. :root {
  912. --panel-bg: ${themeColor};
  913. --panel-text: ${invertedColor};
  914. --panel-border: ${invertedColor};
  915. --button-bg: ${invertedColor};
  916. --button-text: ${themeColor};
  917. --button-hover-bg: ${getInvertColor(invertedColor)};
  918. --button-hover-text: ${getInvertColor(themeColor)};
  919. }
  920.  
  921. .modern-panel {
  922. position: fixed;
  923. top: 80px;
  924. right: 20px;
  925. width: 360px;
  926. background: color-mix(in srgb, var(--panel-bg) 85%, transparent);
  927. backdrop-filter: blur(12px);
  928. -webkit-backdrop-filter: blur(12px);
  929. border-radius: 16px;
  930. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
  931. z-index: 10000;
  932. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  933. border: 1px solid color-mix(in srgb, var(--panel-border) 30%, transparent);
  934. overflow: hidden;
  935. opacity: 0;
  936. transform: translateY(-10px);
  937. transition: all 0.3s ease;
  938. color: var(--panel-text);
  939. }
  940.  
  941. .modern-panel.show {
  942. opacity: 1;
  943. transform: translateY(0);
  944. }
  945.  
  946. .modern-panel-header {
  947. padding: 20px 24px;
  948. border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 10%, transparent);
  949. display: flex;
  950. justify-content: space-between;
  951. align-items: center;
  952. }
  953.  
  954. .modern-panel-title {
  955. font-size: 18px;
  956. font-weight: 600;
  957. color: var(--panel-text);
  958. margin: 0;
  959. }
  960.  
  961. .modern-panel-content {
  962. padding: 24px;
  963. max-height: calc(80vh - 140px);
  964. overflow-y: auto;
  965. }
  966.  
  967. .modern-panel-content::-webkit-scrollbar {
  968. width: 6px;
  969. }
  970.  
  971. .modern-panel-content::-webkit-scrollbar-thumb {
  972. background: color-mix(in srgb, var(--panel-border) 20%, transparent);
  973. border-radius: 3px;
  974. }
  975.  
  976. .form-group {
  977. margin-bottom: 20px;
  978. }
  979.  
  980. .form-label {
  981. display: block;
  982. font-size: 14px;
  983. font-weight: 500;
  984. color: var(--panel-text);
  985. margin-bottom: 8px;
  986. }
  987.  
  988. .modern-select {
  989. width: 100%;
  990. padding: 10px 12px;
  991. border: 1px solid color-mix(in srgb, var(--panel-border) 20%, transparent);
  992. border-radius: 8px;
  993. background: color-mix(in srgb, var(--panel-bg) 90%, transparent);
  994. color: var(--panel-text);
  995. font-size: 14px;
  996. transition: all 0.2s ease;
  997. appearance: none;
  998. background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  999. background-repeat: no-repeat;
  1000. background-position: right 12px center;
  1001. cursor: pointer;
  1002. }
  1003.  
  1004. .modern-select:hover {
  1005. border-color: color-mix(in srgb, var(--panel-border) 40%, transparent);
  1006. }
  1007.  
  1008. .modern-select:focus {
  1009. outline: none;
  1010. border-color: var(--button-bg);
  1011. box-shadow: 0 0 0 3px color-mix(in srgb, var(--button-bg) 10%, transparent);
  1012. }
  1013.  
  1014. .modern-input {
  1015. width: 100%;
  1016. padding: 10px 12px;
  1017. border: 1px solid color-mix(in srgb, var(--panel-border) 20%, transparent);
  1018. border-radius: 8px;
  1019. background: color-mix(in srgb, var(--panel-bg) 90%, transparent);
  1020. color: var(--panel-text);
  1021. font-size: 14px;
  1022. transition: all 0.2s ease;
  1023. }
  1024.  
  1025. .modern-input:hover {
  1026. border-color: color-mix(in srgb, var(--panel-border) 40%, transparent);
  1027. }
  1028.  
  1029. .modern-input:focus {
  1030. outline: none;
  1031. border-color: var(--button-bg);
  1032. box-shadow: 0 0 0 3px color-mix(in srgb, var(--button-bg) 10%, transparent);
  1033. }
  1034.  
  1035. .switch-container {
  1036. display: flex;
  1037. justify-content: space-between;
  1038. align-items: center;
  1039. margin-bottom: 16px;
  1040. }
  1041.  
  1042. .modern-switch {
  1043. position: relative;
  1044. width: 44px;
  1045. height: 24px;
  1046. background: color-mix(in srgb, var(--panel-border) 20%, transparent);
  1047. border-radius: 12px;
  1048. padding: 2px;
  1049. transition: background 0.3s ease;
  1050. cursor: pointer;
  1051. }
  1052.  
  1053. .modern-switch.active {
  1054. background: var(--button-bg);
  1055. }
  1056.  
  1057. .modern-switch::after {
  1058. content: '';
  1059. position: absolute;
  1060. width: 20px;
  1061. height: 20px;
  1062. border-radius: 10px;
  1063. background: var(--panel-bg);
  1064. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  1065. transition: transform 0.3s ease;
  1066. transform: translateY(1px) translateX(2px);
  1067. }
  1068.  
  1069. .modern-switch.active::after {
  1070. transform: translateY(1px) translateX(22px);
  1071. }
  1072.  
  1073. .button-group {
  1074. display: flex;
  1075. gap: 12px;
  1076. margin-top: 24px;
  1077. padding-top: 20px;
  1078. border-top: 1px solid color-mix(in srgb, var(--panel-border) 10%, transparent);
  1079. }
  1080.  
  1081. .modern-button {
  1082. flex: 1;
  1083. padding: 2px 8px;
  1084. border-radius: 8px;
  1085. font-size: 14px;
  1086. font-weight: 500;
  1087. cursor: pointer;
  1088. transition: all 0.2s ease;
  1089. border: none;
  1090. display: flex;
  1091. align-items: center;
  1092. justify-content: center;
  1093. gap: 6px;
  1094. }
  1095.  
  1096. .modern-button.primary {
  1097. background: var(--button-bg);
  1098. color: var(--button-text);
  1099. }
  1100.  
  1101. .modern-button.primary:hover {
  1102. background: var(--button-hover-bg);
  1103. color: var(--button-hover-text);
  1104. }
  1105.  
  1106. .modern-button.secondary {
  1107. background: color-mix(in srgb, var(--panel-border) 10%, transparent);
  1108. color: var(--panel-text);
  1109. }
  1110.  
  1111. .modern-button.secondary:hover {
  1112. background: color-mix(in srgb, var(--panel-border) 20%, transparent);
  1113. }
  1114.  
  1115. .modern-button.ghost {
  1116. background: transparent;
  1117. color: var(--panel-text);
  1118. }
  1119.  
  1120. .modern-button.ghost:hover {
  1121. background: color-mix(in srgb, var(--panel-border) 10%, transparent);
  1122. }
  1123. }`;
  1124. document.head.appendChild(style);
  1125.  
  1126. const panel = document.createElement('div');
  1127. panel.id = uiIDs.configPanel;
  1128. panel.className = 'modern-panel';
  1129. panel.style.display = 'none';
  1130.  
  1131. // 创建头部
  1132. const header = document.createElement('div');
  1133. header.className = 'modern-panel-header';
  1134.  
  1135. const title = document.createElement('h3');
  1136. title.className = 'modern-panel-title';
  1137. title.textContent = '设置';
  1138. header.appendChild(title);
  1139.  
  1140. // 创建内容区域
  1141. const content = document.createElement('div');
  1142. content.className = 'modern-panel-content';
  1143.  
  1144. console.log();
  1145. updateConfigPanelContent(panel, content);
  1146.  
  1147. panel.appendChild(header);
  1148. panel.appendChild(content);
  1149. document.body.appendChild(panel);
  1150.  
  1151. return panel;
  1152. };
  1153.  
  1154. const toggleConfigPanelAnimation = panel => {
  1155. if (panel.style.display === 'none') {
  1156. panel.classList.add('show');
  1157. setTimeout(() => {
  1158. panel.style.display = 'block';
  1159. }, 10);
  1160. } else {
  1161. panel.classList.remove('show');
  1162. setTimeout(() => {
  1163. panel.style.display = 'none';
  1164. }, 300);
  1165. }
  1166. };
  1167.  
  1168. const toggleConfigPanel = () => {
  1169. let panel = document.getElementById(uiIDs.configPanel);
  1170. panel = panel || createConfigPanel();
  1171. toggleConfigPanelAnimation(panel);
  1172. };
  1173.  
  1174. const createConfigButton = () => {
  1175. const toolbar = document.querySelector('.d-editor-button-bar');
  1176. if (!toolbar || document.getElementById(uiIDs.configButton)) return;
  1177.  
  1178. const configButton = document.createElement('button');
  1179. configButton.id = uiIDs.configButton;
  1180. configButton.className = 'btn btn-flat btn-icon no-text user-menu-tab active';
  1181. configButton.title = '配置';
  1182. configButton.innerHTML =
  1183. '<svg class="fa d-icon d-icon-discourse-other-tab svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#discourse-other-tab"></use></svg>';
  1184. configButton.onclick = toggleConfigPanel;
  1185.  
  1186. toolbar.appendChild(configButton);
  1187. };
  1188.  
  1189. const watchReplyControl = () => {
  1190. const replyControl = document.getElementById(uiIDs.replyControl);
  1191. if (!replyControl) return;
  1192.  
  1193. const observer = new MutationObserver(mutations => {
  1194. mutations.forEach(mutation => {
  1195. if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
  1196. if (replyControl.classList.contains('closed')) {
  1197. const panel = document.getElementById(uiIDs.configPanel);
  1198. if (panel) {
  1199. panel.style.display = 'none';
  1200. }
  1201. } else {
  1202. // 当 reply-control 重新打开时,尝试添加配置按钮
  1203. setTimeout(createConfigButton, 500); // 给予一些时间让编辑器完全加载
  1204. }
  1205. }
  1206. });
  1207. });
  1208.  
  1209. observer.observe(replyControl, { attributes: true });
  1210. };
  1211.  
  1212. const watchForEditor = () => {
  1213. const observer = new MutationObserver(mutations => {
  1214. mutations.forEach(mutation => {
  1215. if (mutation.type === 'childList') {
  1216. const addedNodes = mutation.addedNodes;
  1217. for (let node of addedNodes) {
  1218. if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('d-editor')) {
  1219. createConfigButton();
  1220. return;
  1221. }
  1222. }
  1223. }
  1224. });
  1225. });
  1226.  
  1227. observer.observe(document.body, { childList: true, subtree: true });
  1228. };
  1229.  
  1230. const init = () => {
  1231. const container = document.getElementById(uiIDs.replyControl);
  1232. container.addEventListener('click', handleClick, true);
  1233. document.addEventListener('keydown', handleKeydown, true);
  1234. if (!document.getElementById(uiIDs.configButton)) {
  1235. createConfigButton();
  1236. }
  1237. watchReplyControl();
  1238. watchForEditor();
  1239. };
  1240.  
  1241. // 初始化
  1242. setTimeout(() => {
  1243. init();
  1244. }, 1000);
  1245. })();