Linux Do Translate

对回复进行翻译

当前为 2024-11-20 提交的版本,查看 最新版本

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