Naposledy aktivní 1 month ago

在GitLab仓库设置页面一键填充镜像仓库信息

Revize d4e98fdf511970718ee3e8f78a53ba4aa42c5bb3

gitea.user.js Raw
1// ==UserScript==
2// @name Gitea 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.2.1
5// @description 在Gitea仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接
6// @author Jetsung Chan <[email protected]>
7// @match https://git.idev.top/*/*/settings
8// @match https://git.asfd.cn/*/*/settings
9// @match https://codeberg.org/*/*/settings
10// @grant GM_getValue
11// @grant GM_setValue
12// @grant GM_deleteValue
13// @grant GM_addStyle
14// @grant GM_registerMenuCommand
15// @grant GM_unregisterMenuCommand
16// @grant GM_notification
17// @grant GM_xmlhttpRequest
18// @connect openapi-rdc.aliyuncs.com
19// @connect codeup.aliyun.com
20// @connect api.github.com
21// @connect codeberg.org
22// @connect gitcode.com
23// @connect framagit.org
24// @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js
25// @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js
26// ==/UserScript==
27
28(function() {
29 'use strict';
30
31 // 平台配置
32 const platforms = {
33 github: {
34 name: 'GitHub',
35 url: 'https://github.com/',
36 tokenKey: 'github_token',
37 usernameKey: 'github_username',
38 domain: 'github.com'
39 },
40 gitcode: {
41 name: 'GitCode',
42 url: 'https://gitcode.com/',
43 tokenKey: 'gitcode_token',
44 usernameKey: 'gitcode_username',
45 domain: 'gitcode.com'
46 },
47 codeup: {
48 name: 'CodeUp',
49 url: 'https://codeup.aliyun.com/jetsung/',
50 tokenKey: 'codeup_token',
51 usernameKey: 'codeup_username',
52 domain: 'codeup.aliyun.com'
53 },
54 codeberg: {
55 name: 'Codeberg',
56 url: 'https://codeberg.org/',
57 tokenKey: 'codeberg_token',
58 usernameKey: 'codeberg_username',
59 domain: 'codeberg.org',
60 type: 'gitea'
61 },
62 framagit: {
63 name: 'Framagit',
64 url: 'https://framagit.org/',
65 tokenKey: 'framagit_token',
66 usernameKey: 'framagit_username',
67 domain: 'framagit.org',
68 type: 'gitlab'
69 }
70 };
71
72 const description = document.getElementById('description').value;
73
74 // 添加样式
75 GM_addStyle(`
76 .mirror-helper-container {
77 position: fixed;
78 top: 20px;
79 right: 20px;
80 z-index: 9999;
81 background: white;
82 border: 1px solid #ddd;
83 border-radius: 8px;
84 box-shadow: 0 4px 12px rgba(0,0,0,0.15);
85 padding: 20px;
86 width: 270px;
87 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
88 }
89
90 .mirror-helper-header {
91 font-size: 18px;
92 font-weight: 600;
93 margin-bottom: 15px;
94 color: #333;
95 border-bottom: 1px solid #eee;
96 padding-bottom: 10px;
97 }
98
99 .mirror-helper-section {
100 margin-bottom: 15px;
101 }
102
103 .mirror-helper-section h4 {
104 font-size: 14px;
105 margin: 0 0 8px 0;
106 color: #555;
107 }
108
109 .mirror-helper-input {
110 width: 100%;
111 padding: 8px 10px;
112 margin-bottom: 8px;
113 border: 1px solid #ddd;
114 border-radius: 4px;
115 font-size: 14px;
116 box-sizing: border-box;
117 }
118
119 .mirror-helper-button {
120 background: #1f75cb;
121 color: white;
122 border: none;
123 border-radius: 4px;
124 padding: 8px 16px;
125 font-size: 14px;
126 cursor: pointer;
127 margin-right: 8px;
128 margin-bottom: 8px;
129 transition: background 0.2s;
130 }
131
132 .mirror-helper-button:hover {
133 background: #1b5faa;
134 }
135
136 .mirror-helper-button.secondary {
137 background: #e6e6e6;
138 color: #333;
139 }
140
141 .mirror-helper-button.secondary:hover {
142 background: #d0d0d0;
143 }
144
145 .mirror-helper-select {
146 width: 100%;
147 padding: 8px 10px;
148 margin-bottom: 12px;
149 border: 1px solid #ddd;
150 border-radius: 4px;
151 font-size: 14px;
152 box-sizing: border-box;
153 }
154
155 .mirror-helper-footer {
156 margin-top: 15px;
157 padding-top: 10px;
158 border-top: 1px solid #eee;
159 font-size: 12px;
160 color: #777;
161 }
162
163 .mirror-helper-close {
164 position: absolute;
165 top: 10px;
166 right: 10px;
167 background: none;
168 border: none;
169 font-size: 18px;
170 cursor: pointer;
171 color: #999;
172 }
173
174 .mirror-helper-close:hover {
175 color: #333;
176 }
177
178 /* 目标仓库列样式 */
179 .target-repo-column {
180 max-width: 200px;
181 }
182
183 .target-repo-link {
184 color: #0366d6;
185 text-decoration: none;
186 font-weight: 500;
187 display: inline-block;
188 padding: 2px 4px;
189 border-radius: 3px;
190 transition: background-color 0.2s;
191 }
192
193 .target-repo-link:hover {
194 background-color: rgba(3, 102, 214, 0.1);
195 text-decoration: none;
196 }
197
198 .target-repo-link svg {
199 width: 14px;
200 height: 14px;
201 margin-right: 4px;
202 vertical-align: middle;
203 }
204
205 .no-target-link {
206 color: #666;
207 font-style: italic;
208 }
209
210 .create-repo-td { white-space: nowrap; }
211 .create-repo-btn {
212 background: #28a745; color: #fff; border: none; border-radius: 4px;
213 padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s;
214 }
215 .create-repo-btn:hover { background: #218838; }
216 .create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; }
217 .create-repo-btn.processing { background: #fd7e14 !important; }
218 .create-repo-btn.success { background: #1e7e34 !important; }
219 .create-repo-btn.error { background: #dc3545 !important; }
220 `);
221
222 // 解析镜像URL获取目标仓库信息
223 function parseMirrorUrl(mirrorUrl) {
224 try {
225 let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://')
226 .replace(/\.git$/, '');
227
228 // console.log(cleanUrl);
229 for (const [key, platform] of Object.entries(platforms)) {
230 if (cleanUrl.includes(platform.domain)) {
231 return {
232 platform: platform.name,
233 url: cleanUrl,
234 key: key
235 };
236 }
237 }
238 } catch (e) {
239 console.error('解析镜像URL失败:', mirrorUrl, e);
240 }
241 return null;
242 }
243
244 // 在推送镜像表格的操作列中添加「创建仓库」按钮
245 function addTargetRepoColumn() {
246 // 匹配推送镜像的表格
247 const table = document.querySelector('table.ui.table');
248 if (!table) return;
249
250 const tbody = table.querySelector('tbody');
251 if (!tbody) return;
252
253 // 获取所有行,过滤掉最后的“添加镜像”表单行
254 const rows = tbody.querySelectorAll('tr');
255 rows.forEach(row => {
256 // 如果行内包含 form 或者 colspan="4",说明是添加镜像的输入框行,跳过
257 if (row.querySelector('form[action*="push-mirror-add"]') || row.querySelector('td[colspan]')) return;
258
259 // 查找操作列(右对齐的那个 td)
260 const actionTd = row.querySelector('td.tw-text-right');
261 if (!actionTd || actionTd.querySelector('.create-repo-btn')) return;
262
263 // 获取当前行的仓库 URL
264 const urlTd = row.querySelector('td.tw-break-anywhere');
265 if (!urlTd) return;
266
267 const mirrorUrl = urlTd.textContent.trim();
268 const info = parseMirrorUrl(mirrorUrl);
269
270 // 创建「创建仓库」按钮
271 const btn = document.createElement('button');
272 btn.className = 'create-repo-btn ui primary tiny button';
273 btn.style.marginRight = '4px'; // 留点间距
274 btn.innerHTML = `<svg viewBox="0 0 16 16" class="svg octicon-plus" width="14" height="14" style="vertical-align: middle; margin-right: 2px;"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"></path></svg>`;
275 btn.title = `创建仓库`;
276
277 // console.log(info);
278
279 btn.onclick = async () => {
280 const key = info.key;
281 const p = platforms[key];
282 const username = GM_getValue(p.usernameKey, '');
283 const token = GM_getValue(p.tokenKey, '');
284
285 if (!username || !token) {
286 alert(`${p.name} 未配置账号信息`);
287 return;
288 }
289
290 btn.disabled = true;
291 btn.textContent = '创建中...';
292 btn.classList.add('processing');
293
294 try {
295 // === 终极解析:支持多级子组 ===
296 const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
297 const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
298 if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
299 const repoName = parts.pop(); // 最后一段:仓库名
300 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
301
302 if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
303
304 let resp, data;
305
306 // === 统一创建接口 ===
307 if (key === 'github') {
308 let reqPath = 'user/repos'
309 if (username !== ownerPath) {
310 reqPath = `orgs/${ownerPath}/repos`
311 }
312 resp = await fetch('https://api.github.com/' + reqPath, {
313 method: 'POST',
314 headers: {
315 'Authorization': `token ${token}`,
316 'Content-Type': 'application/json',
317 'Accept': 'application/vnd.github.v3+json'
318 },
319 body: JSON.stringify({
320 name: repoName,
321 private: false,
322 auto_init: false,
323 description: description,
324 })
325 });
326 } else if (p.type === 'gitea' || key === 'codeberg') {
327 let reqPath = 'api/v1/user/repos'
328 if (username !== ownerPath) {
329 reqPath = `api/v1/orgs/${ownerPath}/repos`
330 }
331 resp = await fetch(p.url + reqPath, {
332 method: 'POST',
333 headers: {
334 'Authorization': `token ${token}`,
335 'Content-Type': 'application/json'
336 },
337 body: JSON.stringify({
338 name: repoName,
339 private: false,
340 auto_init: false,
341 description:description,
342 })
343 });
344 } else if (p.type === 'gitlab' || key === 'framagit') {
345 let reqPath = 'api/v4//projects'
346 if (username !== ownerPath) {
347 // https://docs.gitlab.com/api/projects/#create-a-project
348 // reqPath += `/user/${user_id}`
349 reqPath = `api/v4/groups/${encodeURIComponent(ownerPath)}`
350 }
351 resp = await fetch(p.url + reqPath, {
352 method: 'POST',
353 headers: {
354 'Authorization': `PRIVATE-TOKEN: ${token}`,
355 'Content-Type': 'application/json'
356 },
357 body: JSON.stringify({
358 name: repoName,
359 visibility: 'private',
360 description: description,
361 })
362 });
363 } else if (key === 'gitcode') {
364 let reqPath = 'api/v5/user/repos'
365 if (username !== ownerPath) {
366 reqPath = `api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`
367 }
368 resp = await fetch(`https://api.gitcode.com/` + reqPath, {
369 method: 'POST',
370 headers: {
371 'Authorization': `Bearer ${token}`,
372 'Content-Type': 'application/json'
373 },
374 body: JSON.stringify({
375 name: repoName,
376 path: repoName,
377 public: 1,
378 auto_init: false,
379 default_branch: 'main',
380 description: description,
381 })
382 });
383 } else if (key === 'codeup') {
384 const domain = 'https://openapi-rdc.aliyuncs.com';
385 const referer = 'https://codeup.aliyun.com';
386
387 // Step 1: 获取组织列表
388 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
389 method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
390 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
391 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
392 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
393 }));
394
395 // Step 2: 获取群组空间
396 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
397 if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
398
399 const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
400 method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
401 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
402 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
403 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
404 }));
405
406 // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
407 const targetNs = namespaces.find(ns => {
408 const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
409 const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
410 return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
411 });
412
413 if (!targetNs) {
414 const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
415 throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
416 }
417
418 // console.log('匹配群组成功:', targetNs);
419 // console.log(JSON.stringify({
420 // name: repoName,
421 // path: repoName,
422 // namespaceId: targetNs.id,
423 // readMeType: 'NONE'
424 // }));
425
426 // Step 4: 正确创建仓库(使用 organizationId + namespaceId)
427 await new Promise((resolve, reject) => {
428 GM_xmlhttpRequest({
429 method: 'POST',
430 url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
431 headers: {
432 'x-yunxiao-token': token,
433 'Content-Type': 'application/json',
434 'Referer': referer,
435 'Origin': referer
436 },
437 data: JSON.stringify({
438 name: repoName,
439 path: repoName,
440 namespaceId: targetNs.id,
441 }),
442 onload: r => {
443 const data = r.responseText ? JSON.parse(r.responseText) : {};
444 if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
445 console.log('CodeUp 仓库创建成功:', data);
446 resolve(data);
447 } else {
448 reject(new Error(data.message || data.error || `创建失败(${r.status}`));
449 }
450 },
451 onerror: () => reject(new Error('网络错误')),
452 ontimeout: () => reject(new Error('请求超时'))
453 });
454 });
455
456 // 模拟成功响应
457 resp = { ok: true, status: 200 };
458 data = { id: 'success' };
459 }
460
461 // === 统一成功判断 ===
462 if (
463 resp.status >= 200 && resp.status < 400 ||
464 data.id || data.full_name || data.html_url || data.name || data.path ||
465 (data.message && /already exists|已存在/i.test(data.message))
466 ) {
467 btn.textContent = '成功';
468 btn.classList.add('success');
469
470 // 自动填回 URL
471 const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
472 if (input) input.value = info.url;
473 } else {
474 throw new Error(data.message || data.error || `HTTP ${resp.status}`);
475 }
476
477 } catch (e) {
478 btn.textContent = '失败';
479 btn.classList.add('error');
480 console.error('创建仓库失败:', e);
481 alert(`${p.name} 创建失败:${e.message}`);
482 }
483
484 // 3秒后恢复
485 setTimeout(() => {
486 btn.disabled = false;
487 btn.textContent = '创建仓库';
488 btn.className = 'create-repo-btn';
489 }, 3000);
490 };
491
492 // 插入到操作按钮组的最前面
493 actionTd.insertBefore(btn, actionTd.firstChild);
494 });
495 }
496
497 // 创建设置面板
498 function createSettingsPanel() {
499 const panel = document.createElement('div');
500 panel.className = 'mirror-helper-container';
501 panel.id = 'mirror-settings-panel';
502
503 let html = `
504 <button class="mirror-helper-close" id="close-settings">×</button>
505 <div class="mirror-helper-header">镜像仓库设置</div>
506 `;
507
508 // 为每个平台创建输入框
509 for (const [key, platform] of Object.entries(platforms)) {
510 html += `
511 <div class="mirror-helper-section">
512 <h4>${platform.name}</h4>
513 <input type="text" class="mirror-helper-input" id="${key}-username"
514 placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
515 <input type="password" class="mirror-helper-input" id="${key}-token"
516 placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
517 </div>
518 `;
519 }
520
521 html += `
522 <button class="mirror-helper-button" id="save-settings">保存设置</button>
523 <button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
524 <div class="mirror-helper-footer">设置将保存在浏览器本地</div>
525 `;
526
527 panel.innerHTML = html;
528 document.body.appendChild(panel);
529
530 // 添加事件监听
531 document.getElementById('close-settings').addEventListener('click', () => {
532 panel.remove();
533 });
534
535 document.getElementById('save-settings').addEventListener('click', () => {
536 for (const [key, platform] of Object.entries(platforms)) {
537 const username = document.getElementById(`${key}-username`).value;
538 const token = document.getElementById(`${key}-token`).value;
539
540 GM_setValue(platform.usernameKey, username);
541 GM_setValue(platform.tokenKey, token);
542 }
543
544 alert('设置已保存!');
545 panel.remove();
546 location.reload(); // 刷新页面以应用新设置
547 });
548
549 document.getElementById('clear-settings').addEventListener('click', () => {
550 if (confirm('确定要清除所有设置吗?')) {
551 for (const platform of Object.values(platforms)) {
552 GM_deleteValue(platform.usernameKey);
553 GM_deleteValue(platform.tokenKey);
554 }
555 alert('设置已清除!');
556 panel.remove();
557 location.reload();
558 }
559 });
560 }
561
562 // 创建填充按钮
563 function createFillButton() {
564 const container = document.createElement('div');
565 container.className = 'mirror-helper-container';
566 container.id = 'mirror-fill-container';
567
568 let html = `
569 <button class="mirror-helper-close" id="close-fill">×</button>
570 <div class="mirror-helper-header">镜像仓库填充</div>
571 <div class="mirror-helper-section">
572 <h4>选择平台</h4>
573 <select class="mirror-helper-select" id="platform-select">
574 `;
575
576 // 添加平台选项
577 for (const [key, platform] of Object.entries(platforms)) {
578 const username = GM_getValue(platform.usernameKey, '');
579 const token = GM_getValue(platform.tokenKey, '');
580 const status = (username && token) ? '✓' : '✗';
581 html += `<option value="${key}">${platform.name} ${status}</option>`;
582 }
583
584 html += `
585 </select>
586 </div>
587 <button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
588 <button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
589 <div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
590 `;
591
592 container.innerHTML = html;
593 document.body.appendChild(container);
594
595 // 添加事件监听
596 document.getElementById('close-fill').addEventListener('click', () => {
597 container.remove();
598 });
599
600 document.getElementById('open-settings').addEventListener('click', () => {
601 container.remove();
602 createSettingsPanel();
603 });
604
605 document.getElementById('fill-mirror').addEventListener('click', () => {
606 const selectedPlatform = document.getElementById('platform-select').value;
607 fillMirrorForm(selectedPlatform);
608 });
609 }
610
611 // 填充表单
612 function fillMirrorForm(platformKey) {
613 const platform = platforms[platformKey];
614 const username = GM_getValue(platform.usernameKey, '');
615 const token = GM_getValue(platform.tokenKey, '');
616
617 if (!username || !token) {
618 alert(`请先设置 ${platform.name} 的用户名和Token!`);
619 return;
620 }
621
622 // 获取当前仓库路径
623 let [, group, project] = window.location.pathname.split('/');
624 if (!group || !project) {
625 alert('无法获取仓库路径!');
626 return;
627 }
628
629 switch (group) {
630 case 'idev':
631 if (platformKey === 'github') {
632 group = 'idevsig'
633 }
634 break;
635
636 case 'tiny':
637 if (platformKey === 'github' || platformKey === 'gitcode') {
638 group = 'tinyzen'
639 }
640 break;
641 }
642
643 // 构建镜像URL
644 const mirrorUrl = `${platform.url}${group}/${project}.git`;
645
646 // 填充表单
647 document.getElementById('push_mirror_address').value = mirrorUrl;
648 document.getElementById('push_mirror_username').value = username;
649 document.getElementById('push_mirror_password').value = token;
650 document.getElementById('push_mirror_interval').value = 0;
651
652 // 勾选"推送提交时同步"
653 const syncCheckbox = document.getElementById('push_mirror_sync_on_commit');
654 if (syncCheckbox && !syncCheckbox.checked) {
655 syncCheckbox.click();
656 }
657
658 // 显示成功消息
659 const successMsg = document.createElement('div');
660 successMsg.style.cssText = `
661 position: fixed;
662 top: 20px;
663 left: 50%;
664 transform: translateX(-50%);
665 background: #4caf50;
666 color: white;
667 padding: 12px 24px;
668 border-radius: 4px;
669 box-shadow: 0 2px 8px rgba(0,0,0,0.2);
670 z-index: 10000;
671 font-size: 14px;
672 `;
673 successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
674 document.body.appendChild(successMsg);
675
676 setTimeout(() => {
677 successMsg.remove();
678 }, 3000);
679 }
680
681 // 新增一个专门处理「推送镜像」列表中 URL 变成可点击链接的函数
682 function makePushMirrorUrlsClickable() {
683 // 找到所有推送镜像的 URL 单元格
684 const urlCells = document.querySelectorAll(
685 'table.ui.table tbody tr:not([style*="display: none"]) td.tw-break-anywhere'
686 );
687
688 urlCells.forEach(cell => {
689 // 避免重复处理
690 if (cell.querySelector('a')) return;
691
692 const urlText = cell.textContent.trim();
693
694 // 简单验证是否像 Git URL
695 if (urlText && (urlText.startsWith('http://') || urlText.startsWith('https://'))) {
696 const link = document.createElement('a');
697 link.href = urlText;
698 link.textContent = urlText;
699 link.target = '_blank'; // 新标签页打开
700 link.rel = 'noopener noreferrer'; // 安全最佳实践
701 link.style.color = '#0366d6'; // 模仿 Gitea 的蓝色链接
702 link.style.textDecoration = 'none';
703
704 // hover 效果(可选,更像原生链接)
705 link.addEventListener('mouseover', () => {
706 link.style.textDecoration = 'underline';
707 });
708 link.addEventListener('mouseout', () => {
709 link.style.textDecoration = 'none';
710 });
711
712 // 清空原文字并替换成链接
713 cell.innerHTML = '';
714 cell.appendChild(link);
715 }
716 });
717 }
718
719 // 初始化
720 function init() {
721 // 添加目标仓库列(适用于镜像列表页面)
722 if (document.querySelector('table.ui.table')) {
723 // 延迟执行以确保DOM完全加载
724 setTimeout(() => {
725 addTargetRepoColumn();
726 // 监听动态加载的行
727 const observer = new MutationObserver(() => {
728 addTargetRepoColumn();
729 });
730 observer.observe(document.body, { childList: true, subtree: true });
731 }, 1000);
732 }
733
734 // 创建填充按钮(适用于设置页面)
735 if (document.querySelector('table.ui.table')) {
736 createFillButton();
737 makePushMirrorUrlsClickable();
738 }
739
740 // 注册菜单命令
741 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
742 }
743
744 // 页面加载完成后初始化
745 if (document.readyState === 'loading') {
746 document.addEventListener('DOMContentLoaded', init);
747 } else {
748 init();
749 }
750
751 // 监听页面导航(SPA应用)
752 let currentUrl = location.href;
753 new MutationObserver(() => {
754 if (location.href !== currentUrl) {
755 currentUrl = location.href;
756 setTimeout(init, 500);
757 }
758 }).observe(document, { subtree: true, childList: true });
759})();
760
761
gitlab.user.js Raw
1// ==UserScript==
2// @name GitLab 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.3.3
5// @description 在GitLab仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接
6// @author Jetsung Chan <[email protected]>
7// @match https://framagit.org/*/-/settings/repository*
8// @match https://gitlab.com/*/-/settings/repository*
9// @match https://framagit.org/*/project_activity
10// @grant GM_getValue
11// @grant GM_setValue
12// @grant GM_deleteValue
13// @grant GM_addStyle
14// @grant GM_registerMenuCommand
15// @grant GM_unregisterMenuCommand
16// @grant GM_notification
17// @grant GM_xmlhttpRequest
18// @connect openapi-rdc.aliyuncs.com
19// @connect codeup.aliyun.com
20// @connect api.github.com
21// @connect codeberg.org
22// @connect gitcode.com
23// @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js
24// @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js
25// ==/UserScript==
26
27(function() {
28 'use strict';
29
30 // 平台配置
31 const platforms = {
32 github: {
33 name: 'GitHub',
34 url: 'https://github.com/',
35 tokenKey: 'github_token',
36 usernameKey: 'github_username',
37 domain: 'github.com'
38 },
39 gitcode: {
40 name: 'GitCode',
41 url: 'https://gitcode.com/',
42 tokenKey: 'gitcode_token',
43 usernameKey: 'gitcode_username',
44 domain: 'gitcode.com'
45 },
46 codeup: {
47 name: 'CodeUp',
48 url: 'https://codeup.aliyun.com/jetsung/',
49 tokenKey: 'codeup_token',
50 usernameKey: 'codeup_username',
51 domain: 'codeup.aliyun.com'
52 },
53 codeberg: {
54 name: 'Codeberg',
55 url: 'https://codeberg.org/',
56 tokenKey: 'codeberg_token',
57 usernameKey: 'codeberg_username',
58 domain: 'codeberg.org'
59 }
60 };
61
62 // 添加样式
63 GM_addStyle(`
64 .mirror-helper-container {
65 position: fixed;
66 top: 20px;
67 right: 20px;
68 z-index: 9999;
69 background: white;
70 border: 1px solid #ddd;
71 border-radius: 8px;
72 box-shadow: 0 4px 12px rgba(0,0,0,0.15);
73 padding: 20px;
74 width: 270px;
75 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
76 }
77
78 .mirror-helper-header {
79 font-size: 18px;
80 font-weight: 600;
81 margin-bottom: 15px;
82 color: #333;
83 border-bottom: 1px solid #eee;
84 padding-bottom: 10px;
85 }
86
87 .mirror-helper-section {
88 margin-bottom: 15px;
89 }
90
91 .mirror-helper-section h4 {
92 font-size: 14px;
93 margin: 0 0 8px 0;
94 color: #555;
95 }
96
97 .mirror-helper-input {
98 width: 100%;
99 padding: 8px 10px;
100 margin-bottom: 8px;
101 border: 1px solid #ddd;
102 border-radius: 4px;
103 font-size: 14px;
104 box-sizing: border-box;
105 }
106
107 .mirror-helper-button {
108 background: #1f75cb;
109 color: white;
110 border: none;
111 border-radius: 4px;
112 padding: 8px 16px;
113 font-size: 14px;
114 cursor: pointer;
115 margin-right: 8px;
116 margin-bottom: 8px;
117 transition: background 0.2s;
118 }
119
120 .mirror-helper-button:hover {
121 background: #1b5faa;
122 }
123
124 .mirror-helper-button.secondary {
125 background: #e6e6e6;
126 color: #333;
127 }
128
129 .mirror-helper-button.secondary:hover {
130 background: #d0d0d0;
131 }
132
133 .mirror-helper-select {
134 width: 100%;
135 padding: 8px 10px;
136 margin-bottom: 12px;
137 border: 1px solid #ddd;
138 border-radius: 4px;
139 font-size: 14px;
140 box-sizing: border-box;
141 }
142
143 .mirror-helper-footer {
144 margin-top: 15px;
145 padding-top: 10px;
146 border-top: 1px solid #eee;
147 font-size: 12px;
148 color: #777;
149 }
150
151 .mirror-helper-close {
152 position: absolute;
153 top: 10px;
154 right: 10px;
155 background: none;
156 border: none;
157 font-size: 18px;
158 cursor: pointer;
159 color: #999;
160 }
161
162 .mirror-helper-close:hover {
163 color: #333;
164 }
165
166 /* 目标仓库列样式 */
167 .target-repo-column {
168 max-width: 200px;
169 }
170
171 .target-repo-link {
172 color: #0366d6;
173 text-decoration: none;
174 font-weight: 500;
175 display: inline-block;
176 padding: 2px 4px;
177 border-radius: 3px;
178 transition: background-color 0.2s;
179 }
180
181 .target-repo-link:hover {
182 background-color: rgba(3, 102, 214, 0.1);
183 text-decoration: none;
184 }
185
186 .target-repo-link svg {
187 width: 14px;
188 height: 14px;
189 margin-right: 4px;
190 vertical-align: middle;
191 }
192
193 .no-target-link {
194 color: #666;
195 font-style: italic;
196 }
197
198 .create-repo-td { white-space: nowrap; }
199 .create-repo-btn {
200 background: #28a745; color: #fff; border: none; border-radius: 4px;
201 padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s;
202 }
203 .create-repo-btn:hover { background: #218838; }
204 .create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; }
205 .create-repo-btn.processing { background: #fd7e14 !important; }
206 .create-repo-btn.success { background: #1e7e34 !important; }
207 .create-repo-btn.error { background: #dc3545 !important; }
208 `);
209
210 // 解析镜像URL获取目标仓库信息
211 function parseMirrorUrl(mirrorUrl) {
212 try {
213 let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://')
214 .replace(/\.git$/, '');
215
216 for (const [key, platform] of Object.entries(platforms)) {
217 if (cleanUrl.includes(platform.domain)) {
218 return {
219 platform: platform.name,
220 url: cleanUrl,
221 displayText: '打开',
222 key: key
223 };
224 }
225 }
226 } catch (e) {
227 console.error('解析镜像URL失败:', mirrorUrl, e);
228 }
229 return null;
230 }
231
232 // 在表格中添加「打开」和「创建仓库」两列
233 function addTargetRepoColumn() {
234 const table = document.querySelector('table.gl-table');
235 if (!table) return;
236
237 const thead = table.querySelector('thead');
238 const tbody = table.querySelector('tbody');
239 if (!thead || !tbody) return;
240
241 // 防止重复添加
242 if (thead.querySelector('th[data-col="open"]')) return;
243
244 const firstTh = thead.querySelector('tr th:first-child');
245
246 // 1 「打开」列(保持你原来位置)
247 const openTh = document.createElement('th');
248 openTh.textContent = '目标仓库';
249 openTh.className = 'target-repo-column';
250 openTh.setAttribute('data-col', 'open');
251 firstTh.parentNode.insertBefore(openTh, firstTh.nextSibling);
252
253 // 2 「创建仓库」列(紧跟在打开后面)
254 const createTh = document.createElement('th');
255 createTh.textContent = '创建仓库';
256 createTh.setAttribute('data-col', 'create');
257 openTh.parentNode.insertBefore(createTh, openTh.nextSibling);
258
259 // 处理每一行
260 const rows = tbody.querySelectorAll('tr.rspec-mirrored-repository-row');
261 rows.forEach(row => {
262 const urlCell = row.querySelector('[data-testid="mirror-repository-url-content"]');
263 if (!urlCell) return;
264
265 const mirrorUrl = urlCell.textContent.trim();
266 const info = parseMirrorUrl(mirrorUrl);
267
268 // === 打开列(你原来的代码完全不动)===
269 const openTd = document.createElement('td');
270 openTd.className = 'target-repo-column';
271 if (info) {
272 const link = document.createElement('a');
273 link.href = info.url;
274 link.className = 'target-repo-link';
275 link.target = '_blank';
276 link.innerHTML = `
277 <svg viewBox="0 0 24 24" fill="currentColor">
278 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
279 </svg>
280 ${info.displayText}
281 `;
282 openTd.appendChild(link);
283 } else {
284 openTd.innerHTML = '<span class="no-target-link">无法解析</span>';
285 }
286 const firstTd = row.querySelector('td:first-child');
287 firstTd.parentNode.insertBefore(openTd, firstTd.nextSibling);
288
289 // === 新增:创建仓库列 ===
290 const createTd = document.createElement('td');
291 createTd.className = 'create-repo-td';
292 if (info && info.key) {
293 const btn = document.createElement('button');
294 btn.className = 'create-repo-btn';
295 btn.textContent = '创建仓库';
296
297 btn.onclick = async () => {
298 const key = info.key;
299 const p = platforms[key];
300 const username = GM_getValue(p.usernameKey, '');
301 const token = GM_getValue(p.tokenKey, '');
302
303 if (!username || !token) {
304 alert(`${p.name} 未配置账号信息`);
305 return;
306 }
307
308 btn.disabled = true;
309 btn.textContent = '创建中...';
310 btn.classList.add('processing');
311
312 try {
313 // === 终极解析:支持多级子组 ===
314 const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
315 const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
316 if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
317 const repoName = parts.pop(); // 最后一段:仓库名
318 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
319
320 if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
321
322 let resp, data;
323
324 // === 统一创建接口 ===
325 if (key === 'github') {
326 resp = await fetch('https://api.github.com/user/repos', {
327 method: 'POST',
328 headers: {
329 'Authorization': `token ${token}`,
330 'Content-Type': 'application/json',
331 'Accept': 'application/vnd.github.v3+json'
332 },
333 body: JSON.stringify({ name: repoName, private: false, auto_init: false })
334 });
335 } else if (key === 'codeberg') {
336 resp = await fetch('https://codeberg.org/api/v1/user/repos', {
337 method: 'POST',
338 headers: {
339 'Authorization': `token ${token}`,
340 'Content-Type': 'application/json'
341 },
342 body: JSON.stringify({ name: repoName, private: false, auto_init: false })
343 });
344 } else if (key === 'gitcode') {
345 console.log(token)
346 resp = await fetch(`https://api.gitcode.com/api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`, {
347 method: 'POST',
348 headers: {
349 'Authorization': `Bearer ${token}`,
350 'Content-Type': 'application/json'
351 },
352 body: JSON.stringify({
353 name: repoName,
354 path: repoName,
355 public: 1,
356 auto_init: false,
357 default_branch: 'main'
358 })
359 });
360 } else if (key === 'codeup') {
361 const domain = 'https://openapi-rdc.aliyuncs.com';
362 const referer = 'https://codeup.aliyun.com';
363
364 // Step 1: 获取组织列表
365 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
366 method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
367 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
368 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
369 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
370 }));
371
372 // Step 2: 获取群组空间
373 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
374 if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
375
376 const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
377 method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
378 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
379 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
380 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
381 }));
382
383 // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
384 const targetNs = namespaces.find(ns => {
385 const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
386 const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
387 return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
388 });
389
390 if (!targetNs) {
391 const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
392 throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
393 }
394
395 console.log('匹配群组成功:', targetNs);
396 console.log(JSON.stringify({
397 name: repoName,
398 path: repoName,
399 namespaceId: targetNs.id,
400 readMeType: 'NONE'
401 }));
402
403 // Step 4: 正确创建仓库(使用 organizationId + namespaceId)
404 await new Promise((resolve, reject) => {
405 GM_xmlhttpRequest({
406 method: 'POST',
407 url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
408 headers: {
409 'x-yunxiao-token': token,
410 'Content-Type': 'application/json',
411 'Referer': referer,
412 'Origin': referer
413 },
414 data: JSON.stringify({
415 name: repoName,
416 path: repoName,
417 namespaceId: targetNs.id,
418 }),
419 onload: r => {
420 const data = r.responseText ? JSON.parse(r.responseText) : {};
421 if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
422 console.log('CodeUp 仓库创建成功:', data);
423 resolve(data);
424 } else {
425 reject(new Error(data.message || data.error || `创建失败(${r.status}`));
426 }
427 },
428 onerror: () => reject(new Error('网络错误')),
429 ontimeout: () => reject(new Error('请求超时'))
430 });
431 });
432
433 // 模拟成功响应
434 resp = { ok: true, status: 200 };
435 data = { id: 'success' };
436 }
437
438 // === 统一成功判断 ===
439 if (
440 resp.status >= 200 && resp.status < 400 ||
441 data.id || data.full_name || data.html_url || data.name || data.path ||
442 (data.message && /already exists|已存在/i.test(data.message))
443 ) {
444 btn.textContent = '成功';
445 btn.classList.add('success');
446
447 // 自动填回 URL
448 const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
449 if (input) input.value = info.url;
450 } else {
451 throw new Error(data.message || data.error || `HTTP ${resp.status}`);
452 }
453
454 } catch (e) {
455 btn.textContent = '失败';
456 btn.classList.add('error');
457 console.error('创建仓库失败:', e);
458 alert(`${p.name} 创建失败:${e.message}`);
459 }
460
461 // 3秒后恢复
462 setTimeout(() => {
463 btn.disabled = false;
464 btn.textContent = '创建仓库';
465 btn.className = 'create-repo-btn';
466 }, 3000);
467 };
468
469 createTd.appendChild(btn);
470 } else {
471 createTd.textContent = '—';
472 }
473 // 插入到「打开」列的后面
474 openTd.parentNode.insertBefore(createTd, openTd.nextSibling);
475 });
476 }
477
478 // 创建设置面板
479 function createSettingsPanel() {
480 const panel = document.createElement('div');
481 panel.className = 'mirror-helper-container';
482 panel.id = 'mirror-settings-panel';
483
484 let html = `
485 <button class="mirror-helper-close" id="close-settings">×</button>
486 <div class="mirror-helper-header">镜像仓库设置</div>
487 `;
488
489 // 为每个平台创建输入框
490 for (const [key, platform] of Object.entries(platforms)) {
491 html += `
492 <div class="mirror-helper-section">
493 <h4>${platform.name}</h4>
494 <input type="text" class="mirror-helper-input" id="${key}-username"
495 placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
496 <input type="password" class="mirror-helper-input" id="${key}-token"
497 placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
498 </div>
499 `;
500 }
501
502 html += `
503 <button class="mirror-helper-button" id="save-settings">保存设置</button>
504 <button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
505 <div class="mirror-helper-footer">设置将保存在浏览器本地</div>
506 `;
507
508 panel.innerHTML = html;
509 document.body.appendChild(panel);
510
511 // 添加事件监听
512 document.getElementById('close-settings').addEventListener('click', () => {
513 panel.remove();
514 });
515
516 document.getElementById('save-settings').addEventListener('click', () => {
517 for (const [key, platform] of Object.entries(platforms)) {
518 const username = document.getElementById(`${key}-username`).value;
519 const token = document.getElementById(`${key}-token`).value;
520
521 GM_setValue(platform.usernameKey, username);
522 GM_setValue(platform.tokenKey, token);
523 }
524
525 alert('设置已保存!');
526 panel.remove();
527 location.reload(); // 刷新页面以应用新设置
528 });
529
530 document.getElementById('clear-settings').addEventListener('click', () => {
531 if (confirm('确定要清除所有设置吗?')) {
532 for (const platform of Object.values(platforms)) {
533 GM_deleteValue(platform.usernameKey);
534 GM_deleteValue(platform.tokenKey);
535 }
536 alert('设置已清除!');
537 panel.remove();
538 location.reload();
539 }
540 });
541 }
542
543 // 创建填充按钮
544 function createFillButton() {
545 const container = document.createElement('div');
546 container.className = 'mirror-helper-container';
547 container.id = 'mirror-fill-container';
548
549 let html = `
550 <button class="mirror-helper-close" id="close-fill">×</button>
551 <div class="mirror-helper-header">镜像仓库填充</div>
552 <div class="mirror-helper-section">
553 <h4>选择平台</h4>
554 <select class="mirror-helper-select" id="platform-select">
555 `;
556
557 // 添加平台选项
558 for (const [key, platform] of Object.entries(platforms)) {
559 const username = GM_getValue(platform.usernameKey, '');
560 const token = GM_getValue(platform.tokenKey, '');
561 const status = (username && token) ? '✓' : '✗';
562 html += `<option value="${key}">${platform.name} ${status}</option>`;
563 }
564
565 html += `
566 </select>
567 </div>
568 <button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
569 <button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
570 <div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
571 `;
572
573 container.innerHTML = html;
574 document.body.appendChild(container);
575
576 // 添加事件监听
577 document.getElementById('close-fill').addEventListener('click', () => {
578 container.remove();
579 });
580
581 document.getElementById('open-settings').addEventListener('click', () => {
582 container.remove();
583 createSettingsPanel();
584 });
585
586 document.getElementById('fill-mirror').addEventListener('click', () => {
587 const selectedPlatform = document.getElementById('platform-select').value;
588 fillMirrorForm(selectedPlatform);
589 });
590 }
591
592 // 填充表单
593 function fillMirrorForm(platformKey) {
594 const platform = platforms[platformKey];
595 const username = GM_getValue(platform.usernameKey, '');
596 const token = GM_getValue(platform.tokenKey, '');
597
598 if (!username || !token) {
599 alert(`请先设置 ${platform.name} 的用户名和Token!`);
600 return;
601 }
602
603 // 获取当前仓库路径
604 let [, group, project] = window.location.pathname.split('/');
605 if (!group || !project) {
606 alert('无法获取仓库路径!');
607 return;
608 }
609
610 switch (group) {
611 case 'idev':
612 if (platformKey === 'github') {
613 group = 'idevsig'
614 }
615 break;
616
617 case 'tiny':
618 if (platformKey === 'github' || platformKey === 'gitcode') {
619 group = 'tinyzen'
620 }
621 break;
622 }
623
624 // 构建镜像URL
625 const mirrorUrl = `${platform.url}${group}/${project}.git`;
626
627 // 填充表单
628 document.getElementById('url').value = mirrorUrl;
629 document.getElementById('project_remote_mirrors_attributes_0_url').value = mirrorUrl;
630 document.getElementById('project_remote_mirrors_attributes_0_user').value = username;
631 document.getElementById('project_remote_mirrors_attributes_0_password').value = token;
632
633 // 勾选"仅镜像受保护的分支"
634 const protectedCheckbox = document.getElementById('only_protected_branches');
635 if (protectedCheckbox && !protectedCheckbox.checked) {
636 protectedCheckbox.click();
637 }
638
639 // 显示成功消息
640 const successMsg = document.createElement('div');
641 successMsg.style.cssText = `
642 position: fixed;
643 top: 20px;
644 left: 50%;
645 transform: translateX(-50%);
646 background: #4caf50;
647 color: white;
648 padding: 12px 24px;
649 border-radius: 4px;
650 box-shadow: 0 2px 8px rgba(0,0,0,0.2);
651 z-index: 10000;
652 font-size: 14px;
653 `;
654 successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
655 document.body.appendChild(successMsg);
656
657 setTimeout(() => {
658 successMsg.remove();
659 }, 3000);
660 }
661
662 // 初始化
663 function init() {
664 // 添加目标仓库列(适用于镜像列表页面)
665 if (document.querySelector('.js-mirrors-table-body')) {
666 // 延迟执行以确保DOM完全加载
667 setTimeout(() => {
668 addTargetRepoColumn();
669 // 监听动态加载的行
670 const observer = new MutationObserver(() => {
671 addTargetRepoColumn();
672 });
673 observer.observe(document.body, { childList: true, subtree: true });
674 }, 1000);
675 }
676
677 // 创建填充按钮(适用于设置页面)
678 if (document.querySelector('.js-mirror-form')) {
679 createFillButton();
680 }
681
682 // 注册菜单命令
683 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
684 }
685
686 // 页面加载完成后初始化
687 if (document.readyState === 'loading') {
688 document.addEventListener('DOMContentLoaded', init);
689 } else {
690 init();
691 }
692
693 // 监听页面导航(SPA应用)
694 let currentUrl = location.href;
695 new MutationObserver(() => {
696 if (location.href !== currentUrl) {
697 currentUrl = location.href;
698 setTimeout(init, 500);
699 }
700 }).observe(document, { subtree: true, childList: true });
701})();
702