Ostatnio aktywny 1 month ago

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

Rewizja ee14d089edf246ead8395563693da4a3f31ed767

gitea.user.js Surowy
1// ==UserScript==
2// @name Gitea 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.2.2
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 // 查询群组 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 === 'gitcode') {
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.gitcode.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 }),
481 onload: r => {
482 const data = r.responseText ? JSON.parse(r.responseText) : {};
483 if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
484 console.log('CodeUp 仓库创建成功:', data);
485 resolve(data);
486 } else {
487 reject(new Error(data.message || data.error || `创建失败(${r.status}`));
488 }
489 },
490 onerror: () => reject(new Error('网络错误')),
491 ontimeout: () => reject(new Error('请求超时'))
492 });
493 });
494
495 // 模拟成功响应
496 resp = { ok: true, status: 200 };
497 data = { id: 'success' };
498 }
499
500 // === 统一成功判断 ===
501 if (
502 resp.status >= 200 && resp.status < 400 ||
503 data.id || data.full_name || data.html_url || data.name || data.path ||
504 (data.message && /already exists|已存在/i.test(data.message))
505 ) {
506 btn.textContent = '成功';
507 btn.classList.add('success');
508
509 // 自动填回 URL
510 const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
511 if (input) input.value = info.url;
512 } else {
513 throw new Error(data.message || data.error || `HTTP ${resp.status}`);
514 }
515
516 } catch (e) {
517 btn.textContent = '失败';
518 btn.classList.add('error');
519 console.error('创建仓库失败:', e);
520 alert(`${p.name} 创建失败:${e.message}`);
521 }
522
523 // 3秒后恢复
524 setTimeout(() => {
525 btn.disabled = false;
526 btn.textContent = '创建仓库';
527 btn.className = 'create-repo-btn';
528 }, 3000);
529 };
530
531 // 插入到操作按钮组的最前面
532 actionTd.insertBefore(btn, actionTd.firstChild);
533 });
534 }
535
536 // 创建设置面板
537 function createSettingsPanel() {
538 const panel = document.createElement('div');
539 panel.className = 'mirror-helper-container';
540 panel.id = 'mirror-settings-panel';
541
542 let html = `
543 <button class="mirror-helper-close" id="close-settings">×</button>
544 <div class="mirror-helper-header">镜像仓库设置</div>
545 `;
546
547 // 为每个平台创建输入框
548 for (const [key, platform] of Object.entries(platforms)) {
549 html += `
550 <div class="mirror-helper-section">
551 <h4>${platform.name}</h4>
552 <input type="text" class="mirror-helper-input" id="${key}-username"
553 placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
554 <input type="password" class="mirror-helper-input" id="${key}-token"
555 placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
556 </div>
557 `;
558 }
559
560 html += `
561 <button class="mirror-helper-button" id="save-settings">保存设置</button>
562 <button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
563 <div class="mirror-helper-footer">设置将保存在浏览器本地</div>
564 `;
565
566 panel.innerHTML = html;
567 document.body.appendChild(panel);
568
569 // 添加事件监听
570 document.getElementById('close-settings').addEventListener('click', () => {
571 panel.remove();
572 });
573
574 document.getElementById('save-settings').addEventListener('click', () => {
575 for (const [key, platform] of Object.entries(platforms)) {
576 const username = document.getElementById(`${key}-username`).value;
577 const token = document.getElementById(`${key}-token`).value;
578
579 GM_setValue(platform.usernameKey, username);
580 GM_setValue(platform.tokenKey, token);
581 }
582
583 alert('设置已保存!');
584 panel.remove();
585 location.reload(); // 刷新页面以应用新设置
586 });
587
588 document.getElementById('clear-settings').addEventListener('click', () => {
589 if (confirm('确定要清除所有设置吗?')) {
590 for (const platform of Object.values(platforms)) {
591 GM_deleteValue(platform.usernameKey);
592 GM_deleteValue(platform.tokenKey);
593 }
594 alert('设置已清除!');
595 panel.remove();
596 location.reload();
597 }
598 });
599 }
600
601 // 创建填充按钮
602 function createFillButton() {
603 const container = document.createElement('div');
604 container.className = 'mirror-helper-container';
605 container.id = 'mirror-fill-container';
606
607 let html = `
608 <button class="mirror-helper-close" id="close-fill">×</button>
609 <div class="mirror-helper-header">镜像仓库填充</div>
610 <div class="mirror-helper-section">
611 <h4>选择平台</h4>
612 <select class="mirror-helper-select" id="platform-select">
613 `;
614
615 // 添加平台选项
616 for (const [key, platform] of Object.entries(platforms)) {
617 const username = GM_getValue(platform.usernameKey, '');
618 const token = GM_getValue(platform.tokenKey, '');
619 const status = (username && token) ? '✓' : '✗';
620 html += `<option value="${key}">${platform.name} ${status}</option>`;
621 }
622
623 html += `
624 </select>
625 </div>
626 <button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
627 <button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
628 <div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
629 `;
630
631 container.innerHTML = html;
632 document.body.appendChild(container);
633
634 // 添加事件监听
635 document.getElementById('close-fill').addEventListener('click', () => {
636 container.remove();
637 });
638
639 document.getElementById('open-settings').addEventListener('click', () => {
640 container.remove();
641 createSettingsPanel();
642 });
643
644 document.getElementById('fill-mirror').addEventListener('click', () => {
645 const selectedPlatform = document.getElementById('platform-select').value;
646 fillMirrorForm(selectedPlatform);
647 });
648 }
649
650 // 填充表单
651 function fillMirrorForm(platformKey) {
652 const platform = platforms[platformKey];
653 const username = GM_getValue(platform.usernameKey, '');
654 const token = GM_getValue(platform.tokenKey, '');
655
656 if (!username || !token) {
657 alert(`请先设置 ${platform.name} 的用户名和Token!`);
658 return;
659 }
660
661 // 获取当前仓库路径
662 let [, group, project] = window.location.pathname.split('/');
663 if (!group || !project) {
664 alert('无法获取仓库路径!');
665 return;
666 }
667
668 switch (group) {
669 case 'idev':
670 if (platformKey === 'github') {
671 group = 'idevsig'
672 }
673 break;
674
675 case 'tiny':
676 if (platformKey === 'github' || platformKey === 'gitcode') {
677 group = 'tinyzen'
678 }
679 break;
680 }
681
682 // 构建镜像URL
683 const mirrorUrl = `${platform.url}${group}/${project}.git`;
684
685 // 填充表单
686 document.getElementById('push_mirror_address').value = mirrorUrl;
687 document.getElementById('push_mirror_username').value = username;
688 document.getElementById('push_mirror_password').value = token;
689 document.getElementById('push_mirror_interval').value = 0;
690
691 // 勾选"推送提交时同步"
692 const syncCheckbox = document.getElementById('push_mirror_sync_on_commit');
693 if (syncCheckbox && !syncCheckbox.checked) {
694 syncCheckbox.click();
695 }
696
697 // 显示成功消息
698 const successMsg = document.createElement('div');
699 successMsg.style.cssText = `
700 position: fixed;
701 top: 20px;
702 left: 50%;
703 transform: translateX(-50%);
704 background: #4caf50;
705 color: white;
706 padding: 12px 24px;
707 border-radius: 4px;
708 box-shadow: 0 2px 8px rgba(0,0,0,0.2);
709 z-index: 10000;
710 font-size: 14px;
711 `;
712 successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
713 document.body.appendChild(successMsg);
714
715 setTimeout(() => {
716 successMsg.remove();
717 }, 3000);
718 }
719
720 // 新增一个专门处理「推送镜像」列表中 URL 变成可点击链接的函数
721 function makePushMirrorUrlsClickable() {
722 // 找到所有推送镜像的 URL 单元格
723 const urlCells = document.querySelectorAll(
724 'table.ui.table tbody tr:not([style*="display: none"]) td.tw-break-anywhere'
725 );
726
727 urlCells.forEach(cell => {
728 // 避免重复处理
729 if (cell.querySelector('a')) return;
730
731 const urlText = cell.textContent.trim();
732
733 // 简单验证是否像 Git URL
734 if (urlText && (urlText.startsWith('http://') || urlText.startsWith('https://'))) {
735 const link = document.createElement('a');
736 link.href = urlText;
737 link.textContent = urlText;
738 link.target = '_blank'; // 新标签页打开
739 link.rel = 'noopener noreferrer'; // 安全最佳实践
740 link.style.color = '#0366d6'; // 模仿 Gitea 的蓝色链接
741 link.style.textDecoration = 'none';
742
743 // hover 效果(可选,更像原生链接)
744 link.addEventListener('mouseover', () => {
745 link.style.textDecoration = 'underline';
746 });
747 link.addEventListener('mouseout', () => {
748 link.style.textDecoration = 'none';
749 });
750
751 // 清空原文字并替换成链接
752 cell.innerHTML = '';
753 cell.appendChild(link);
754 }
755 });
756 }
757
758 // 初始化
759 function init() {
760 // 添加目标仓库列(适用于镜像列表页面)
761 if (document.querySelector('table.ui.table')) {
762 // 延迟执行以确保DOM完全加载
763 setTimeout(() => {
764 addTargetRepoColumn();
765 // 监听动态加载的行
766 const observer = new MutationObserver(() => {
767 addTargetRepoColumn();
768 });
769 observer.observe(document.body, { childList: true, subtree: true });
770 }, 1000);
771 }
772
773 // 创建填充按钮(适用于设置页面)
774 if (document.querySelector('table.ui.table')) {
775 createFillButton();
776 makePushMirrorUrlsClickable();
777 }
778
779 // 注册菜单命令
780 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
781 }
782
783 // 页面加载完成后初始化
784 if (document.readyState === 'loading') {
785 document.addEventListener('DOMContentLoaded', init);
786 } else {
787 init();
788 }
789
790 // 监听页面导航(SPA应用)
791 let currentUrl = location.href;
792 new MutationObserver(() => {
793 if (location.href !== currentUrl) {
794 currentUrl = location.href;
795 setTimeout(init, 500);
796 }
797 }).observe(document, { subtree: true, childList: true });
798})();
799
800
gitlab.user.js Surowy
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