Files
gitea-jira-task-bot/public/mappingsEditor.js

1124 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}