Last active 1 month ago

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

gitea.user.js Raw
1// ==UserScript==
2// @name Gitea 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.2.4
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 atomgit.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 atomgit: {
41 name: 'AtomGit',
42 url: 'https://atomgit.com/',
43 tokenKey: 'atomgit_token',
44 usernameKey: 'atomgit_username',
45 domain: 'atomgit.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 // 查询群组 ID(异步函数)
245 async function getGitLabGroupId(token, url, ownerPath) {
246 let namespaceId = null;
247
248 try {
249 const encodedOwnerPath = encodeURIComponent(ownerPath);
250 const groupResp = await fetch(`${url}api/v4/groups/${encodedOwnerPath}`, {
251 method: 'GET',
252 headers: {
253 'PRIVATE-TOKEN': token,
254 'Accept': 'application/json'
255 }
256 });
257
258 if (groupResp.ok) {
259 const groupData = await groupResp.json();
260 namespaceId = groupData.id;
261 console.log(`找到群组 ${ownerPath},ID = ${namespaceId}`);
262 } else if (groupResp.status === 404) {
263 console.warn(`群组 ${ownerPath} 不存在,将创建在个人命名空间下`);
264 // namespaceId 保持 null
265 } else {
266 const err = await groupResp.text();
267 throw new Error(`查询群组失败: ${groupResp.status} - ${err}`);
268 }
269 } catch (err) {
270 console.error('查询群组出错:', err);
271 throw err; // 让调用方处理错误
272 }
273
274 return namespaceId;
275 }
276
277 // 在推送镜像表格的操作列中添加「创建仓库」按钮
278 function addTargetRepoColumn() {
279 // 匹配推送镜像的表格
280 const table = document.querySelector('table.ui.table');
281 if (!table) return;
282
283 const tbody = table.querySelector('tbody');
284 if (!tbody) return;
285
286 // 获取所有行,过滤掉最后的“添加镜像”表单行
287 const rows = tbody.querySelectorAll('tr');
288 rows.forEach(row => {
289 // 如果行内包含 form 或者 colspan="4",说明是添加镜像的输入框行,跳过
290 if (row.querySelector('form[action*="push-mirror-add"]') || row.querySelector('td[colspan]')) return;
291
292 // 查找操作列(右对齐的那个 td)
293 const actionTd = row.querySelector('td.tw-text-right');
294 if (!actionTd || actionTd.querySelector('.create-repo-btn')) return;
295
296 // 获取当前行的仓库 URL
297 const urlTd = row.querySelector('td.tw-break-anywhere');
298 if (!urlTd) return;
299
300 const mirrorUrl = urlTd.textContent.trim();
301 const info = parseMirrorUrl(mirrorUrl);
302
303 // 创建「创建仓库」按钮
304 const btn = document.createElement('button');
305 btn.className = 'create-repo-btn ui primary tiny button';
306 btn.style.marginRight = '4px'; // 留点间距
307 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>`;
308 btn.title = `创建仓库`;
309
310 // console.log(info);
311
312 btn.onclick = async () => {
313 const key = info.key;
314 const p = platforms[key];
315 const username = GM_getValue(p.usernameKey, '');
316 const token = GM_getValue(p.tokenKey, '');
317
318 if (!username || !token) {
319 alert(`${p.name} 未配置账号信息`);
320 return;
321 }
322
323 btn.disabled = true;
324 btn.textContent = '创建中...';
325 btn.classList.add('processing');
326
327 try {
328 // === 终极解析:支持多级子组 ===
329 const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
330 const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
331 if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
332 const repoName = parts.pop(); // 最后一段:仓库名
333 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
334
335 if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
336
337 let resp, data;
338 let namespaceId = null;
339
340 // === 统一创建接口 ===
341 if (key === 'github') {
342 let reqPath = 'user/repos'
343 if (username !== ownerPath) {
344 reqPath = `orgs/${ownerPath}/repos`
345 }
346 resp = await fetch('https://api.github.com/' + reqPath, {
347 method: 'POST',
348 headers: {
349 'Authorization': `token ${token}`,
350 'Content-Type': 'application/json',
351 'Accept': 'application/vnd.github.v3+json'
352 },
353 body: JSON.stringify({
354 name: repoName,
355 private: false,
356 auto_init: false,
357 description: description,
358 })
359 });
360 } else if (p.type === 'gitea' || key === 'codeberg') {
361 let reqPath = 'api/v1/user/repos'
362 if (username !== ownerPath) {
363 reqPath = `api/v1/orgs/${ownerPath}/repos`
364 }
365 resp = await fetch(p.url + reqPath, {
366 method: 'POST',
367 headers: {
368 'Authorization': `token ${token}`,
369 'Content-Type': 'application/json'
370 },
371 body: JSON.stringify({
372 name: repoName,
373 private: false,
374 auto_init: false,
375 description:description,
376 })
377 });
378 } else if (p.type === 'gitlab' || key === 'framagit') {
379 let reqPath = 'api/v4/projects'
380 if (username !== ownerPath) {
381 try {
382 namespaceId = await getGitLabGroupId(token, p.url, ownerPath);
383 } catch (err) {
384 console.error("获取群组ID失败", err);
385 }
386 }
387 resp = await fetch(p.url + reqPath, {
388 method: 'POST',
389 headers: {
390 'PRIVATE-TOKEN': token,
391 'Content-Type': 'application/json',
392 'Referer': p.url,
393 'Origin': p.url
394 },
395 body: JSON.stringify({
396 name: repoName,
397 visibility: 'private',
398 description: description,
399 namespace_id: namespaceId,
400 })
401 });
402 } else if (key === 'atomgit') {
403 let reqPath = 'api/v5/user/repos'
404 if (username !== ownerPath) {
405 reqPath = `api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`
406 }
407 resp = await fetch(`https://api.atomgit.com/` + reqPath, {
408 method: 'POST',
409 headers: {
410 'Authorization': `Bearer ${token}`,
411 'Content-Type': 'application/json'
412 },
413 body: JSON.stringify({
414 name: repoName,
415 path: repoName,
416 public: 1,
417 auto_init: false,
418 default_branch: 'main',
419 description: description,
420 })
421 });
422 } else if (key === 'codeup') {
423 const domain = 'https://openapi-rdc.aliyuncs.com';
424 const referer = 'https://codeup.aliyun.com';
425
426 // Step 1: 获取组织列表
427 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
428 method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
429 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
430 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
431 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
432 }));
433
434 // Step 2: 获取群组空间
435 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
436 if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
437
438 const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
439 method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
440 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
441 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
442 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
443 }));
444
445 // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
446 const targetNs = namespaces.find(ns => {
447 const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
448 const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
449 return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
450 });
451
452 if (!targetNs) {
453 const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
454 throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
455 }
456
457 // console.log('匹配群组成功:', targetNs);
458 // console.log(JSON.stringify({
459 // name: repoName,
460 // path: repoName,
461 // namespaceId: targetNs.id,
462 // readMeType: 'NONE'
463 // }));
464
465 // Step 4: 正确创建仓库(使用 organizationId + namespaceId)
466 await new Promise((resolve, reject) => {
467 GM_xmlhttpRequest({
468 method: 'POST',
469 url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
470 headers: {
471 'x-yunxiao-token': token,
472 'Content-Type': 'application/json',
473 'Referer': referer,
474 'Origin': referer
475 },
476 data: JSON.stringify({
477 name: repoName,
478 path: repoName,
479 namespaceId: targetNs.id,
480 description: description,
481 }),
482 onload: r => {
483 const data = r.responseText ? JSON.parse(r.responseText) : {};
484 if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
485 console.log('CodeUp 仓库创建成功:', data);
486 resolve(data);
487 } else {
488 reject(new Error(data.message || data.error || `创建失败(${r.status}`));
489 }
490 },
491 onerror: () => reject(new Error('网络错误')),
492 ontimeout: () => reject(new Error('请求超时'))
493 });
494 });
495
496 // 模拟成功响应
497 resp = { ok: true, status: 200 };
498 data = { id: 'success' };
499 }
500
501 // === 统一成功判断 ===
502 if (
503 resp.status >= 200 && resp.status < 400 ||
504 data.id || data.full_name || data.html_url || data.name || data.path ||
505 (data.message && /already exists|已存在/i.test(data.message))
506 ) {
507 btn.textContent = '成功';
508 btn.classList.add('success');
509
510 // 自动填回 URL
511 const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
512 if (input) input.value = info.url;
513 } else {
514 throw new Error(data.message || data.error || `HTTP ${resp.status}`);
515 }
516
517 } catch (e) {
518 btn.textContent = '失败';
519 btn.classList.add('error');
520 console.error('创建仓库失败:', e);
521 alert(`${p.name} 创建失败:${e.message}`);
522 }
523
524 // 3秒后恢复
525 setTimeout(() => {
526 btn.disabled = false;
527 btn.textContent = '创建仓库';
528 btn.className = 'create-repo-btn';
529 }, 3000);
530 };
531
532 // 插入到操作按钮组的最前面
533 actionTd.insertBefore(btn, actionTd.firstChild);
534 });
535 }
536
537 // 创建设置面板
538 function createSettingsPanel() {
539 const panel = document.createElement('div');
540 panel.className = 'mirror-helper-container';
541 panel.id = 'mirror-settings-panel';
542
543 let html = `
544 <button class="mirror-helper-close" id="close-settings">×</button>
545 <div class="mirror-helper-header">镜像仓库设置</div>
546 `;
547
548 // 为每个平台创建输入框
549 for (const [key, platform] of Object.entries(platforms)) {
550 html += `
551 <div class="mirror-helper-section">
552 <h4>${platform.name}</h4>
553 <input type="text" class="mirror-helper-input" id="${key}-username"
554 placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
555 <input type="password" class="mirror-helper-input" id="${key}-token"
556 placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
557 </div>
558 `;
559 }
560
561 html += `
562 <button class="mirror-helper-button" id="save-settings">保存设置</button>
563 <button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
564 <div class="mirror-helper-footer">设置将保存在浏览器本地</div>
565 `;
566
567 panel.innerHTML = html;
568 document.body.appendChild(panel);
569
570 // 添加事件监听
571 document.getElementById('close-settings').addEventListener('click', () => {
572 panel.remove();
573 });
574
575 document.getElementById('save-settings').addEventListener('click', () => {
576 for (const [key, platform] of Object.entries(platforms)) {
577 const username = document.getElementById(`${key}-username`).value;
578 const token = document.getElementById(`${key}-token`).value;
579
580 GM_setValue(platform.usernameKey, username);
581 GM_setValue(platform.tokenKey, token);
582 }
583
584 alert('设置已保存!');
585 panel.remove();
586 location.reload(); // 刷新页面以应用新设置
587 });
588
589 document.getElementById('clear-settings').addEventListener('click', () => {
590 if (confirm('确定要清除所有设置吗?')) {
591 for (const platform of Object.values(platforms)) {
592 GM_deleteValue(platform.usernameKey);
593 GM_deleteValue(platform.tokenKey);
594 }
595 alert('设置已清除!');
596 panel.remove();
597 location.reload();
598 }
599 });
600 }
601
602 // 创建填充按钮
603 function createFillButton() {
604 const container = document.createElement('div');
605 container.className = 'mirror-helper-container';
606 container.id = 'mirror-fill-container';
607
608 let html = `
609 <button class="mirror-helper-close" id="close-fill">×</button>
610 <div class="mirror-helper-header">镜像仓库填充</div>
611 <div class="mirror-helper-section">
612 <h4>选择平台</h4>
613 <select class="mirror-helper-select" id="platform-select">
614 `;
615
616 // 添加平台选项
617 for (const [key, platform] of Object.entries(platforms)) {
618 const username = GM_getValue(platform.usernameKey, '');
619 const token = GM_getValue(platform.tokenKey, '');
620 const status = (username && token) ? '✓' : '✗';
621 html += `<option value="${key}">${platform.name} ${status}</option>`;
622 }
623
624 html += `
625 </select>
626 </div>
627 <button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
628 <button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
629 <div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
630 `;
631
632 container.innerHTML = html;
633 document.body.appendChild(container);
634
635 // 添加事件监听
636 document.getElementById('close-fill').addEventListener('click', () => {
637 container.remove();
638 });
639
640 document.getElementById('open-settings').addEventListener('click', () => {
641 container.remove();
642 createSettingsPanel();
643 });
644
645 document.getElementById('fill-mirror').addEventListener('click', () => {
646 const selectedPlatform = document.getElementById('platform-select').value;
647 fillMirrorForm(selectedPlatform);
648 });
649 }
650
651 // 填充表单
652 function fillMirrorForm(platformKey) {
653 const platform = platforms[platformKey];
654 const username = GM_getValue(platform.usernameKey, '');
655 const token = GM_getValue(platform.tokenKey, '');
656
657 if (!username || !token) {
658 alert(`请先设置 ${platform.name} 的用户名和Token!`);
659 return;
660 }
661
662 // 获取当前仓库路径
663 let [, group, project] = window.location.pathname.split('/');
664 if (!group || !project) {
665 alert('无法获取仓库路径!');
666 return;
667 }
668
669 switch (group) {
670 case 'idev':
671 if (platformKey === 'github') {
672 group = 'idevsig'
673 }
674 break;
675
676 case 'tiny':
677 if (platformKey === 'github' || platformKey === 'atomgit') {
678 group = 'tinyzen'
679 }
680 break;
681 }
682
683 // 构建镜像URL
684 const mirrorUrl = `${platform.url}${group}/${project}.git`;
685
686 // 填充表单
687 document.getElementById('push_mirror_address').value = mirrorUrl;
688 document.getElementById('push_mirror_username').value = username;
689 document.getElementById('push_mirror_password').value = token;
690 document.getElementById('push_mirror_interval').value = 0;
691
692 // 勾选"推送提交时同步"
693 const syncCheckbox = document.getElementById('push_mirror_sync_on_commit');
694 if (syncCheckbox && !syncCheckbox.checked) {
695 syncCheckbox.click();
696 }
697
698 // 显示成功消息
699 const successMsg = document.createElement('div');
700 successMsg.style.cssText = `
701 position: fixed;
702 top: 20px;
703 left: 50%;
704 transform: translateX(-50%);
705 background: #4caf50;
706 color: white;
707 padding: 12px 24px;
708 border-radius: 4px;
709 box-shadow: 0 2px 8px rgba(0,0,0,0.2);
710 z-index: 10000;
711 font-size: 14px;
712 `;
713 successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
714 document.body.appendChild(successMsg);
715
716 setTimeout(() => {
717 successMsg.remove();
718 }, 3000);
719 }
720
721 // 新增一个专门处理「推送镜像」列表中 URL 变成可点击链接的函数
722 function makePushMirrorUrlsClickable() {
723 // 找到所有推送镜像的 URL 单元格
724 const urlCells = document.querySelectorAll(
725 'table.ui.table tbody tr:not([style*="display: none"]) td.tw-break-anywhere'
726 );
727
728 urlCells.forEach(cell => {
729 // 避免重复处理
730 if (cell.querySelector('a')) return;
731
732 const urlText = cell.textContent.trim();
733
734 // 简单验证是否像 Git URL
735 if (urlText && (urlText.startsWith('http://') || urlText.startsWith('https://'))) {
736 const link = document.createElement('a');
737 link.href = urlText;
738 link.textContent = urlText;
739 link.target = '_blank'; // 新标签页打开
740 link.rel = 'noopener noreferrer'; // 安全最佳实践
741 link.style.color = '#0366d6'; // 模仿 Gitea 的蓝色链接
742 link.style.textDecoration = 'none';
743
744 // hover 效果(可选,更像原生链接)
745 link.addEventListener('mouseover', () => {
746 link.style.textDecoration = 'underline';
747 });
748 link.addEventListener('mouseout', () => {
749 link.style.textDecoration = 'none';
750 });
751
752 // 清空原文字并替换成链接
753 cell.innerHTML = '';
754 cell.appendChild(link);
755 }
756 });
757 }
758
759 // 初始化
760 function init() {
761 // 添加目标仓库列(适用于镜像列表页面)
762 if (document.querySelector('table.ui.table')) {
763 // 延迟执行以确保DOM完全加载
764 setTimeout(() => {
765 addTargetRepoColumn();
766 // 监听动态加载的行
767 const observer = new MutationObserver(() => {
768 addTargetRepoColumn();
769 });
770 observer.observe(document.body, { childList: true, subtree: true });
771 }, 1000);
772 }
773
774 // 创建填充按钮(适用于设置页面)
775 if (document.querySelector('table.ui.table')) {
776 createFillButton();
777 makePushMirrorUrlsClickable();
778 }
779
780 // 注册菜单命令
781 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
782 }
783
784 // 页面加载完成后初始化
785 if (document.readyState === 'loading') {
786 document.addEventListener('DOMContentLoaded', init);
787 } else {
788 init();
789 }
790
791 // 监听页面导航(SPA应用)
792 let currentUrl = location.href;
793 new MutationObserver(() => {
794 if (location.href !== currentUrl) {
795 currentUrl = location.href;
796 setTimeout(init, 500);
797 }
798 }).observe(document, { subtree: true, childList: true });
799})();
800
gitlab.user.js Raw
1// ==UserScript==
2// @name GitLab 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.3.4
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 atomgit.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 atomgit: {
40 name: 'AtomGit',
41 url: 'https://atomgit.com/',
42 tokenKey: 'atomgit_token',
43 usernameKey: 'atomgit_username',
44 domain: 'atomgit.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 === 'atomgit') {
345 console.log(token)
346 resp = await fetch(`https://api.atomgit.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 === 'atomgit') {
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