gitea.user.js
· 31 KiB · JavaScript
Ham
// ==UserScript==
// @name Gitea 镜像仓库一键填充
// @namespace ScriptCat Scripts
// @version 0.2.4
// @description 在Gitea仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接
// @author Jetsung Chan <[email protected]>
// @match https://git.idev.top/*/*/settings
// @match https://git.asfd.cn/*/*/settings
// @match https://codeberg.org/*/*/settings
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_notification
// @grant GM_xmlhttpRequest
// @connect openapi-rdc.aliyuncs.com
// @connect codeup.aliyun.com
// @connect api.github.com
// @connect codeberg.org
// @connect atomgit.com
// @connect framagit.org
// @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js
// @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js
// ==/UserScript==
(function() {
'use strict';
// 平台配置
const platforms = {
github: {
name: 'GitHub',
url: 'https://github.com/',
tokenKey: 'github_token',
usernameKey: 'github_username',
domain: 'github.com'
},
atomgit: {
name: 'AtomGit',
url: 'https://atomgit.com/',
tokenKey: 'atomgit_token',
usernameKey: 'atomgit_username',
domain: 'atomgit.com'
},
codeup: {
name: 'CodeUp',
url: 'https://codeup.aliyun.com/jetsung/',
tokenKey: 'codeup_token',
usernameKey: 'codeup_username',
domain: 'codeup.aliyun.com'
},
codeberg: {
name: 'Codeberg',
url: 'https://codeberg.org/',
tokenKey: 'codeberg_token',
usernameKey: 'codeberg_username',
domain: 'codeberg.org',
type: 'gitea'
},
framagit: {
name: 'Framagit',
url: 'https://framagit.org/',
tokenKey: 'framagit_token',
usernameKey: 'framagit_username',
domain: 'framagit.org',
type: 'gitlab'
}
};
const description = document.getElementById('description').value;
// 添加样式
GM_addStyle(`
.mirror-helper-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 20px;
width: 270px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.mirror-helper-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.mirror-helper-section {
margin-bottom: 15px;
}
.mirror-helper-section h4 {
font-size: 14px;
margin: 0 0 8px 0;
color: #555;
}
.mirror-helper-input {
width: 100%;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.mirror-helper-button {
background: #1f75cb;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
margin-right: 8px;
margin-bottom: 8px;
transition: background 0.2s;
}
.mirror-helper-button:hover {
background: #1b5faa;
}
.mirror-helper-button.secondary {
background: #e6e6e6;
color: #333;
}
.mirror-helper-button.secondary:hover {
background: #d0d0d0;
}
.mirror-helper-select {
width: 100%;
padding: 8px 10px;
margin-bottom: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.mirror-helper-footer {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #eee;
font-size: 12px;
color: #777;
}
.mirror-helper-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #999;
}
.mirror-helper-close:hover {
color: #333;
}
/* 目标仓库列样式 */
.target-repo-column {
max-width: 200px;
}
.target-repo-link {
color: #0366d6;
text-decoration: none;
font-weight: 500;
display: inline-block;
padding: 2px 4px;
border-radius: 3px;
transition: background-color 0.2s;
}
.target-repo-link:hover {
background-color: rgba(3, 102, 214, 0.1);
text-decoration: none;
}
.target-repo-link svg {
width: 14px;
height: 14px;
margin-right: 4px;
vertical-align: middle;
}
.no-target-link {
color: #666;
font-style: italic;
}
.create-repo-td { white-space: nowrap; }
.create-repo-btn {
background: #28a745; color: #fff; border: none; border-radius: 4px;
padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s;
}
.create-repo-btn:hover { background: #218838; }
.create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; }
.create-repo-btn.processing { background: #fd7e14 !important; }
.create-repo-btn.success { background: #1e7e34 !important; }
.create-repo-btn.error { background: #dc3545 !important; }
`);
// 解析镜像URL获取目标仓库信息
function parseMirrorUrl(mirrorUrl) {
try {
let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://')
.replace(/\.git$/, '');
// console.log(cleanUrl);
for (const [key, platform] of Object.entries(platforms)) {
if (cleanUrl.includes(platform.domain)) {
return {
platform: platform.name,
url: cleanUrl,
key: key
};
}
}
} catch (e) {
console.error('解析镜像URL失败:', mirrorUrl, e);
}
return null;
}
// 查询群组 ID(异步函数)
async function getGitLabGroupId(token, url, ownerPath) {
let namespaceId = null;
try {
const encodedOwnerPath = encodeURIComponent(ownerPath);
const groupResp = await fetch(`${url}api/v4/groups/${encodedOwnerPath}`, {
method: 'GET',
headers: {
'PRIVATE-TOKEN': token,
'Accept': 'application/json'
}
});
if (groupResp.ok) {
const groupData = await groupResp.json();
namespaceId = groupData.id;
console.log(`找到群组 ${ownerPath},ID = ${namespaceId}`);
} else if (groupResp.status === 404) {
console.warn(`群组 ${ownerPath} 不存在,将创建在个人命名空间下`);
// namespaceId 保持 null
} else {
const err = await groupResp.text();
throw new Error(`查询群组失败: ${groupResp.status} - ${err}`);
}
} catch (err) {
console.error('查询群组出错:', err);
throw err; // 让调用方处理错误
}
return namespaceId;
}
// 在推送镜像表格的操作列中添加「创建仓库」按钮
function addTargetRepoColumn() {
// 匹配推送镜像的表格
const table = document.querySelector('table.ui.table');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
// 获取所有行,过滤掉最后的“添加镜像”表单行
const rows = tbody.querySelectorAll('tr');
rows.forEach(row => {
// 如果行内包含 form 或者 colspan="4",说明是添加镜像的输入框行,跳过
if (row.querySelector('form[action*="push-mirror-add"]') || row.querySelector('td[colspan]')) return;
// 查找操作列(右对齐的那个 td)
const actionTd = row.querySelector('td.tw-text-right');
if (!actionTd || actionTd.querySelector('.create-repo-btn')) return;
// 获取当前行的仓库 URL
const urlTd = row.querySelector('td.tw-break-anywhere');
if (!urlTd) return;
const mirrorUrl = urlTd.textContent.trim();
const info = parseMirrorUrl(mirrorUrl);
// 创建「创建仓库」按钮
const btn = document.createElement('button');
btn.className = 'create-repo-btn ui primary tiny button';
btn.style.marginRight = '4px'; // 留点间距
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>`;
btn.title = `创建仓库`;
// console.log(info);
btn.onclick = async () => {
const key = info.key;
const p = platforms[key];
const username = GM_getValue(p.usernameKey, '');
const token = GM_getValue(p.tokenKey, '');
if (!username || !token) {
alert(`${p.name} 未配置账号信息`);
return;
}
btn.disabled = true;
btn.textContent = '创建中...';
btn.classList.add('processing');
try {
// === 终极解析:支持多级子组 ===
const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
const repoName = parts.pop(); // 最后一段:仓库名
const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
let resp, data;
let namespaceId = null;
// === 统一创建接口 ===
if (key === 'github') {
let reqPath = 'user/repos'
if (username !== ownerPath) {
reqPath = `orgs/${ownerPath}/repos`
}
resp = await fetch('https://api.github.com/' + reqPath, {
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/vnd.github.v3+json'
},
body: JSON.stringify({
name: repoName,
private: false,
auto_init: false,
description: description,
})
});
} else if (p.type === 'gitea' || key === 'codeberg') {
let reqPath = 'api/v1/user/repos'
if (username !== ownerPath) {
reqPath = `api/v1/orgs/${ownerPath}/repos`
}
resp = await fetch(p.url + reqPath, {
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: repoName,
private: false,
auto_init: false,
description:description,
})
});
} else if (p.type === 'gitlab' || key === 'framagit') {
let reqPath = 'api/v4/projects'
if (username !== ownerPath) {
try {
namespaceId = await getGitLabGroupId(token, p.url, ownerPath);
} catch (err) {
console.error("获取群组ID失败", err);
}
}
resp = await fetch(p.url + reqPath, {
method: 'POST',
headers: {
'PRIVATE-TOKEN': token,
'Content-Type': 'application/json',
'Referer': p.url,
'Origin': p.url
},
body: JSON.stringify({
name: repoName,
visibility: 'private',
description: description,
namespace_id: namespaceId,
})
});
} else if (key === 'atomgit') {
let reqPath = 'api/v5/user/repos'
if (username !== ownerPath) {
reqPath = `api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`
}
resp = await fetch(`https://api.atomgit.com/` + reqPath, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: repoName,
path: repoName,
public: 1,
auto_init: false,
default_branch: 'main',
description: description,
})
});
} else if (key === 'codeup') {
const domain = 'https://openapi-rdc.aliyuncs.com';
const referer = 'https://codeup.aliyun.com';
// Step 1: 获取组织列表
const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
}));
// Step 2: 获取群组空间
const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
}));
// Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
const targetNs = namespaces.find(ns => {
const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
});
if (!targetNs) {
const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
}
// console.log('匹配群组成功:', targetNs);
// console.log(JSON.stringify({
// name: repoName,
// path: repoName,
// namespaceId: targetNs.id,
// readMeType: 'NONE'
// }));
// Step 4: 正确创建仓库(使用 organizationId + namespaceId)
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
headers: {
'x-yunxiao-token': token,
'Content-Type': 'application/json',
'Referer': referer,
'Origin': referer
},
data: JSON.stringify({
name: repoName,
path: repoName,
namespaceId: targetNs.id,
description: description,
}),
onload: r => {
const data = r.responseText ? JSON.parse(r.responseText) : {};
if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
console.log('CodeUp 仓库创建成功:', data);
resolve(data);
} else {
reject(new Error(data.message || data.error || `创建失败(${r.status})`));
}
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时'))
});
});
// 模拟成功响应
resp = { ok: true, status: 200 };
data = { id: 'success' };
}
// === 统一成功判断 ===
if (
resp.status >= 200 && resp.status < 400 ||
data.id || data.full_name || data.html_url || data.name || data.path ||
(data.message && /already exists|已存在/i.test(data.message))
) {
btn.textContent = '成功';
btn.classList.add('success');
// 自动填回 URL
const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
if (input) input.value = info.url;
} else {
throw new Error(data.message || data.error || `HTTP ${resp.status}`);
}
} catch (e) {
btn.textContent = '失败';
btn.classList.add('error');
console.error('创建仓库失败:', e);
alert(`${p.name} 创建失败:${e.message}`);
}
// 3秒后恢复
setTimeout(() => {
btn.disabled = false;
btn.textContent = '创建仓库';
btn.className = 'create-repo-btn';
}, 3000);
};
// 插入到操作按钮组的最前面
actionTd.insertBefore(btn, actionTd.firstChild);
});
}
// 创建设置面板
function createSettingsPanel() {
const panel = document.createElement('div');
panel.className = 'mirror-helper-container';
panel.id = 'mirror-settings-panel';
let html = `
<button class="mirror-helper-close" id="close-settings">×</button>
<div class="mirror-helper-header">镜像仓库设置</div>
`;
// 为每个平台创建输入框
for (const [key, platform] of Object.entries(platforms)) {
html += `
<div class="mirror-helper-section">
<h4>${platform.name}</h4>
<input type="text" class="mirror-helper-input" id="${key}-username"
placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
<input type="password" class="mirror-helper-input" id="${key}-token"
placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
</div>
`;
}
html += `
<button class="mirror-helper-button" id="save-settings">保存设置</button>
<button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
<div class="mirror-helper-footer">设置将保存在浏览器本地</div>
`;
panel.innerHTML = html;
document.body.appendChild(panel);
// 添加事件监听
document.getElementById('close-settings').addEventListener('click', () => {
panel.remove();
});
document.getElementById('save-settings').addEventListener('click', () => {
for (const [key, platform] of Object.entries(platforms)) {
const username = document.getElementById(`${key}-username`).value;
const token = document.getElementById(`${key}-token`).value;
GM_setValue(platform.usernameKey, username);
GM_setValue(platform.tokenKey, token);
}
alert('设置已保存!');
panel.remove();
location.reload(); // 刷新页面以应用新设置
});
document.getElementById('clear-settings').addEventListener('click', () => {
if (confirm('确定要清除所有设置吗?')) {
for (const platform of Object.values(platforms)) {
GM_deleteValue(platform.usernameKey);
GM_deleteValue(platform.tokenKey);
}
alert('设置已清除!');
panel.remove();
location.reload();
}
});
}
// 创建填充按钮
function createFillButton() {
const container = document.createElement('div');
container.className = 'mirror-helper-container';
container.id = 'mirror-fill-container';
let html = `
<button class="mirror-helper-close" id="close-fill">×</button>
<div class="mirror-helper-header">镜像仓库填充</div>
<div class="mirror-helper-section">
<h4>选择平台</h4>
<select class="mirror-helper-select" id="platform-select">
`;
// 添加平台选项
for (const [key, platform] of Object.entries(platforms)) {
const username = GM_getValue(platform.usernameKey, '');
const token = GM_getValue(platform.tokenKey, '');
const status = (username && token) ? '✓' : '✗';
html += `<option value="${key}">${platform.name} ${status}</option>`;
}
html += `
</select>
</div>
<button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
<button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
<div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
`;
container.innerHTML = html;
document.body.appendChild(container);
// 添加事件监听
document.getElementById('close-fill').addEventListener('click', () => {
container.remove();
});
document.getElementById('open-settings').addEventListener('click', () => {
container.remove();
createSettingsPanel();
});
document.getElementById('fill-mirror').addEventListener('click', () => {
const selectedPlatform = document.getElementById('platform-select').value;
fillMirrorForm(selectedPlatform);
});
}
// 填充表单
function fillMirrorForm(platformKey) {
const platform = platforms[platformKey];
const username = GM_getValue(platform.usernameKey, '');
const token = GM_getValue(platform.tokenKey, '');
if (!username || !token) {
alert(`请先设置 ${platform.name} 的用户名和Token!`);
return;
}
// 获取当前仓库路径
let [, group, project] = window.location.pathname.split('/');
if (!group || !project) {
alert('无法获取仓库路径!');
return;
}
switch (group) {
case 'idev':
if (platformKey === 'github') {
group = 'idevsig'
}
break;
case 'tiny':
if (platformKey === 'github' || platformKey === 'atomgit') {
group = 'tinyzen'
}
break;
}
// 构建镜像URL
const mirrorUrl = `${platform.url}${group}/${project}.git`;
// 填充表单
document.getElementById('push_mirror_address').value = mirrorUrl;
document.getElementById('push_mirror_username').value = username;
document.getElementById('push_mirror_password').value = token;
document.getElementById('push_mirror_interval').value = 0;
// 勾选"推送提交时同步"
const syncCheckbox = document.getElementById('push_mirror_sync_on_commit');
if (syncCheckbox && !syncCheckbox.checked) {
syncCheckbox.click();
}
// 显示成功消息
const successMsg = document.createElement('div');
successMsg.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #4caf50;
color: white;
padding: 12px 24px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 10000;
font-size: 14px;
`;
successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
document.body.appendChild(successMsg);
setTimeout(() => {
successMsg.remove();
}, 3000);
}
// 新增一个专门处理「推送镜像」列表中 URL 变成可点击链接的函数
function makePushMirrorUrlsClickable() {
// 找到所有推送镜像的 URL 单元格
const urlCells = document.querySelectorAll(
'table.ui.table tbody tr:not([style*="display: none"]) td.tw-break-anywhere'
);
urlCells.forEach(cell => {
// 避免重复处理
if (cell.querySelector('a')) return;
const urlText = cell.textContent.trim();
// 简单验证是否像 Git URL
if (urlText && (urlText.startsWith('http://') || urlText.startsWith('https://'))) {
const link = document.createElement('a');
link.href = urlText;
link.textContent = urlText;
link.target = '_blank'; // 新标签页打开
link.rel = 'noopener noreferrer'; // 安全最佳实践
link.style.color = '#0366d6'; // 模仿 Gitea 的蓝色链接
link.style.textDecoration = 'none';
// hover 效果(可选,更像原生链接)
link.addEventListener('mouseover', () => {
link.style.textDecoration = 'underline';
});
link.addEventListener('mouseout', () => {
link.style.textDecoration = 'none';
});
// 清空原文字并替换成链接
cell.innerHTML = '';
cell.appendChild(link);
}
});
}
// 初始化
function init() {
// 添加目标仓库列(适用于镜像列表页面)
if (document.querySelector('table.ui.table')) {
// 延迟执行以确保DOM完全加载
setTimeout(() => {
addTargetRepoColumn();
// 监听动态加载的行
const observer = new MutationObserver(() => {
addTargetRepoColumn();
});
observer.observe(document.body, { childList: true, subtree: true });
}, 1000);
}
// 创建填充按钮(适用于设置页面)
if (document.querySelector('table.ui.table')) {
createFillButton();
makePushMirrorUrlsClickable();
}
// 注册菜单命令
GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 监听页面导航(SPA应用)
let currentUrl = location.href;
new MutationObserver(() => {
if (location.href !== currentUrl) {
currentUrl = location.href;
setTimeout(init, 500);
}
}).observe(document, { subtree: true, childList: true });
})();
| 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
· 28 KiB · JavaScript
Ham
// ==UserScript==
// @name GitLab 镜像仓库一键填充
// @namespace ScriptCat Scripts
// @version 0.3.4
// @description 在GitLab仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接
// @author Jetsung Chan <[email protected]>
// @match https://framagit.org/*/-/settings/repository*
// @match https://gitlab.com/*/-/settings/repository*
// @match https://framagit.org/*/project_activity
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_notification
// @grant GM_xmlhttpRequest
// @connect openapi-rdc.aliyuncs.com
// @connect codeup.aliyun.com
// @connect api.github.com
// @connect codeberg.org
// @connect atomgit.com
// @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js
// @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js
// ==/UserScript==
(function() {
'use strict';
// 平台配置
const platforms = {
github: {
name: 'GitHub',
url: 'https://github.com/',
tokenKey: 'github_token',
usernameKey: 'github_username',
domain: 'github.com'
},
atomgit: {
name: 'AtomGit',
url: 'https://atomgit.com/',
tokenKey: 'atomgit_token',
usernameKey: 'atomgit_username',
domain: 'atomgit.com'
},
codeup: {
name: 'CodeUp',
url: 'https://codeup.aliyun.com/jetsung/',
tokenKey: 'codeup_token',
usernameKey: 'codeup_username',
domain: 'codeup.aliyun.com'
},
codeberg: {
name: 'Codeberg',
url: 'https://codeberg.org/',
tokenKey: 'codeberg_token',
usernameKey: 'codeberg_username',
domain: 'codeberg.org'
}
};
// 添加样式
GM_addStyle(`
.mirror-helper-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 20px;
width: 270px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.mirror-helper-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.mirror-helper-section {
margin-bottom: 15px;
}
.mirror-helper-section h4 {
font-size: 14px;
margin: 0 0 8px 0;
color: #555;
}
.mirror-helper-input {
width: 100%;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.mirror-helper-button {
background: #1f75cb;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
margin-right: 8px;
margin-bottom: 8px;
transition: background 0.2s;
}
.mirror-helper-button:hover {
background: #1b5faa;
}
.mirror-helper-button.secondary {
background: #e6e6e6;
color: #333;
}
.mirror-helper-button.secondary:hover {
background: #d0d0d0;
}
.mirror-helper-select {
width: 100%;
padding: 8px 10px;
margin-bottom: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.mirror-helper-footer {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #eee;
font-size: 12px;
color: #777;
}
.mirror-helper-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #999;
}
.mirror-helper-close:hover {
color: #333;
}
/* 目标仓库列样式 */
.target-repo-column {
max-width: 200px;
}
.target-repo-link {
color: #0366d6;
text-decoration: none;
font-weight: 500;
display: inline-block;
padding: 2px 4px;
border-radius: 3px;
transition: background-color 0.2s;
}
.target-repo-link:hover {
background-color: rgba(3, 102, 214, 0.1);
text-decoration: none;
}
.target-repo-link svg {
width: 14px;
height: 14px;
margin-right: 4px;
vertical-align: middle;
}
.no-target-link {
color: #666;
font-style: italic;
}
.create-repo-td { white-space: nowrap; }
.create-repo-btn {
background: #28a745; color: #fff; border: none; border-radius: 4px;
padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s;
}
.create-repo-btn:hover { background: #218838; }
.create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; }
.create-repo-btn.processing { background: #fd7e14 !important; }
.create-repo-btn.success { background: #1e7e34 !important; }
.create-repo-btn.error { background: #dc3545 !important; }
`);
// 解析镜像URL获取目标仓库信息
function parseMirrorUrl(mirrorUrl) {
try {
let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://')
.replace(/\.git$/, '');
for (const [key, platform] of Object.entries(platforms)) {
if (cleanUrl.includes(platform.domain)) {
return {
platform: platform.name,
url: cleanUrl,
displayText: '打开',
key: key
};
}
}
} catch (e) {
console.error('解析镜像URL失败:', mirrorUrl, e);
}
return null;
}
// 在表格中添加「打开」和「创建仓库」两列
function addTargetRepoColumn() {
const table = document.querySelector('table.gl-table');
if (!table) return;
const thead = table.querySelector('thead');
const tbody = table.querySelector('tbody');
if (!thead || !tbody) return;
// 防止重复添加
if (thead.querySelector('th[data-col="open"]')) return;
const firstTh = thead.querySelector('tr th:first-child');
// 1 「打开」列(保持你原来位置)
const openTh = document.createElement('th');
openTh.textContent = '目标仓库';
openTh.className = 'target-repo-column';
openTh.setAttribute('data-col', 'open');
firstTh.parentNode.insertBefore(openTh, firstTh.nextSibling);
// 2 「创建仓库」列(紧跟在打开后面)
const createTh = document.createElement('th');
createTh.textContent = '创建仓库';
createTh.setAttribute('data-col', 'create');
openTh.parentNode.insertBefore(createTh, openTh.nextSibling);
// 处理每一行
const rows = tbody.querySelectorAll('tr.rspec-mirrored-repository-row');
rows.forEach(row => {
const urlCell = row.querySelector('[data-testid="mirror-repository-url-content"]');
if (!urlCell) return;
const mirrorUrl = urlCell.textContent.trim();
const info = parseMirrorUrl(mirrorUrl);
// === 打开列(你原来的代码完全不动)===
const openTd = document.createElement('td');
openTd.className = 'target-repo-column';
if (info) {
const link = document.createElement('a');
link.href = info.url;
link.className = 'target-repo-link';
link.target = '_blank';
link.innerHTML = `
<svg viewBox="0 0 24 24" fill="currentColor">
<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"/>
</svg>
${info.displayText}
`;
openTd.appendChild(link);
} else {
openTd.innerHTML = '<span class="no-target-link">无法解析</span>';
}
const firstTd = row.querySelector('td:first-child');
firstTd.parentNode.insertBefore(openTd, firstTd.nextSibling);
// === 新增:创建仓库列 ===
const createTd = document.createElement('td');
createTd.className = 'create-repo-td';
if (info && info.key) {
const btn = document.createElement('button');
btn.className = 'create-repo-btn';
btn.textContent = '创建仓库';
btn.onclick = async () => {
const key = info.key;
const p = platforms[key];
const username = GM_getValue(p.usernameKey, '');
const token = GM_getValue(p.tokenKey, '');
if (!username || !token) {
alert(`${p.name} 未配置账号信息`);
return;
}
btn.disabled = true;
btn.textContent = '创建中...';
btn.classList.add('processing');
try {
// === 终极解析:支持多级子组 ===
const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
const repoName = parts.pop(); // 最后一段:仓库名
const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
let resp, data;
// === 统一创建接口 ===
if (key === 'github') {
resp = await fetch('https://api.github.com/user/repos', {
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/vnd.github.v3+json'
},
body: JSON.stringify({ name: repoName, private: false, auto_init: false })
});
} else if (key === 'codeberg') {
resp = await fetch('https://codeberg.org/api/v1/user/repos', {
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: repoName, private: false, auto_init: false })
});
} else if (key === 'atomgit') {
console.log(token)
resp = await fetch(`https://api.atomgit.com/api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: repoName,
path: repoName,
public: 1,
auto_init: false,
default_branch: 'main'
})
});
} else if (key === 'codeup') {
const domain = 'https://openapi-rdc.aliyuncs.com';
const referer = 'https://codeup.aliyun.com';
// Step 1: 获取组织列表
const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
}));
// Step 2: 获取群组空间
const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
}));
// Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
const targetNs = namespaces.find(ns => {
const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
});
if (!targetNs) {
const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
}
console.log('匹配群组成功:', targetNs);
console.log(JSON.stringify({
name: repoName,
path: repoName,
namespaceId: targetNs.id,
readMeType: 'NONE'
}));
// Step 4: 正确创建仓库(使用 organizationId + namespaceId)
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
headers: {
'x-yunxiao-token': token,
'Content-Type': 'application/json',
'Referer': referer,
'Origin': referer
},
data: JSON.stringify({
name: repoName,
path: repoName,
namespaceId: targetNs.id,
}),
onload: r => {
const data = r.responseText ? JSON.parse(r.responseText) : {};
if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
console.log('CodeUp 仓库创建成功:', data);
resolve(data);
} else {
reject(new Error(data.message || data.error || `创建失败(${r.status})`));
}
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时'))
});
});
// 模拟成功响应
resp = { ok: true, status: 200 };
data = { id: 'success' };
}
// === 统一成功判断 ===
if (
resp.status >= 200 && resp.status < 400 ||
data.id || data.full_name || data.html_url || data.name || data.path ||
(data.message && /already exists|已存在/i.test(data.message))
) {
btn.textContent = '成功';
btn.classList.add('success');
// 自动填回 URL
const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
if (input) input.value = info.url;
} else {
throw new Error(data.message || data.error || `HTTP ${resp.status}`);
}
} catch (e) {
btn.textContent = '失败';
btn.classList.add('error');
console.error('创建仓库失败:', e);
alert(`${p.name} 创建失败:${e.message}`);
}
// 3秒后恢复
setTimeout(() => {
btn.disabled = false;
btn.textContent = '创建仓库';
btn.className = 'create-repo-btn';
}, 3000);
};
createTd.appendChild(btn);
} else {
createTd.textContent = '—';
}
// 插入到「打开」列的后面
openTd.parentNode.insertBefore(createTd, openTd.nextSibling);
});
}
// 创建设置面板
function createSettingsPanel() {
const panel = document.createElement('div');
panel.className = 'mirror-helper-container';
panel.id = 'mirror-settings-panel';
let html = `
<button class="mirror-helper-close" id="close-settings">×</button>
<div class="mirror-helper-header">镜像仓库设置</div>
`;
// 为每个平台创建输入框
for (const [key, platform] of Object.entries(platforms)) {
html += `
<div class="mirror-helper-section">
<h4>${platform.name}</h4>
<input type="text" class="mirror-helper-input" id="${key}-username"
placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
<input type="password" class="mirror-helper-input" id="${key}-token"
placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
</div>
`;
}
html += `
<button class="mirror-helper-button" id="save-settings">保存设置</button>
<button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
<div class="mirror-helper-footer">设置将保存在浏览器本地</div>
`;
panel.innerHTML = html;
document.body.appendChild(panel);
// 添加事件监听
document.getElementById('close-settings').addEventListener('click', () => {
panel.remove();
});
document.getElementById('save-settings').addEventListener('click', () => {
for (const [key, platform] of Object.entries(platforms)) {
const username = document.getElementById(`${key}-username`).value;
const token = document.getElementById(`${key}-token`).value;
GM_setValue(platform.usernameKey, username);
GM_setValue(platform.tokenKey, token);
}
alert('设置已保存!');
panel.remove();
location.reload(); // 刷新页面以应用新设置
});
document.getElementById('clear-settings').addEventListener('click', () => {
if (confirm('确定要清除所有设置吗?')) {
for (const platform of Object.values(platforms)) {
GM_deleteValue(platform.usernameKey);
GM_deleteValue(platform.tokenKey);
}
alert('设置已清除!');
panel.remove();
location.reload();
}
});
}
// 创建填充按钮
function createFillButton() {
const container = document.createElement('div');
container.className = 'mirror-helper-container';
container.id = 'mirror-fill-container';
let html = `
<button class="mirror-helper-close" id="close-fill">×</button>
<div class="mirror-helper-header">镜像仓库填充</div>
<div class="mirror-helper-section">
<h4>选择平台</h4>
<select class="mirror-helper-select" id="platform-select">
`;
// 添加平台选项
for (const [key, platform] of Object.entries(platforms)) {
const username = GM_getValue(platform.usernameKey, '');
const token = GM_getValue(platform.tokenKey, '');
const status = (username && token) ? '✓' : '✗';
html += `<option value="${key}">${platform.name} ${status}</option>`;
}
html += `
</select>
</div>
<button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
<button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
<div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
`;
container.innerHTML = html;
document.body.appendChild(container);
// 添加事件监听
document.getElementById('close-fill').addEventListener('click', () => {
container.remove();
});
document.getElementById('open-settings').addEventListener('click', () => {
container.remove();
createSettingsPanel();
});
document.getElementById('fill-mirror').addEventListener('click', () => {
const selectedPlatform = document.getElementById('platform-select').value;
fillMirrorForm(selectedPlatform);
});
}
// 填充表单
function fillMirrorForm(platformKey) {
const platform = platforms[platformKey];
const username = GM_getValue(platform.usernameKey, '');
const token = GM_getValue(platform.tokenKey, '');
if (!username || !token) {
alert(`请先设置 ${platform.name} 的用户名和Token!`);
return;
}
// 获取当前仓库路径
let [, group, project] = window.location.pathname.split('/');
if (!group || !project) {
alert('无法获取仓库路径!');
return;
}
switch (group) {
case 'idev':
if (platformKey === 'github') {
group = 'idevsig'
}
break;
case 'tiny':
if (platformKey === 'github' || platformKey === 'atomgit') {
group = 'tinyzen'
}
break;
}
// 构建镜像URL
const mirrorUrl = `${platform.url}${group}/${project}.git`;
// 填充表单
document.getElementById('url').value = mirrorUrl;
document.getElementById('project_remote_mirrors_attributes_0_url').value = mirrorUrl;
document.getElementById('project_remote_mirrors_attributes_0_user').value = username;
document.getElementById('project_remote_mirrors_attributes_0_password').value = token;
// 勾选"仅镜像受保护的分支"
const protectedCheckbox = document.getElementById('only_protected_branches');
if (protectedCheckbox && !protectedCheckbox.checked) {
protectedCheckbox.click();
}
// 显示成功消息
const successMsg = document.createElement('div');
successMsg.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #4caf50;
color: white;
padding: 12px 24px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 10000;
font-size: 14px;
`;
successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
document.body.appendChild(successMsg);
setTimeout(() => {
successMsg.remove();
}, 3000);
}
// 初始化
function init() {
// 添加目标仓库列(适用于镜像列表页面)
if (document.querySelector('.js-mirrors-table-body')) {
// 延迟执行以确保DOM完全加载
setTimeout(() => {
addTargetRepoColumn();
// 监听动态加载的行
const observer = new MutationObserver(() => {
addTargetRepoColumn();
});
observer.observe(document.body, { childList: true, subtree: true });
}, 1000);
}
// 创建填充按钮(适用于设置页面)
if (document.querySelector('.js-mirror-form')) {
createFillButton();
}
// 注册菜单命令
GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 监听页面导航(SPA应用)
let currentUrl = location.href;
new MutationObserver(() => {
if (location.href !== currentUrl) {
currentUrl = location.href;
setTimeout(init, 500);
}
}).observe(document, { subtree: true, childList: true });
})();
| 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 |