-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Expand file tree
/
Copy pathmain.user.js
More file actions
1633 lines (1446 loc) · 59 KB
/
Copy pathmain.user.js
File metadata and controls
1633 lines (1446 loc) · 59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ==UserScript==
// @name GitHub 中文化插件
// @namespace https://github.com/maboloshi/github-chinese
// @description 中文化 GitHub 界面的部分菜单及内容。原作者为楼教主(http://www.52cik.com/)。
// @copyright 2021, 沙漠之子 (https://maboloshi.github.io/Blog)
// @icon https://github.githubassets.com/pinned-octocat.svg
// @version 1.9.4.4-2026-06-21
// @author 沙漠之子
// @license GPL-3.0
// @match https://github.com/*
// @match https://skills.github.com/*
// @match https://gist.github.com/*
// @match https://education.github.com/*
// @match https://www.githubstatus.com/*
// @require https://raw.githubusercontent.com/maboloshi/github-chinese/gh-pages/locals.js?v1.9.4.4-2026-06-21
// @run-at document-start
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_notification
// @connect fanyi.iflyrec.com
// @supportURL https://github.com/maboloshi/github-chinese/issues
// ==/UserScript==
(function (window, document, undefined) {
'use strict';
/* =========================== 全局配置常量 =========================== */
const CONFIG = {
LANG: 'zh-CN', // 默认语言
DEV: false, // 默认不开启开发者模式
PAGE_MAP: { // 站点域名 -> 类型映射
'gist.github.com': 'gist',
'www.githubstatus.com': 'status',
'skills.github.com': 'skills',
'education.github.com': 'education'
},
SPECIAL_SITES: ['gist', 'status', 'skills', 'education'], // 特殊站点类型
DESC_SELECTORS: { // 简介元素的CSS选择器
repository: ".f4.tmp-my-3",
gist: ".gist-content [itemprop='about']"
},
OBSERVER_CONFIG: { // MutationObserver配置
childList: true,
subtree: true,
characterData: true,
attributeFilter: ['value', 'placeholder', 'aria-label', 'data-confirm']
},
TRANS_ENGINES: { // 翻译引擎配置
iflyrec: {
name: '讯飞听见',
url: 'https://fanyi.iflyrec.com/text-translate',
url_api: 'https://fanyi.iflyrec.com/TJHZTranslationService/v2/textAutoTranslation',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://fanyi.iflyrec.com'
},
getRequestData: (text) => ({
from: 2, // 英语
to: 1, // 简体中文
type: 1,
contents: [{ text: text }]
}),
responseIdentifier: 'biz[0]?.sectionResult[0]?.dst', // 翻译结果在响应中的路径
},
},
STYLES: `
/* 基础样式变量 */
:root {
--ghc-primary-color: #1b95e0;
--ghc-bg-color: #f8f9fa;
--ghc-border-color: #e1e4e8;
--ghc-button-bg: #f6f8fa;
}
/* 浅色主题样式(默认) */
.translate-button {
color: var(--ghc-primary-color);
font-size: small;
cursor: pointer;
margin-top: 5px;
display: inline-block;
}
.translation-result {
margin-top: 10px;
padding: 8px;
border: 1px solid var(--ghc-border-color);
background-color: var(--ghc-button-bg);
border-radius: 6px;
}
.translation-credit {
font-size: small;
color: var(--ghc-primary-color);
}
.translation-content {
margin-top: 5px;
white-space: pre-wrap;
}
/* 暗色主题适配 - 使用 prefers-color-scheme */
@media (prefers-color-scheme: dark) {
:root {
--ghc-primary-color: #58a6ff;
--ghc-bg-color: #0d1117;
--ghc-border-color: #30363d;
--ghc-button-bg: #21262d;
}
}
`
};
/* =========================== 状态管理器 =========================== */
const State = {
// 功能开关
featureSet: {
enable_RegExp: GM_getValue("enable_RegExp", true),
enable_transDesc: GM_getValue("enable_transDesc", true),
enable_missedTerms: GM_getValue("enable_missedTerms", false),
enable_onurlchange: false,
},
// 当前运行时状态
pageConfig: null, // 当前页面配置(null 表示无有效页面)
currentURL: window.location.href, // 当前页面URL
transEngine: 'iflyrec', // 当前翻译引擎
mutationObserver: null, // DOM变化观察器
urlChangeHandler: null, // 存储URL变化处理器
dynamicMenus: {}, // 动态菜单ID记录
initDone: false,
};
/* =========================== 安全检查 =========================== */
/**
* 检查词库文件是否加载 — 未加载则抛出错误阻止继续执行
*/
function checkI18NLoaded() {
if (typeof I18N === 'undefined') {
alert('GitHub 汉化插件:词库文件 locals.js 未加载,脚本无法运行!');
throw new Error('[GitHub 中文化插件] 词库文件 locals.js 未加载');
}
}
/**
* 错误边界 — 包装函数,捕获异常避免阻断页面正常使用
* @param {Function} fn - 要执行的函数
* @param {string} label - 错误标签
* @returns {Function} 包装后的函数
*/
function safe(fn, label) {
return function (...args) {
try {
return fn.apply(this, args);
} catch (e) {
console.error(`[GitHub 中文化插件] ${label} 出错:`, e);
}
};
}
/* =========================== 初始化入口 =========================== */
function init() {
checkI18NLoaded();
setupReactGlobalNavTranslation();
initLangEnv();
injectStyles();
setupMenuCommands();
setupInitTrans();
setupUrlChangeListener();
setupTurboEvents();
State.initDone = true;
}
/**
* 初始化并保护中文语言环境
*/
function initLangEnv() {
// 设置初始语言
document.documentElement.lang = CONFIG.LANG;
// 监视语言属性变化,防止被改回英文
const langObserver = new MutationObserver(() => {
// 如果检测到语言被改回英文,重新设置
if (document.documentElement.lang === "en") {
document.documentElement.lang = CONFIG.LANG;
}
});
langObserver.observe(document.documentElement, { attributeFilter: ['lang'] });
}
/**
* 注入自定义样式到页面
*/
function injectStyles() {
GM_addStyle(CONFIG.STYLES);
}
/**
* 设置初始翻译
*
* 即使 @run-at document-start,Tampermonkey 注入脚本也可能晚于 DOMContentLoaded
*(扩展冷启动、bfcache 恢复等场景)。因此不能假设注册监听器时事件尚未触发:
* readyState 已是 interactive/complete 则直接执行,否则才注册一次性监听器。
*/
function setupInitTrans() {
function doInitTrans() {
updatePageConfig('首次载入');
if (State.pageConfig) {
safe(traverseNode, '首次遍历')(document.body);
}
setupMutationObserver(); // 设置DOM变化观察器
}
if (document.readyState === 'interactive' || document.readyState === 'complete') {
// 文档已就绪,直接执行
doInitTrans();
} else {
// 等待 DOMContentLoaded
window.addEventListener('DOMContentLoaded', doInitTrans, { once: true });
}
}
/* =========================== URL 变化监听 =========================== */
/**
* 设置URL变化监听器
* Tampermonkey 环境使用 onurlchange 事件,其他环境回退到 MutationObserver URL 检测
*/
function setupUrlChangeListener() {
// Tampermonkey 环境下 window.onurlchange 为 null(支持),其他环境为 undefined
if (State.featureSet.enable_onurlchange && window.onurlchange === null) {
// 创建URL变化处理函数
State.urlChangeHandler = function (event) {
console.log("URL变化检测 (Tampermonkey onurlchange)", event);
handleUrlChange();
};
window.addEventListener('urlchange', State.urlChangeHandler);
console.log("🛠️ 开发者模式:已启用 onurlchange 事件监听");
} else {
console.log("当前环境不支持 onurlchange 事件,使用传统URL检测方式");
}
}
/**
* 处理URL变化
*/
function handleUrlChange() {
const currentURL = window.location.href;
// 如果URL没有实际变化,则跳过处理
if (currentURL === State.currentURL) return;
State.currentURL = currentURL;
updatePageConfig("URL变化 (onurlchange)");
// 重新设置观察器
if (State.mutationObserver) {
State.mutationObserver.disconnect();
}
// 如果页面类型有效,重新遍历DOM
if (State.pageConfig) {
safe(traverseNode, 'URL变化遍历')(document.body);
}
setupMutationObserver();
}
/* =========================== Turbo 事件 =========================== */
/**
* 设置Turbo框架事件监听
* 处理GitHub的Turbolinks页面切换
*/
function setupTurboEvents() {
document.addEventListener('turbo:load', handleTurboLoad);
}
/**
* 处理Turbo页面加载事件
* 在新页面加载后执行必要的翻译
*/
function handleTurboLoad() {
if (!State.pageConfig) return;
transTitle(); // 翻译页面标题
transBySelector(); // 通过选择器翻译特定元素
// 如果描述翻译功能启用,翻译页面描述
if (State.featureSet.enable_transDesc &&
CONFIG.DESC_SELECTORS[State.pageConfig.currentPageType]) {
transDesc(CONFIG.DESC_SELECTORS[State.pageConfig.currentPageType]);
}
}
/* =========================== 页面配置管理 =========================== */
/**
* 更新页面配置 — 页面类型变化时重建 State.pageConfig
* @param {string} trigger - 触发更新的原因(用于调试)
*/
function updatePageConfig(trigger) {
const newType = detectPageType();
if (!newType) {
State.pageConfig = null;
} else if (newType !== State.pageConfig?.currentPageType) {
State.pageConfig = buildPageConfig(newType);
}
console.log(`【Debug】${trigger}触发, 页面类型为 ${State.pageConfig?.currentPageType}`);
}
/**
* 构建页面配置对象
* @param {string} pageType - 页面类型
* @returns {Object} 页面配置对象
*/
function buildPageConfig(pageType) {
return {
currentPageType: pageType, // 当前页面类型
currentPath: window.location.pathname, // 当前路径
titleStaticDict: I18N[CONFIG.LANG][pageType]?.title?.static || {},
titleRegexpRules: I18N[CONFIG.LANG][pageType]?.title?.regexp || [],
staticDict: { // 合并公共和页面特定的静态词典
...I18N[CONFIG.LANG].public.static,
...(I18N[CONFIG.LANG][pageType]?.static || {})
},
regexpRules: [ // 合并公共和页面特定的正则规则
...(I18N[CONFIG.LANG][pageType]?.regexp || []),
...(I18N[CONFIG.LANG].public.regexp || [])
],
ignoreMutationSelectors: [ // 忽略的突变选择器
...(I18N.conf.ignoreMutationSelectorPage['*'] || []),
...(I18N.conf.ignoreMutationSelectorPage[pageType] || [])
].join(', '),
ignoreSelectors: [ // 忽略的选择器
...(I18N.conf.ignoreSelectorPage['*'] || []),
...(I18N.conf.ignoreSelectorPage[pageType] || [])
].join(', '),
characterData: (I18N.conf.characterDataPage || []).includes(pageType), // 是否监视文本节点变化
transSelectors: [ // 翻译选择器规则
...(I18N[CONFIG.LANG].public.selector || []),
...(I18N[CONFIG.LANG][pageType]?.selector || [])
],
};
}
/* =========================== 页面类型检测 =========================== */
/**
* 检测当前页面类型
* @returns {string|boolean} 页面类型或false(如果未识别)
*/
function detectPageType() {
const url = new URL(window.location.href);
const { PAGE_MAP, SPECIAL_SITES } = CONFIG;
const { hostname, pathname } = url;
// 基础配置
const site = PAGE_MAP[hostname] || 'github'; // 通过站点映射获取基础类型
const isLogin = document.body.classList.contains("logged-in");
const metaLocation = document.head.querySelector('meta[name="analytics-location"]')?.content || '';
// 页面特征检测
const isSession = document.body.classList.contains("session-authentication");
const isHomepage = pathname === '/' && site === 'github';
const isProfile = document.body.classList.contains("page-profile") || metaLocation === '/<user-name>';
const isRepository = /\/<user-name>\/<repo-name>/.test(metaLocation);
const isOrganization = /\/<org-login>/.test(metaLocation) || /^\/(?:orgs|organizations)/.test(pathname);
let pageType;
// 根据页面特征确定页面类型
switch (true) { // 使用 switch(true) 模式处理多条件分支
case isSession: // 登录/认证页面
pageType = 'session-authentication';
break;
case SPECIAL_SITES.includes(site): // 特殊站点
pageType = site;
break;
case isProfile: { // 用户资料页面
const tabParam = new URLSearchParams(url.search).get('tab');
pageType = pathname.includes('/stars') ? 'page-profile/stars'
: tabParam ? `page-profile/${tabParam}`
: 'page-profile';
break;
}
case isHomepage: // 首页/仪表盘
pageType = isLogin ? 'dashboard' : 'homepage';
break;
case isRepository: { // 代码仓库页面
const repoMatch = pathname.match(I18N.conf.rePagePathRepo);
pageType = repoMatch ? `repository/${repoMatch[1]}` : 'repository';
break;
}
case isOrganization: { // 组织页面
const orgMatch = pathname.match(I18N.conf.rePagePathOrg);
pageType = orgMatch ? `orgs/${orgMatch[1] || orgMatch.slice(-1)[0]}` : 'orgs';
break;
}
default: { // 默认页面类型
const pathMatch = pathname.match(I18N.conf.rePagePath);
pageType = pathMatch ? (pathMatch[1] || pathMatch.slice(-1)[0]) : false;
}
}
// 验证页面类型是否有效
if (pageType === false || !I18N[CONFIG.LANG]?.[pageType]) {
const reason = pageType === false
? '路径未匹配任何页面规则'
: `词库中缺少 "${pageType}" 的翻译`;
console.warn('[i18n] %s', reason, {
url: window.location.href,
hostname,
pathname,
site,
pageType,
isLogin,
metaLocation
});
return false;
}
return pageType;
}
/* =========================== React 新版头部翻译补丁 =================== */
/**
* 模块:React 新版头部翻译补丁
* 说明:针对 GitHub 新版 React 头部(GlobalNav)及其弹层,
* 通过 DOM 操作 + 精细的时机控制来翻译文本,
* 避免与 React 渲染发生冲突。
* 作者:MasterBao66
* 日期:2026-06-17
*/
function isReactGlobalNavPortalNode(node) {
const element = node?.nodeType === 1 ? node : node?.parentElement;
const portalRoot = element?.closest?.('#__primerPortalRoot__');
if (!portalRoot) return false;
const portal = element.closest?.('[data-component="Portal"]')
|| element.querySelector?.('[data-component="Portal"]')
|| portalRoot;
if (portal.matches?.('#search-suggestions-dialog')
|| portal.querySelector?.('#search-suggestions-dialog')) return true;
const referenceAttributes = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns'];
const referenceElements = [
portal,
...portal.querySelectorAll?.(
referenceAttributes.map(attribute => `[${attribute}]`).join(', ')
) || [],
];
for (const referenceElement of referenceElements) {
for (const attribute of referenceAttributes) {
const ids = referenceElement.getAttribute?.(attribute)?.split(/\s+/) || [];
if (ids.some(id => document.getElementById(id)?.closest?.('header.GlobalNav'))) {
return true;
}
}
}
const portalIds = new Set([
portal.id,
...Array.from(portal.querySelectorAll?.('[id]') || [], item => item.id),
].filter(Boolean));
if (portalIds.size) {
const headerReferences = document.querySelectorAll(
'header.GlobalNav [aria-describedby], header.GlobalNav [aria-controls], header.GlobalNav [aria-owns]'
);
for (const headerReference of headerReferences) {
for (const attribute of ['aria-describedby', 'aria-controls', 'aria-owns']) {
const ids = headerReference.getAttribute(attribute)?.split(/\s+/) || [];
if (ids.some(id => portalIds.has(id))) return true;
}
}
}
const hasControlledSurface = portal.matches?.('[role="menu"], [role="dialog"], [role="tooltip"]')
|| portal.querySelector?.('[role="menu"], [role="dialog"], [role="tooltip"]');
return !!hasControlledSurface
&& !!document.activeElement?.closest?.('header.GlobalNav, qbsearch-input');
}
function setupReactGlobalNavTranslation() {
// ----- 环境检查 -----
if (typeof document === 'undefined' || typeof window === 'undefined') return;
// ----- 词库(从 I18N 读取)-----
const labels = I18N.conf.reactGlobalNavLabels || {};
// ----- 选择器定义 -----
const dataContentLabelSelector = 'header.GlobalNav [data-component="text"][data-content]';
// 需要监听的 React 渲染区域:头部和弹层
const controlledSurfaceSelector = [
'header.GlobalNav',
'#__primerPortalRoot__ [role="menu"]',
'#__primerPortalRoot__ [role="dialog"]',
'#__primerPortalRoot__ [role="tooltip"]',
].join(', ');
// 仅弹层(用于单独判断更新状态)
const portalSurfaceSelector = '#__primerPortalRoot__ [role="menu"], #__primerPortalRoot__ [role="dialog"], #__primerPortalRoot__ [role="tooltip"]';
// 旧版搜索框(兼容)
const searchSurfaceSelector = 'qbsearch-input';
// 新版搜索模块(类名包含 Search-module__)
const searchModuleSelector = 'header.GlobalNav [class*="Search-module__"]';
// 不翻译的标签(避免破坏代码、图片等)
const unsafeTextSelector = [
'textarea',
'[contenteditable="true"]',
'code',
'pre',
'kbd',
'svg',
'img',
'canvas',
'video',
].join(', ');
// 搜索相关区域(用于判断焦点状态)
const searchSelector = `${searchModuleSelector}, ${searchSurfaceSelector}, #__primerPortalRoot__ [role="dialog"]`;
// 需要翻译的属性列表
const translatableAttributeNames = ['title', 'aria-label', 'data-visible-text', 'placeholder'];
// ----- 时间控制参数 -----
const reactGlobalNavIdleMs = 700; // 判断渲染空闲的等待时间(毫秒)
const reactGlobalNavRetryMs = 400; // 重试间隔
// ----- 状态变量 -----
let timer = null; // 延时执行句柄
let headerObserver = null; // MutationObserver 实例
let lastReactGlobalNavMutationAt = Date.now(); // 头部最后变化时间
let lastReactGlobalNavPortalMutationAt = Date.now(); // 弹层最后变化时间
const observedSurfaces = new WeakSet(); // 已监听的 DOM 元素(避免重复绑定)
// ----- 状态查询函数 -----
/**
* 判断当前是否处于搜索激活状态(输入框有焦点或弹层打开)
*/
function isReactGlobalNavSearchActive() {
const active = document.activeElement;
return !!active?.closest?.(searchSelector)
|| !!document.querySelector('#__primerPortalRoot__ [role="dialog"]');
}
/**
* 判断指定区域是否已经处于空闲状态(无变化超过 IDLE_MS)
* @param {string} surfaceType - 'header' 或 'portal'
*/
function isReactGlobalNavSurfaceIdle(surfaceType = 'header') {
const lastMutationAt = surfaceType === 'portal'
? lastReactGlobalNavPortalMutationAt
: lastReactGlobalNavMutationAt;
return Date.now() - lastMutationAt >= reactGlobalNavIdleMs;
}
/**
* 判断是否可以进行头部翻译
* 条件:页面完全加载、头部空闲、搜索未激活
*/
function canTranslateReactGlobalNavHeader() {
return document.readyState === 'complete'
&& isReactGlobalNavSurfaceIdle('header')
&& !isReactGlobalNavSearchActive();
}
// ----- 词库查找函数(与现有 I18N 集成) -----
/**
* 从 I18N 的静态词典中查找翻译
* @param {string} source - 原文
* @returns {string|null}
*/
function findStaticGlobalNavLabel(source) {
const locale = I18N["zh-CN"] || I18N.zh;
if (!locale) return null;
for (const section of Object.values(locale)) {
const label = section?.static?.[source];
if (typeof label === 'string' && label && label !== source) {
return label;
}
}
return null;
}
/**
* 从 I18N 的正则规则中查找翻译
*/
function findRegexpGlobalNavLabel(source) {
const locale = I18N["zh-CN"] || I18N.zh;
if (!locale) return null;
for (const section of Object.values(locale)) {
for (const [pattern, replacement] of section?.regexp || []) {
const match = source.match(pattern);
if (!match || match.index !== 0 || match[0] !== source) continue;
const label = source.replace(pattern, replacement);
if (label !== source) return label;
}
}
return null;
}
/**
* 解析翻译:优先硬编码标签 -> 静态词库 -> 正则词库
*/
function resolveReactGlobalNavLabel(source) {
return labels[source] || findStaticGlobalNavLabel(source) || findRegexpGlobalNavLabel(source);
}
// ----- 文本处理辅助函数 -----
function normalizeReactGlobalNavText(text) {
return text?.replace(/\s+/g, ' ').trim();
}
function translateReactGlobalNavText(text) {
const source = normalizeReactGlobalNavText(text);
return source ? resolveReactGlobalNavLabel(source) : null;
}
// ----- 翻译执行函数 -----
/**
* 翻译单个元素的文本内容(直接修改 textContent)
*/
function translateReactGlobalNavElement(element, source) {
const label = translateReactGlobalNavText(source ?? element.textContent);
if (label && element.textContent !== label) {
element.textContent = label;
}
}
/**
* 判断节点是否应该被跳过(不翻译)
*/
function shouldSkipReactGlobalNavNode(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (!element) return true;
if (element.closest?.(unsafeTextSelector)) return true;
if (element.closest?.(searchModuleSelector)) return true;
if (element.closest?.(searchSurfaceSelector)) return true;
return false;
}
/**
* 翻译元素的可翻译属性(title、aria-label 等)
*/
function translateReactGlobalNavAttributes(element) {
translatableAttributeNames.forEach(attributeName => {
const value = element.getAttribute?.(attributeName);
const label = translateReactGlobalNavText(value);
if (label && value !== label) {
element.setAttribute(attributeName, label);
}
});
}
/**
* 翻译文本节点
*/
function translateReactGlobalNavTextNode(node) {
const label = translateReactGlobalNavText(node.data);
if (label) {
// 替换原有文本(保留前后空白)
node.data = node.data.replace(node.data.trim(), label);
}
}
/**
* 遍历并翻译整个 Surface(区域)
* 使用 TreeWalker 遍历所有元素和文本节点
*/
function translateReactGlobalNavSurface(surface) {
if (!surface || shouldSkipReactGlobalNavNode(surface)) return;
if (surface.nodeType === Node.ELEMENT_NODE) {
translateReactGlobalNavAttributes(surface);
}
const walker = document.createTreeWalker(
surface,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
return shouldSkipReactGlobalNavNode(node)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
}
}
);
let node;
while ((node = walker.nextNode())) {
if (node.nodeType === Node.ELEMENT_NODE) {
translateReactGlobalNavAttributes(node);
} else if (node.nodeType === Node.TEXT_NODE) {
translateReactGlobalNavTextNode(node);
}
}
}
// ----- 主翻译入口 -----
/**
* 翻译头部(header.GlobalNav)
* @returns {boolean} 是否成功翻译(头部存在且空闲)
*/
function translateReactGlobalNavHeader() {
const header = document.querySelector('header.GlobalNav');
if (!header) return true; // 不存在时认为已处理
if (!canTranslateReactGlobalNavHeader()) return false;
// 优先翻译包含 data-content 的元素(React 组件通过此属性存储原始文案)
document.querySelectorAll(dataContentLabelSelector).forEach(element => {
if (!shouldSkipReactGlobalNavNode(element)) {
translateReactGlobalNavElement(element, element.getAttribute('data-content'));
}
});
// 翻译整个头部区域
translateReactGlobalNavSurface(header);
return true;
}
function isReactGlobalNavSearchPortal(surface) {
return surface.matches?.('[role="dialog"]')
|| !!surface.querySelector?.('#search-suggestions-dialog, qbsearch-input, [role="dialog"]');
}
/**
* 翻译弹层(portal)
* @returns {boolean} 是否成功翻译(弹层存在且空闲)
*/
function translateReactGlobalNavPortals() {
const surfaces = Array.from(document.querySelectorAll(portalSurfaceSelector))
.filter(isReactGlobalNavPortalNode);
if (!surfaces.length) return true;
let searchPortalPending = false;
surfaces.forEach(surface => {
if (isReactGlobalNavSearchPortal(surface) && !isReactGlobalNavSurfaceIdle('portal')) {
searchPortalPending = true;
return;
}
translateReactGlobalNavSurface(surface);
});
return !searchPortalPending;
}
/**
* 总翻译入口,被调度函数调用
* @param {object} options - { requireSettledHeader: true/false }
*/
function translateReactGlobalNavLabels(options = { requireSettledHeader: true }) {
observeReactGlobalNav(); // 确保观察器已启动
const headerTranslated = translateReactGlobalNavHeader();
const portalsTranslated = translateReactGlobalNavPortals();
// 如果未完成(头部未就绪或弹层未空闲),则安排重试
if ((options.requireSettledHeader && !headerTranslated) || !portalsTranslated) {
scheduleReactGlobalNavTranslation(reactGlobalNavRetryMs, options);
}
}
// ----- 调度函数 -----
/**
* 延迟调度翻译
*/
function scheduleReactGlobalNavTranslation(delay = 800, options = {}) {
window.clearTimeout(timer);
timer = window.setTimeout(() => translateReactGlobalNavLabels(options), delay);
}
/**
* 初始启动序列:在多个时间点尝试翻译,覆盖 React 异步渲染
*/
function scheduleReactGlobalNavSeries() {
[800, 1600, 3000].forEach(delay => {
window.setTimeout(translateReactGlobalNavLabels, delay);
});
}
// ----- MutationObserver 与状态记录 -----
/**
* 记录 DOM 变化的时间戳(区分头部和弹层)
*/
function recordReactGlobalNavMutation(surface) {
if (surface?.id === '__primerPortalRoot__' || surface?.closest?.('#__primerPortalRoot__')) {
lastReactGlobalNavPortalMutationAt = Date.now();
return;
}
lastReactGlobalNavMutationAt = Date.now();
}
/**
* 设置 MutationObserver,监听头部和弹层的变化
*/
function observeReactGlobalNav() {
if (!headerObserver) {
headerObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => recordReactGlobalNavMutation(mutation.target));
translateReactGlobalNavPortals();
// 变化后延迟重试翻译
scheduleReactGlobalNavTranslation(reactGlobalNavRetryMs, { requireSettledHeader: true });
});
}
[
document.querySelector('header.GlobalNav'),
document.querySelector('#__primerPortalRoot__'),
].forEach(surface => {
if (!surface || observedSurfaces.has(surface)) return;
observedSurfaces.add(surface);
recordReactGlobalNavMutation(surface);
headerObserver.observe(surface, {
childList: true,
subtree: true,
characterData: true,
});
});
}
function startReactGlobalNavTranslation() {
observeReactGlobalNav();
scheduleReactGlobalNavSeries();
}
// ----- 初始化入口 -----
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startReactGlobalNavTranslation, { once: true });
} else {
startReactGlobalNavTranslation();
}
// 监听 Turbo 导航和 URL 变化
window.addEventListener('turbo:load', scheduleReactGlobalNavSeries);
window.addEventListener('urlchange', scheduleReactGlobalNavSeries);
// 监听用户交互事件,交互后可能触发 React 更新,延迟重试翻译
['click', 'focusin', 'focusout', 'pointerover'].forEach(evt => {
document.addEventListener(evt, () => scheduleReactGlobalNavTranslation(reactGlobalNavRetryMs, { requireSettledHeader: true }), true);
});
}
/* =========================== MutationObserver =========================== */
/**
* 设置DOM变化观察器
* 监听页面变化并触发翻译
*/
function setupMutationObserver() {
// 缓存当前页面的 URL
let previousURL = window.location.href;
if (State.mutationObserver) {
State.mutationObserver.disconnect();
}
State.mutationObserver = new MutationObserver(
safe((mutations) => {
const currentURL = window.location.href;
// 当没有 onurlchange 支持时,通过 Observer 检测 URL 变化
if (!State.urlChangeHandler && currentURL !== previousURL) {
previousURL = currentURL;
State.currentURL = currentURL;
updatePageConfig("URL变化 (MutationObserver)");
}
// 处理DOM变化
if (State.pageConfig) {
processMutations(mutations);
}
}, 'MutationObserver')
);
// 开始观察页面主体
State.mutationObserver.observe(document.body, CONFIG.OBSERVER_CONFIG);
}
/**
* 处理MutationObserver检测到的变化
* 收集突变节点、过滤忽略选择器、对祖先-后代关系去重,仅遍历顶层节点
* @param {Array} mutations - 变化记录数组
*/
function shouldIgnoreMutationNode(node) {
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
if (!element) return true;
const ignoredSelectors = State.pageConfig?.ignoreMutationSelectors;
if (ignoredSelectors && element.closest?.(ignoredSelectors)) return true;
return isReactGlobalNavPortalNode(element);
}
function processMutations(mutations) {
const nodesToProcess = new Set();
// 收集需要处理的节点
mutations.forEach(({ target, addedNodes, type }) => {
if (type === 'childList' && addedNodes.length > 0) {
// 处理新增节点
addedNodes.forEach(node => {
if (!shouldIgnoreMutationNode(node)) {
nodesToProcess.add(node);
}
});
} else if (type === 'attributes') {
// 处理属性变化,target 就是元素
if (!shouldIgnoreMutationNode(target)) {
nodesToProcess.add(target);
}
} else if (type === 'characterData' && State.pageConfig.characterData) {
// 处理文本变化,target 是文本节点,取其父元素
if (!shouldIgnoreMutationNode(target)) {
nodesToProcess.add(target);
}
}
});
// 过滤掉祖先已在集合中的后代节点,避免重复遍历
const topNodes = new Set();
nodesToProcess.forEach(node => {
let ancestor = node.parentElement;
let hasAncestor = false;
while (ancestor) {
if (nodesToProcess.has(ancestor)) {
hasAncestor = true;
return;
}
ancestor = ancestor.parentElement;
}
if (!hasAncestor) {
topNodes.add(node);
}
});
if (CONFIG.DEV) console.log("DOM变化(已过滤)", topNodes);
// 仅遍历顶层节点
topNodes.forEach(node => {
traverseNode(node);
});
}
/* =========================== DOM 遍历与节点处理 =========================== */
/**
* 遍历节点树并进行翻译
* @param {Node} rootNode - 要遍历的根节点
*/
function traverseNode(rootNode) {
const start = performance.now();
// 文本节点直接处理
if (rootNode.nodeType === Node.TEXT_NODE) {
handleTextNode(rootNode);
return;
}
// 创建TreeWalker遍历节点树
const treeWalker = document.createTreeWalker(
rootNode,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
node => {
if (node.nodeType === Node.ELEMENT_NODE
&& State.pageConfig.ignoreSelectors
&& node.matches(State.pageConfig.ignoreSelectors)) {
return NodeFilter.FILTER_REJECT; // 跳过忽略的选择器
}
return NodeFilter.FILTER_ACCEPT; // 接受其他节点
}
);
let currentNode;
// 遍历所有节点
while ((currentNode = treeWalker.nextNode())) {
if (currentNode.nodeType === Node.ELEMENT_NODE) {
handleElementNode(currentNode);
} else if (currentNode.nodeType === Node.TEXT_NODE) {
handleTextNode(currentNode);
}
}
// 性能监控
const duration = performance.now() - start;
if (duration > 10) {
console.log(`节点遍历耗时: ${duration.toFixed(2)}ms`);
}
}
/**
* 处理文本节点
* @param {Node} node - 文本节点