1124 lines
38 KiB
JavaScript
1124 lines
38 KiB
JavaScript
let fetchedData = null;
|
||
let extractedSprintId = null;
|
||
let extractedSprintName = null;
|
||
let sprintMappings = [];
|
||
let currentRepoName = null;
|
||
let existingMappings = null;
|
||
|
||
//默认的Gitea标签建议
|
||
const defaultLabels = {
|
||
'High': 'high', 'Highest': 'highest', 'Medium': 'medium', 'Low': 'low', 'Lowest': 'lowest',
|
||
'Bug': 'bug', 'Story': 'story', 'Task': 'task', 'New Feature': 'feature'
|
||
};
|
||
|
||
//为Jira字段添加新的Gitea标签输入框
|
||
function addLabel(type, jiraId, jiraName) {
|
||
const containerClass = type === 'priority' ? 'priority-labels-container' : 'type-labels-container';
|
||
const dataType = type === 'priority' ? 'priority' : 'issuetype';
|
||
const removeFunc = type === 'priority' ? 'removePriorityLabelInput' : 'removeTypeLabelInput';
|
||
const placeholder = type === 'priority' ? 'urgent' : 'defect';
|
||
|
||
const container = document.querySelector(`.${containerClass}[data-jira-id="${jiraId}"]`);
|
||
if (!container) return;
|
||
|
||
const newInput = document.createElement('div');
|
||
newInput.className = 'flex items-center gap-2';
|
||
newInput.innerHTML = `
|
||
<span class="text-xs text-gray-400">Gitea标签:</span>
|
||
<input type="text" data-type="${dataType}" data-jira-id="${jiraId}" data-jira-name="${jiraName}"
|
||
class="border rounded px-2 py-1 text-sm flex-1 focus:ring-1 focus:ring-blue-500"
|
||
placeholder="如: ${placeholder}">
|
||
<button type="button" onclick="${removeFunc}(this)"
|
||
class="text-red-600 hover:text-red-800 text-xs px-2">
|
||
删除
|
||
</button>
|
||
`;
|
||
container.appendChild(newInput);
|
||
|
||
//为新输入框绑定实时更新事件
|
||
const input = newInput.querySelector('input');
|
||
if (input) {
|
||
input.addEventListener('input', updatePreview);
|
||
}
|
||
|
||
updateLabelDeleteButtons(type, jiraId);
|
||
updatePreview();
|
||
}
|
||
|
||
//删除标签输入框
|
||
function removeLabelInput(type, btn) {
|
||
const containerClass = type === 'priority' ? 'priority-labels-container' : 'type-labels-container';
|
||
const container = btn.closest(`.${containerClass}`);
|
||
const jiraId = container.dataset.jiraId;
|
||
btn.closest('.flex').remove();
|
||
updateLabelDeleteButtons(type, jiraId);
|
||
updatePreview();
|
||
}
|
||
|
||
//更新标签删除按钮的可见性
|
||
function updateLabelDeleteButtons(type, jiraId) {
|
||
const containerClass = type === 'priority' ? 'priority-labels-container' : 'type-labels-container';
|
||
const removeFunc = type === 'priority' ? 'removePriorityLabelInput' : 'removeTypeLabelInput';
|
||
|
||
const container = document.querySelector(`.${containerClass}[data-jira-id="${jiraId}"]`);
|
||
if (!container) return;
|
||
|
||
const inputs = container.querySelectorAll('.flex');
|
||
inputs.forEach(input => {
|
||
const deleteBtn = input.querySelector(`button[onclick*="${removeFunc}"]`);
|
||
if (deleteBtn) {
|
||
if (inputs.length > 1) {
|
||
deleteBtn.classList.remove('opacity-0', 'pointer-events-none');
|
||
} else {
|
||
deleteBtn.classList.add('opacity-0', 'pointer-events-none');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
//包装函数:保持HTML兼容性
|
||
function addPriorityLabel(jiraId, jiraName) { addLabel('priority', jiraId, jiraName); }
|
||
function addTypeLabel(jiraId, jiraName) { addLabel('type', jiraId, jiraName); }
|
||
function removePriorityLabelInput(btn) { removeLabelInput('priority', btn); }
|
||
function removeTypeLabelInput(btn) { removeLabelInput('type', btn); }
|
||
function updatePriorityLabelDeleteButtons(jiraId) { updateLabelDeleteButtons('priority', jiraId); }
|
||
function updateTypeLabelDeleteButtons(jiraId) { updateLabelDeleteButtons('type', jiraId); }
|
||
|
||
//页面加载时恢复缓存的配置
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
loadGlobalSettings();
|
||
});
|
||
|
||
//加载全局设置
|
||
function loadGlobalSettings() {
|
||
const savedConfig = localStorage.getItem('jiraGlobalConfig');
|
||
if (savedConfig) {
|
||
try {
|
||
const config = JSON.parse(savedConfig);
|
||
if (config.jiraUrl) document.getElementById('settingsJiraUrl').value = config.jiraUrl;
|
||
if (config.jiraUser) document.getElementById('settingsJiraUser').value = config.jiraUser;
|
||
if (config.jiraToken) document.getElementById('settingsJiraToken').value = config.jiraToken;
|
||
} catch (e) {
|
||
console.error('Failed to load saved config:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
//打开设置窗口
|
||
function openSettings() {
|
||
loadGlobalSettings();
|
||
document.getElementById('settingsModal').classList.remove('hidden');
|
||
}
|
||
|
||
//关闭设置窗口
|
||
function closeSettings() {
|
||
document.getElementById('settingsModal').classList.add('hidden');
|
||
}
|
||
|
||
//保存全局设置
|
||
function saveSettings() {
|
||
const config = {
|
||
jiraUrl: document.getElementById('settingsJiraUrl').value.trim(),
|
||
jiraUser: document.getElementById('settingsJiraUser').value.trim(),
|
||
jiraToken: document.getElementById('settingsJiraToken').value.trim()
|
||
};
|
||
|
||
if (!config.jiraUrl) {
|
||
alert('请填写Jira地址');
|
||
return;
|
||
}
|
||
|
||
localStorage.setItem('jiraGlobalConfig', JSON.stringify(config));
|
||
closeSettings();
|
||
alert('全局设置已保存');
|
||
}
|
||
|
||
//获取全局设置
|
||
function getGlobalSettings() {
|
||
const savedConfig = localStorage.getItem('jiraGlobalConfig');
|
||
if (savedConfig) {
|
||
try {
|
||
return JSON.parse(savedConfig);
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
return {};
|
||
}
|
||
|
||
//加载现有映射配置
|
||
async function loadExistingMappings() {
|
||
const btn = document.getElementById('loadBtn');
|
||
const errBox = document.getElementById('step0Error');
|
||
|
||
btn.disabled = true;
|
||
btn.innerText = '加载中...';
|
||
errBox.classList.add('hidden');
|
||
|
||
try {
|
||
const res = await fetch('/editor/api/mappings');
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
existingMappings = data.data;
|
||
const repos = Object.keys(existingMappings.repositories || {});
|
||
|
||
if (repos.length === 0) {
|
||
throw new Error('没有找到现有配置,请新建仓库配置');
|
||
}
|
||
|
||
//显示仓库列表
|
||
const container = document.getElementById('repoListContainer');
|
||
const list = document.getElementById('repoList');
|
||
|
||
list.innerHTML = repos.map(repo => `
|
||
<div class="flex items-center justify-between bg-gray-50 border rounded p-3">
|
||
<div class="flex-1 hover:bg-gray-100 cursor-pointer py-2" onclick="selectRepo('${repo}')">
|
||
<span class="font-mono text-sm font-medium">${repo}</span>
|
||
<span class="text-xs text-gray-500 ml-2">(点击编辑)</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button onclick="event.stopPropagation(); openRenameModal('${repo}')" class="text-blue-600 hover:text-blue-800 text-xs px-3 py-1 border border-blue-300 rounded hover:bg-blue-50">
|
||
改名
|
||
</button>
|
||
<button onclick="event.stopPropagation(); deleteRepo('${repo}')" class="text-red-600 hover:text-red-800 text-xs px-3 py-1 border border-red-300 rounded hover:bg-red-50">
|
||
删除
|
||
</button>
|
||
<span class="text-blue-600">→</span>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
container.classList.remove('hidden');
|
||
|
||
} catch (e) {
|
||
showStep0Error(e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerText = '加载现有配置';
|
||
}
|
||
}
|
||
|
||
//选择要编辑的仓库
|
||
async function selectRepo(repoName) {
|
||
currentRepoName = repoName;
|
||
const config = existingMappings.repositories[repoName];
|
||
|
||
//预填充基本字段
|
||
document.getElementById('currentRepoName').innerText = repoName;
|
||
document.getElementById('projectKey').value = config.jira?.projectKey || '';
|
||
|
||
//预填充 Sprint 映射
|
||
if (config.sprints) {
|
||
sprintMappings = Object.entries(config.sprints).map(([milestone, sprintId]) => ({
|
||
milestone,
|
||
sprintId,
|
||
sprintName: ''
|
||
}));
|
||
renderSprintList();
|
||
}
|
||
|
||
//隐藏 Step 0,显示 Step 1
|
||
document.getElementById('step0').classList.add('hidden');
|
||
document.getElementById('step1').classList.remove('hidden');
|
||
document.getElementById('step1').classList.add('step-active');
|
||
|
||
//如果有项目Key,自动扫描并预填充配置
|
||
if (config.jira?.projectKey) {
|
||
const settings = getGlobalSettings();
|
||
|
||
if (settings.jiraUrl && (settings.jiraToken || settings.jiraUser)) {
|
||
await scanJiraAndLoadConfig(config);
|
||
}
|
||
}
|
||
}
|
||
|
||
//新建仓库配置
|
||
function createNewMapping() {
|
||
//清空之前的状态,避免残留数据
|
||
sprintMappings = [];
|
||
fetchedData = null;
|
||
|
||
document.getElementById('repoListContainer').classList.add('hidden');
|
||
document.getElementById('newRepoContainer').classList.remove('hidden');
|
||
}
|
||
|
||
//确认新仓库名称
|
||
function confirmNewRepo() {
|
||
const repoName = document.getElementById('newRepoName').value.trim();
|
||
|
||
if (!repoName) {
|
||
showStep0Error('请输入仓库名称');
|
||
return;
|
||
}
|
||
|
||
if (!/^[\w-]+\/[\w-]+$/.test(repoName)) {
|
||
showStep0Error('仓库名称格式不正确,应为: owner/repo');
|
||
return;
|
||
}
|
||
|
||
//清空之前的状态
|
||
sprintMappings = [];
|
||
fetchedData = null;
|
||
|
||
currentRepoName = repoName;
|
||
document.getElementById('currentRepoName').innerText = repoName;
|
||
|
||
//隐藏Step 0,显示Step 1
|
||
document.getElementById('step0').classList.add('hidden');
|
||
document.getElementById('step1').classList.remove('hidden');
|
||
document.getElementById('step1').classList.add('step-active');
|
||
}
|
||
|
||
//显示错误信息
|
||
function showErrorMsg(elementId, msg) {
|
||
const el = document.getElementById(elementId);
|
||
el.innerText = msg;
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
function showStep0Error(msg) { showErrorMsg('step0Error', msg); }
|
||
|
||
function backToStart() {
|
||
//重置状态
|
||
currentRepoName = null;
|
||
fetchedData = null;
|
||
sprintMappings = [];
|
||
|
||
//清空UI显示
|
||
document.getElementById('sprintListContainer').classList.add('hidden');
|
||
document.getElementById('sprintList').innerHTML = '';
|
||
|
||
//隐藏所有步骤
|
||
document.getElementById('step1').classList.add('hidden');
|
||
document.getElementById('step2').classList.add('hidden');
|
||
document.getElementById('step3').classList.add('hidden');
|
||
|
||
//显示 Step 0
|
||
document.getElementById('step0').classList.remove('hidden');
|
||
document.getElementById('step0').classList.add('step-active');
|
||
|
||
//重置输入框
|
||
document.getElementById('repoListContainer').classList.add('hidden');
|
||
document.getElementById('newRepoContainer').classList.add('hidden');
|
||
document.getElementById('newRepoName').value = '';
|
||
}
|
||
|
||
//删除仓库
|
||
async function deleteRepo(repoName) {
|
||
if (!confirm(`确定要删除仓库 "${repoName}" 的配置吗?\n\n此操作不可恢复!`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/editor/api/mappings/' + encodeURIComponent(repoName), {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
alert('仓库配置已删除');
|
||
|
||
//重新加载仓库列表
|
||
await loadExistingMappings();
|
||
|
||
} catch (e) {
|
||
alert('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
//打开改名模态窗口
|
||
function openRenameModal(repoName) {
|
||
document.getElementById('renameOldName').value = repoName;
|
||
document.getElementById('renameNewName').value = repoName;
|
||
document.getElementById('renameError').classList.add('hidden');
|
||
document.getElementById('renameModal').classList.remove('hidden');
|
||
|
||
//聚焦到新名称输入框并选中文本
|
||
setTimeout(() => {
|
||
const input = document.getElementById('renameNewName');
|
||
input.focus();
|
||
input.select();
|
||
}, 100);
|
||
}
|
||
|
||
//关闭改名模态窗口
|
||
function closeRenameModal() {
|
||
document.getElementById('renameModal').classList.add('hidden');
|
||
}
|
||
|
||
function showRenameError(msg) { showErrorMsg('renameError', msg); }
|
||
|
||
//确认改名
|
||
async function confirmRename() {
|
||
const oldName = document.getElementById('renameOldName').value.trim();
|
||
const newName = document.getElementById('renameNewName').value.trim();
|
||
const btn = document.getElementById('renameConfirmBtn');
|
||
|
||
//验证
|
||
if (!newName) {
|
||
showRenameError('请输入新名称');
|
||
return;
|
||
}
|
||
|
||
if (newName === oldName) {
|
||
showRenameError('新名称与当前名称相同');
|
||
return;
|
||
}
|
||
|
||
if (!/^[\w-]+\/[\w-]+$/.test(newName)) {
|
||
showRenameError('仓库名称格式不正确,应为: owner/repo');
|
||
return;
|
||
}
|
||
|
||
//检查新名称是否已存在
|
||
if (existingMappings && existingMappings.repositories && existingMappings.repositories[newName]) {
|
||
showRenameError('新名称已存在,请使用其他名称');
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.innerText = '改名中...';
|
||
|
||
try {
|
||
const res = await fetch('/editor/api/mappings/rename', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
oldName: oldName,
|
||
newName: newName
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
alert('仓库已成功改名');
|
||
closeRenameModal();
|
||
|
||
//重新加载仓库列表
|
||
await loadExistingMappings();
|
||
|
||
} catch (e) {
|
||
showRenameError('改名失败: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerText = '确认改名';
|
||
}
|
||
}
|
||
|
||
//scanJiraAndLoadConfig 已合并到 scanJira(existingConfig)
|
||
async function scanJiraAndLoadConfig(existingConfig) {
|
||
return scanJira(existingConfig);
|
||
}
|
||
|
||
//恢复标签配置到UI
|
||
function restoreLabelConfig(config, type) {
|
||
if (!config) return;
|
||
|
||
const containerClass = type === 'priority' ? 'priority-labels-container' : 'type-labels-container';
|
||
const dataType = type === 'priority' ? 'priority' : 'issuetype';
|
||
const removeFunc = type === 'priority' ? 'removePriorityLabelInput' : 'removeTypeLabelInput';
|
||
|
||
//按JiraID 分组Gitea标签
|
||
const byJiraId = {};
|
||
Object.entries(config).forEach(([label, jiraId]) => {
|
||
if (!byJiraId[jiraId]) byJiraId[jiraId] = [];
|
||
byJiraId[jiraId].push(label);
|
||
});
|
||
|
||
//为每个Jira字段填充标签
|
||
Object.entries(byJiraId).forEach(([jiraId, labels]) => {
|
||
const container = document.querySelector(`.${containerClass}[data-jira-id="${jiraId}"]`);
|
||
if (!container) return;
|
||
|
||
container.innerHTML = '';
|
||
labels.forEach(label => {
|
||
const inputDiv = document.createElement('div');
|
||
inputDiv.className = 'flex items-center gap-2';
|
||
inputDiv.innerHTML = `
|
||
<span class="text-xs text-gray-400">Gitea标签:</span>
|
||
<input type="text" data-type="${dataType}" data-jira-id="${jiraId}" data-jira-name=""
|
||
class="border rounded px-2 py-1 text-sm flex-1 focus:ring-1 focus:ring-blue-500"
|
||
value="${label}">
|
||
<button type="button" onclick="${removeFunc}(this)"
|
||
class="text-red-600 hover:text-red-800 text-xs px-2 ${labels.length <= 1 ? 'opacity-0 pointer-events-none' : ''}">
|
||
删除
|
||
</button>
|
||
`;
|
||
container.appendChild(inputDiv);
|
||
});
|
||
});
|
||
}
|
||
|
||
//将现有配置恢复到UI
|
||
function restoreConfigToUI(config) {
|
||
restoreLabelConfig(config.priorities, 'priority');
|
||
restoreLabelConfig(config.types, 'type');
|
||
|
||
if (config.jira?.defaultType) {
|
||
document.getElementById('defaultTypeSelect').value = config.jira.defaultType;
|
||
}
|
||
|
||
if (config.transitions) {
|
||
if (config.transitions.close) document.getElementById('transClose').value = config.transitions.close;
|
||
if (config.transitions.reopen) document.getElementById('transReopen').value = config.transitions.reopen;
|
||
if (config.transitions.in_progress) document.getElementById('transProgress').value = config.transitions.in_progress;
|
||
}
|
||
|
||
updatePreview();
|
||
}
|
||
|
||
async function fetchSprintId() {
|
||
const btn = document.getElementById('fetchSprintBtn');
|
||
const resultBox = document.getElementById('sprintResult');
|
||
const errBox = document.getElementById('sprintError');
|
||
const milestoneBox = document.getElementById('sprintMilestone');
|
||
|
||
const settings = getGlobalSettings();
|
||
const baseUrl = settings.jiraUrl;
|
||
const username = settings.jiraUser;
|
||
const token = settings.jiraToken;
|
||
const issueKey = document.getElementById('issueKey').value.trim().toUpperCase();
|
||
|
||
if (!baseUrl) {
|
||
showSprintError("请先在全局设置中配置Jira地址");
|
||
return;
|
||
}
|
||
|
||
if (!issueKey) {
|
||
showSprintError("请填写工单 Key");
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.innerText = "提取中...";
|
||
errBox.classList.add('hidden');
|
||
resultBox.innerHTML = '';
|
||
|
||
try {
|
||
//构造认证头(支持 PAT 和 Basic Auth)
|
||
let authHeader;
|
||
if (token && !username) {
|
||
//如果只有 token 没有 username,使用 Bearer 认证(PAT)
|
||
authHeader = 'Bearer ' + token;
|
||
} else if (username && token) {
|
||
//如果有 username 和 token/password,使用 Basic 认证
|
||
authHeader = 'Basic ' + btoa(username + ':' + token);
|
||
} else {
|
||
throw new Error('请填写认证信息(PAT 或 用户名+密码)');
|
||
}
|
||
|
||
//调用JiraAPI
|
||
const apiUrl = `${baseUrl}/rest/api/2/issue/${issueKey}`;
|
||
|
||
const res = await fetch('/editor/api/proxy-jira', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
url: apiUrl,
|
||
auth: authHeader
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error || '无法获取工单信息');
|
||
}
|
||
|
||
const issue = data.data;
|
||
const sprintField = issue.fields?.customfield_10105;
|
||
|
||
if (!sprintField || sprintField.length === 0) {
|
||
throw new Error('该工单没有关联 Sprint (customfield_10105 为空)');
|
||
}
|
||
|
||
//解析 Sprint 字符串,提取 id
|
||
const sprintStr = sprintField[0];
|
||
const idMatch = sprintStr.match(/id=(\d+)/);
|
||
const nameMatch = sprintStr.match(/name=([^,\]]+)/);
|
||
|
||
if (!idMatch) {
|
||
throw new Error('无法从 Sprint 字段中解析 ID');
|
||
}
|
||
|
||
extractedSprintId = idMatch[1];
|
||
const sprintName = nameMatch ? nameMatch[1] : 'Unknown';
|
||
extractedSprintName = sprintName;
|
||
|
||
//显示成功结果
|
||
resultBox.innerHTML = `
|
||
<div class="flex items-center gap-2 text-green-700 bg-green-50 px-3 py-1 rounded border border-green-200">
|
||
<span class="font-bold">Sprint ID: ${extractedSprintId}</span>
|
||
<span class="text-xs text-gray-600">(${sprintName})</span>
|
||
</div>
|
||
`;
|
||
|
||
//显示里程碑名称输入框,并预填充Sprint名称
|
||
milestoneBox.classList.remove('hidden');
|
||
document.getElementById('milestoneName').value = sprintName;
|
||
|
||
} catch (e) {
|
||
showSprintError(e.message);
|
||
extractedSprintId = null;
|
||
milestoneBox.classList.add('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerText = "提取 Sprint";
|
||
}
|
||
}
|
||
|
||
function showSprintError(msg) { showErrorMsg('sprintError', msg); }
|
||
|
||
function addSprintMapping() {
|
||
const milestoneName = document.getElementById('milestoneName').value.trim();
|
||
|
||
if (!milestoneName) {
|
||
showSprintError('请填写里程碑名称');
|
||
return;
|
||
}
|
||
|
||
if (!extractedSprintId) {
|
||
showSprintError('请先提取 Sprint ID');
|
||
return;
|
||
}
|
||
|
||
//检查是否已存在相同的里程碑名称
|
||
const existing = sprintMappings.find(m => m.milestone === milestoneName);
|
||
if (existing) {
|
||
if (!confirm(`里程碑 "${milestoneName}" 已存在(Sprint ID: ${existing.sprintId}),是否覆盖?`)) {
|
||
return;
|
||
}
|
||
//移除旧的
|
||
sprintMappings = sprintMappings.filter(m => m.milestone !== milestoneName);
|
||
}
|
||
|
||
//添加新映射
|
||
sprintMappings.push({
|
||
milestone: milestoneName,
|
||
sprintId: parseInt(extractedSprintId),
|
||
sprintName: extractedSprintName
|
||
});
|
||
|
||
//更新显示
|
||
renderSprintList();
|
||
|
||
//清空输入
|
||
document.getElementById('issueKey').value = '';
|
||
document.getElementById('milestoneName').value = '';
|
||
document.getElementById('sprintResult').innerHTML = '';
|
||
document.getElementById('sprintMilestone').classList.add('hidden');
|
||
document.getElementById('sprintError').classList.add('hidden');
|
||
|
||
extractedSprintId = null;
|
||
extractedSprintName = null;
|
||
|
||
//更新预览
|
||
updatePreview();
|
||
}
|
||
|
||
function renderSprintList() {
|
||
const container = document.getElementById('sprintListContainer');
|
||
const list = document.getElementById('sprintList');
|
||
|
||
if (sprintMappings.length === 0) {
|
||
container.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
container.classList.remove('hidden');
|
||
list.innerHTML = sprintMappings.map((m, index) => `
|
||
<div class="flex items-center justify-between bg-blue-50 border border-blue-200 rounded p-3">
|
||
<div class="flex items-center gap-3">
|
||
<span class="bg-blue-600 text-white text-xs px-2 py-1 rounded font-mono">${m.milestone}</span>
|
||
<span class="text-xs text-gray-600">→</span>
|
||
<span class="text-sm font-medium">Sprint ID: ${m.sprintId}</span>
|
||
<span class="text-xs text-gray-500">(${m.sprintName})</span>
|
||
</div>
|
||
<button onclick="removeSprintMapping(${index})" class="text-red-600 hover:text-red-800 text-xs">
|
||
删除
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function removeSprintMapping(index) {
|
||
sprintMappings.splice(index, 1);
|
||
renderSprintList();
|
||
updatePreview();
|
||
}
|
||
|
||
async function saveToFile() {
|
||
if (!currentRepoName) {
|
||
alert('错误:未设置仓库名称');
|
||
return;
|
||
}
|
||
|
||
if (!fetchedData) {
|
||
alert('请先扫描Jira项目信息');
|
||
return;
|
||
}
|
||
|
||
//尝试从JSON预览读取配置
|
||
const jsonPreview = document.getElementById('jsonPreview');
|
||
const jsonError = document.getElementById('jsonError');
|
||
let config;
|
||
|
||
if (jsonPreview.value.trim()) {
|
||
try {
|
||
config = JSON.parse(jsonPreview.value);
|
||
jsonError.classList.add('hidden');
|
||
} catch (e) {
|
||
jsonError.textContent = `JSON格式错误: ${e.message}`;
|
||
jsonError.classList.remove('hidden');
|
||
return;
|
||
}
|
||
} else {
|
||
//如果JSON预览为空,从表单收集配置
|
||
config = buildConfigFromForm();
|
||
}
|
||
|
||
//更新JSON预览
|
||
updatePreview();
|
||
|
||
//保存到服务器
|
||
try {
|
||
const res = await fetch('/editor/api/mappings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
repoName: currentRepoName,
|
||
config: config
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
//显示成功信息
|
||
document.getElementById('saveResult').innerHTML = `
|
||
<div class="bg-green-50 border border-green-200 rounded p-4">
|
||
<p class="text-green-800 font-semibold mb-2">配置已成功保存!</p>
|
||
<p class="text-green-700 text-sm">仓库: <span class="font-mono">${currentRepoName}</span></p>
|
||
<p class="text-green-700 text-sm">文件: <span class="font-mono">mappings.json</span></p>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('step2').classList.add('hidden');
|
||
document.getElementById('step3').classList.remove('hidden');
|
||
document.getElementById('step3').scrollIntoView({ behavior: "smooth" });
|
||
|
||
} catch (e) {
|
||
alert('保存失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
//从表单构建配置对象
|
||
function buildConfigFromForm() {
|
||
const projectId = fetchedData.project.id;
|
||
const projectKey = fetchedData.project.key;
|
||
const defaultType = document.getElementById('defaultTypeSelect').value;
|
||
|
||
//收集优先级(支持多个Gitea标签映射到同一个Jira优先级)
|
||
const priorities = {};
|
||
document.querySelectorAll('input[data-type="priority"]').forEach(input => {
|
||
const val = input.value.trim();
|
||
if (val) {
|
||
//检查是否已存在相同的Gitea标签
|
||
if (priorities[val] && priorities[val] !== input.dataset.jiraId) {
|
||
console.warn(`警告:Gitea标签 "${val}" 被映射到多个Jira优先级,将使用最后一个`);
|
||
}
|
||
priorities[val] = input.dataset.jiraId;
|
||
}
|
||
});
|
||
|
||
//收集类型(支持多个Gitea标签映射到同一个Jira类型)
|
||
const types = {};
|
||
document.querySelectorAll('input[data-type="issuetype"]').forEach(input => {
|
||
const val = input.value.trim();
|
||
if (val) {
|
||
//检查是否已存在相同的Gitea标签
|
||
if (types[val] && types[val] !== input.dataset.jiraId) {
|
||
console.warn(`警告:Gitea标签 "${val}" 被映射到多个Jira类型,将使用最后一个`);
|
||
}
|
||
types[val] = input.dataset.jiraId;
|
||
}
|
||
});
|
||
|
||
//收集流转
|
||
const transitions = {};
|
||
const closeId = document.getElementById('transClose').value;
|
||
const reopenId = document.getElementById('transReopen').value;
|
||
const progressId = document.getElementById('transProgress').value;
|
||
|
||
if (closeId) transitions['close'] = closeId;
|
||
if (reopenId) transitions['reopen'] = reopenId;
|
||
if (progressId) transitions['in_progress'] = progressId;
|
||
|
||
//生成 Sprint 配置对象
|
||
const sprints = {};
|
||
sprintMappings.forEach(m => {
|
||
sprints[m.milestone] = m.sprintId;
|
||
});
|
||
|
||
//构建配置对象
|
||
const jiraConfig = {
|
||
projectId: projectId,
|
||
projectKey: projectKey,
|
||
sprintField: "customfield_10105"
|
||
};
|
||
|
||
//只在有值时添加defaultType
|
||
if (defaultType) {
|
||
jiraConfig.defaultType = defaultType;
|
||
}
|
||
|
||
return {
|
||
jira: jiraConfig,
|
||
priorities: priorities,
|
||
types: types,
|
||
transitions: transitions,
|
||
sprints: sprints
|
||
};
|
||
}
|
||
|
||
//更新JSON预览
|
||
function updatePreview() {
|
||
if (!fetchedData) return;
|
||
|
||
const config = buildConfigFromForm();
|
||
const jsonPreview = document.getElementById('jsonPreview');
|
||
const jsonError = document.getElementById('jsonError');
|
||
|
||
try {
|
||
jsonPreview.value = JSON.stringify(config, null, 2);
|
||
jsonError.classList.add('hidden');
|
||
} catch (e) {
|
||
jsonError.textContent = `生成预览失败: ${e.message}`;
|
||
jsonError.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function scanJira(existingConfig = null) {
|
||
const btn = document.getElementById('scanBtn');
|
||
const errBox = document.getElementById('scanError');
|
||
const step2 = document.getElementById('step2');
|
||
|
||
const settings = getGlobalSettings();
|
||
const baseUrl = settings.jiraUrl;
|
||
const username = settings.jiraUser;
|
||
const token = settings.jiraToken;
|
||
const projectKey = document.getElementById('projectKey').value.trim();
|
||
|
||
if (!baseUrl) {
|
||
showError("请先在全局设置中配置Jira连接信息");
|
||
return;
|
||
}
|
||
|
||
if (!projectKey) {
|
||
showError("请输入项目 Key");
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.classList.add('opacity-75');
|
||
document.getElementById('scanBtnText').innerText = existingConfig ? "加载中..." : "扫描中...";
|
||
errBox.classList.add('hidden');
|
||
if (!existingConfig) step2.classList.add('hidden');
|
||
|
||
try {
|
||
const res = await fetch('/editor/api/scan', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
baseUrl,
|
||
projectKey,
|
||
auth: { username, password: token, token: token.length > 30 ? token : null }
|
||
})
|
||
});
|
||
|
||
const json = await res.json();
|
||
|
||
if (!json.success) {
|
||
throw new Error(json.error);
|
||
}
|
||
|
||
fetchedData = json.data;
|
||
renderMappingUI(json.data);
|
||
|
||
if (existingConfig) {
|
||
restoreConfigToUI(existingConfig);
|
||
}
|
||
|
||
step2.classList.remove('hidden');
|
||
setTimeout(() => step2.scrollIntoView({ behavior: "smooth" }), 100);
|
||
|
||
} catch (e) {
|
||
showError(e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-75');
|
||
document.getElementById('scanBtnText').innerText = "开始扫描";
|
||
}
|
||
}
|
||
|
||
function showError(msg) { showErrorMsg('scanError', msg); }
|
||
|
||
//手动扫描工单的 Transition
|
||
async function scanTransitions() {
|
||
const btn = document.getElementById('scanTransBtn');
|
||
const resultBox = document.getElementById('transResult');
|
||
const errBox = document.getElementById('transError');
|
||
const issueKey = document.getElementById('transIssueKey').value.trim().toUpperCase();
|
||
|
||
const settings = getGlobalSettings();
|
||
const baseUrl = settings.jiraUrl;
|
||
const username = settings.jiraUser;
|
||
const token = settings.jiraToken;
|
||
|
||
if (!baseUrl) {
|
||
showTransError("请先在全局设置中配置Jira地址");
|
||
return;
|
||
}
|
||
|
||
if (!issueKey) {
|
||
showTransError("请输入工单 Key");
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.innerText = "扫描中...";
|
||
errBox.classList.add('hidden');
|
||
resultBox.innerHTML = '';
|
||
|
||
try {
|
||
//构造认证头
|
||
let authHeader;
|
||
if (token && !username) {
|
||
authHeader = 'Bearer ' + token;
|
||
} else if (username && token) {
|
||
authHeader = 'Basic ' + btoa(username + ':' + token);
|
||
} else {
|
||
throw new Error('请填写认证信息(PAT 或 用户名+密码)');
|
||
}
|
||
|
||
//调用 Jira API 获取 transitions
|
||
const apiUrl = `${baseUrl}/rest/api/2/issue/${issueKey}/transitions`;
|
||
|
||
const res = await fetch('/editor/api/proxy-jira', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: apiUrl, auth: authHeader })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error || '无法获取流转信息');
|
||
}
|
||
|
||
const transitions = data.data.transitions || [];
|
||
|
||
if (transitions.length === 0) {
|
||
resultBox.innerHTML = '<span class="text-yellow-700">该工单当前状态没有可用的流转</span>';
|
||
return;
|
||
}
|
||
|
||
//追加到下拉菜单(去重)
|
||
const transSelects = ['transClose', 'transReopen', 'transProgress'];
|
||
let addedCount = 0;
|
||
|
||
transitions.forEach(t => {
|
||
transSelects.forEach(selectId => {
|
||
const sel = document.getElementById(selectId);
|
||
//检查是否已存在(按 ID 去重)
|
||
const exists = Array.from(sel.options).some(opt => opt.value === t.id);
|
||
if (!exists) {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.id;
|
||
opt.text = `${t.name} (To: ${t.to?.name || 'Unknown'})`;
|
||
sel.add(opt);
|
||
addedCount++;
|
||
}
|
||
});
|
||
});
|
||
|
||
//显示结果
|
||
const newTransNames = transitions.map(t => `${t.name} → ${t.to?.name || '?'}`).join(', ');
|
||
resultBox.innerHTML = `
|
||
<div class="flex items-center gap-2 text-green-700 bg-green-50 px-3 py-2 rounded border border-green-200">
|
||
<span>扫描到 <strong>${transitions.length}</strong> 个流转: ${newTransNames}</span>
|
||
</div>
|
||
`;
|
||
|
||
//同时更新 fetchedData 以保持一致
|
||
if (fetchedData) {
|
||
transitions.forEach(t => {
|
||
const exists = fetchedData.transitions.some(existing => existing.id === t.id);
|
||
if (!exists) {
|
||
fetchedData.transitions.push({
|
||
id: t.id,
|
||
name: t.name,
|
||
to: t.to?.name || 'Unknown'
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
} catch (e) {
|
||
showTransError(e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerText = "扫描流转";
|
||
}
|
||
}
|
||
|
||
function showTransError(msg) { showErrorMsg('transError', msg); }
|
||
|
||
function renderMappingUI(data) {
|
||
//渲染优先级
|
||
const pContainer = document.getElementById('priorityContainer');
|
||
pContainer.innerHTML = '';
|
||
data.priorities.forEach(p => {
|
||
let guess = "";
|
||
for (let k in defaultLabels) {
|
||
if (p.name.includes(k)) guess = defaultLabels[k];
|
||
}
|
||
|
||
const itemDiv = document.createElement('div');
|
||
itemDiv.className = 'bg-gray-50 p-3 rounded border';
|
||
itemDiv.dataset.jiraId = p.id;
|
||
itemDiv.innerHTML = `
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
<img src="${p.iconUrl}" class="w-4 h-4">
|
||
<span class="text-sm font-medium">${p.name}</span>
|
||
</div>
|
||
<button type="button" onclick="addPriorityLabel('${p.id}', '${p.name}')"
|
||
class="text-xs text-blue-600 hover:text-blue-800 border border-blue-300 px-2 py-1 rounded hover:bg-blue-50">
|
||
+ 添加标签
|
||
</button>
|
||
</div>
|
||
<div class="priority-labels-container space-y-1" data-jira-id="${p.id}">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-gray-400">Gitea标签:</span>
|
||
<input type="text" data-type="priority" data-jira-id="${p.id}" data-jira-name="${p.name}"
|
||
class="border rounded px-2 py-1 text-sm flex-1 focus:ring-1 focus:ring-blue-500"
|
||
placeholder="如: ${guess || 'urgent'}" value="${guess}">
|
||
<button type="button" onclick="removePriorityLabelInput(this)"
|
||
class="text-red-600 hover:text-red-800 text-xs px-2 opacity-0 pointer-events-none">
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
pContainer.appendChild(itemDiv);
|
||
});
|
||
|
||
//渲染类型
|
||
const tContainer = document.getElementById('typeContainer');
|
||
const defaultSelect = document.getElementById('defaultTypeSelect');
|
||
tContainer.innerHTML = '';
|
||
defaultSelect.innerHTML = '';
|
||
|
||
//添加"不设置"选项
|
||
const emptyOption = document.createElement('option');
|
||
emptyOption.value = '';
|
||
emptyOption.text = '(不设置默认类型)';
|
||
defaultSelect.add(emptyOption);
|
||
|
||
data.types.forEach((t, index) => {
|
||
let guess = "";
|
||
for (let k in defaultLabels) {
|
||
if (t.name.includes(k)) guess = defaultLabels[k];
|
||
}
|
||
|
||
const itemDiv = document.createElement('div');
|
||
itemDiv.className = 'bg-gray-50 p-3 rounded border';
|
||
itemDiv.dataset.jiraId = t.id;
|
||
itemDiv.innerHTML = `
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
<img src="${t.iconUrl}" class="w-4 h-4">
|
||
<span class="text-sm font-medium">${t.name}</span>
|
||
</div>
|
||
<button type="button" onclick="addTypeLabel('${t.id}', '${t.name}')"
|
||
class="text-xs text-blue-600 hover:text-blue-800 border border-blue-300 px-2 py-1 rounded hover:bg-blue-50">
|
||
+ 添加标签
|
||
</button>
|
||
</div>
|
||
<div class="type-labels-container space-y-1" data-jira-id="${t.id}">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-gray-400">Gitea标签:</span>
|
||
<input type="text" data-type="issuetype" data-jira-id="${t.id}" data-jira-name="${t.name}"
|
||
class="border rounded px-2 py-1 text-sm flex-1 focus:ring-1 focus:ring-blue-500"
|
||
placeholder="如: ${guess || 'bug'}" value="${guess}">
|
||
<button type="button" onclick="removeTypeLabelInput(this)"
|
||
class="text-red-600 hover:text-red-800 text-xs px-2 opacity-0 pointer-events-none">
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
tContainer.appendChild(itemDiv);
|
||
|
||
const option = document.createElement('option');
|
||
option.value = t.id;
|
||
option.text = t.name;
|
||
defaultSelect.add(option);
|
||
if (t.name.toLowerCase() === 'bug' || t.name.toLowerCase() === 'task') defaultSelect.value = t.id;
|
||
});
|
||
|
||
//渲染流转
|
||
const transSelects = ['transClose', 'transReopen', 'transProgress'];
|
||
const warningBox = document.getElementById('transWarning');
|
||
|
||
if (data.warning) {
|
||
warningBox.innerText = data.warning;
|
||
warningBox.classList.remove('hidden');
|
||
} else {
|
||
warningBox.classList.add('hidden');
|
||
}
|
||
|
||
transSelects.forEach(id => {
|
||
const sel = document.getElementById(id);
|
||
sel.innerHTML = '<option value="">(不映射)</option>';
|
||
data.transitions.forEach(t => {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.id;
|
||
opt.text = `${t.name} (To: ${t.to})`;
|
||
sel.add(opt);
|
||
|
||
const lowerName = t.name.toLowerCase();
|
||
const lowerTo = t.to.toLowerCase();
|
||
if (id === 'transClose' && (lowerName.includes('close') || lowerName.includes('done') || lowerName.includes('complete'))) sel.value = t.id;
|
||
if (id === 'transReopen' && (lowerName.includes('reopen') || lowerName.includes('open'))) sel.value = t.id;
|
||
if (id === 'transProgress' && (lowerName.includes('progress') || lowerName.includes('process') || lowerTo.includes('progress'))) sel.value = t.id;
|
||
});
|
||
});
|
||
|
||
//为所有输入框添加input事件,自动更新预览(使用input替代change以实现实时更新)
|
||
document.querySelectorAll('input[data-type]').forEach(el => {
|
||
el.addEventListener('input', updatePreview);
|
||
});
|
||
document.querySelectorAll('select').forEach(el => {
|
||
el.addEventListener('change', updatePreview);
|
||
});
|
||
|
||
//初始化预览
|
||
updatePreview();
|
||
} |