Compare commits

..

4 Commits

Author SHA1 Message Date
208d397236 style:格式化 2026-01-30 10:31:28 +08:00
65204c3e6c style:代码格式化和更名 2026-01-30 10:27:35 +08:00
4d2d388f18 docs:更新使用方法 2026-01-30 10:05:38 +08:00
0c4cc03efc feat:访问控制和dashboard重启功能 2026-01-30 10:02:29 +08:00
7 changed files with 411 additions and 319 deletions

2
.gitignore vendored
View File

@@ -25,4 +25,4 @@ logs/
sync_database.sqlite sync_database.sqlite
//暂时忽略 //暂时忽略
app.js mappingsEditor.js

View File

@@ -126,9 +126,10 @@ curl -u username:password https://jira.example.com/rest/api/2/issue/TEST-1/trans
### 第四步:启动服务 ### 第四步:启动服务
#### 4.1 直接运行 #### 4.1 使用PM2运行
```bash ```bash
node index.js npm install -g pm2
pm2 start index.js --name gitea-jira-sync
``` ```
输出示例: 输出示例:
@@ -352,9 +353,9 @@ gitea-jira-sync/
├── package.json - 项目依赖配置 ├── package.json - 项目依赖配置
├── README.md - 本文档 ├── README.md - 本文档
├── how-to-use.md - 使用指南 ├── how-to-use.md - 使用指南
├── data/ - 数据目录 ├── data/ - 数据目录
├── logs/ - 日志目录 ├── logs/ - 日志目录
├── public/ - 前端资源目录 ├── public/ - 前端资源目录
│ ├── app.js - 配置编辑器应用脚本 │ ├── app.js - 配置编辑器应用脚本
│ ├── dashboard-app.js - 仪表板应用脚本 │ ├── dashboard-app.js - 仪表板应用脚本
│ ├── dashboard.html - 仪表板页面 │ ├── dashboard.html - 仪表板页面
@@ -372,16 +373,13 @@ gitea-jira-sync/
│ ├── syncManager.js - Gitea->Jira同步管理 │ ├── syncManager.js - Gitea->Jira同步管理
│ └── jiraSyncManager.js - Jira->Gitea同步管理 │ └── jiraSyncManager.js - Jira->Gitea同步管理
├── routes/ - 路由模块 ├── routes/ - 路由模块
│ └── editor.js - 配置编辑器路由 │ └── control.js - 配置编辑器路由
├── services/ - 第三方API服务 ├── services/ - 第三方API服务
│ ├── gitea.js - Gitea API客户端 │ ├── gitea.js - Gitea API客户端
│ └── jira.js - Jira API客户端 │ └── jira.js - Jira API客户端
└── utils/ - 工具函数 └── utils/ - 工具函数
├── logger.js - 日志模块 ├── logger.js - 日志模块
── circuitBreaker.js - 熔断器实现 ── circuitBreaker.js - 熔断器实现
└── tests_created_by_claude/
├── cleanup-test-issues.js
└── comprehensive-test.js
``` ```
## 技术栈 ## 技术栈

View File

@@ -6,7 +6,7 @@ const config = require('./src/config/env');
const { getConfiguredRepos } = require('./src/config/mappings'); const { getConfiguredRepos } = require('./src/config/mappings');
const { handleIssueEvent } = require('./src/logic/syncManager'); const { handleIssueEvent } = require('./src/logic/syncManager');
const { handleJiraHook } = require('./src/logic/jiraSyncManager'); const { handleJiraHook } = require('./src/logic/jiraSyncManager');
const editorRoutes = require('./src/routes/editor'); const editorRoutes = require('./src/routes/control');
const logger = require('./src/utils/logger'); const logger = require('./src/utils/logger');
const app = new Hono(); const app = new Hono();
@@ -47,6 +47,30 @@ setInterval(() => {
} }
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
//内网访问控制中间件保护管理界面只允许dotenv配置的域名访问
const internalOnlyMiddleware = async (c, next) => {
const pathname = new URL(c.req.url).pathname;
if (pathname.startsWith('/hooks/')) {
return await next();
}
const host = (c.req.header('host') || '').split(':')[0];
const allowedHosts = config.app.dashboardAllowedHosts;
if (!allowedHosts.some(allowed => host === allowed || host.endsWith('.' + allowed))) {
logger.security(`Blocked access from unauthorized host: ${host}`, {
path: pathname,
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
});
return c.text('Forbidden - Access denied from this domain', 403);
}
await next();
};
app.use('*', internalOnlyMiddleware);
//Gitea webhook处理入口 //Gitea webhook处理入口
app.post('/hooks/gitea', rateLimiter, async (c) => { app.post('/hooks/gitea', rateLimiter, async (c) => {
try { try {

View File

@@ -5,35 +5,35 @@ function switchTab(tab) {
btn.classList.remove('bg-indigo-600', 'text-white'); btn.classList.remove('bg-indigo-600', 'text-white');
btn.classList.add('hover:bg-slate-800', 'text-slate-400', 'hover:text-white'); btn.classList.add('hover:bg-slate-800', 'text-slate-400', 'hover:text-white');
}); });
const activeBtn = document.getElementById(`tab-${tab}`); const activeBtn = document.getElementById(`tab-${tab}`);
if (activeBtn) { if (activeBtn) {
activeBtn.classList.add('bg-indigo-600', 'text-white'); activeBtn.classList.add('bg-indigo-600', 'text-white');
activeBtn.classList.remove('hover:bg-slate-800', 'text-slate-400', 'hover:text-white'); activeBtn.classList.remove('hover:bg-slate-800', 'text-slate-400', 'hover:text-white');
} }
//切换内容区 //切换内容区
document.querySelectorAll('.tab-content').forEach(content => { document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden'); content.classList.add('hidden');
}); });
const activeContent = document.getElementById(`content-${tab}`); const activeContent = document.getElementById(`content-${tab}`);
if (activeContent) { if (activeContent) {
activeContent.classList.remove('hidden'); activeContent.classList.remove('hidden');
} }
//如果切换到日志页,开始实时加载 //如果切换到日志页,开始实时加载
if (tab === 'logs') { if (tab === 'logs') {
startLogStreaming(); startLogStreaming();
} else { } else {
stopLogStreaming(); stopLogStreaming();
} }
//如果切换到设置页,加载 .env 文件 //如果切换到设置页,加载 .env 文件
if (tab === 'settings') { if (tab === 'settings') {
loadEnvFile(); loadEnvFile();
} }
//如果切换到使用指南页,加载 README //如果切换到使用指南页,加载 README
if (tab === 'guide') { if (tab === 'guide') {
loadGuide(); loadGuide();
@@ -47,7 +47,7 @@ let lastLogSize = 0;
async function startLogStreaming() { async function startLogStreaming() {
//立即加载一次 //立即加载一次
await loadLogs(); await loadLogs();
//每2秒刷新一次 //每2秒刷新一次
logInterval = setInterval(loadLogs, 2000); logInterval = setInterval(loadLogs, 2000);
} }
@@ -63,19 +63,19 @@ async function loadLogs() {
try { try {
const res = await fetch('/api/logs'); const res = await fetch('/api/logs');
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
const logViewer = document.getElementById('log-viewer'); const logViewer = document.getElementById('log-viewer');
document.getElementById('log-filename').textContent = data.filename || 'sync_service.log'; document.getElementById('log-filename').textContent = data.filename || 'sync_service.log';
if (data.logs && data.logs.length > 0) { if (data.logs && data.logs.length > 0) {
//只在日志有变化时更新 //只在日志有变化时更新
const newContent = data.logs.map((log, index) => const newContent = data.logs.map((log, index) =>
`<div class="log-line border-l-2 border-transparent hover:border-slate-700 hover:bg-slate-800/50 pl-2 py-0.5"> `<div class="log-line border-l-2 border-transparent hover:border-slate-700 hover:bg-slate-800/50 pl-2 py-0.5">
<span class="opacity-50 select-none mr-2">${index + 1}</span>${escapeHtml(log)} <span class="opacity-50 select-none mr-2">${index + 1}</span>${escapeHtml(log)}
</div>` </div>`
).join(''); ).join('');
if (logViewer.innerHTML !== newContent) { if (logViewer.innerHTML !== newContent) {
logViewer.innerHTML = newContent; logViewer.innerHTML = newContent;
//自动滚动到底部 //自动滚动到底部
@@ -102,18 +102,18 @@ async function loadDashboardData() {
try { try {
const res = await fetch('/api/status'); const res = await fetch('/api/status');
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
//更新统计数据 //更新统计数据
document.getElementById('today-syncs').textContent = data.todaySyncs || '--'; document.getElementById('today-syncs').textContent = data.todaySyncs || '--';
document.getElementById('repo-count').textContent = data.repoCount || '--'; document.getElementById('repo-count').textContent = data.repoCount || '--';
document.getElementById('error-count').textContent = (data.errorCount + data.fatalCount) || '--'; document.getElementById('error-count').textContent = (data.errorCount + data.fatalCount) || '--';
document.getElementById('uptime').textContent = data.uptime ? `系统运行时间: ${data.uptime}` : '加载中...'; document.getElementById('uptime').textContent = data.uptime ? `系统运行时间: ${data.uptime}` : '加载中...';
//更新服务状态 //更新服务状态
updateServiceStatus(data.status); updateServiceStatus(data.status);
} }
//加载历史记录 //加载历史记录
loadHistory(); loadHistory();
} catch (e) { } catch (e) {
@@ -125,15 +125,15 @@ async function loadHistory() {
try { try {
const res = await fetch('/api/history'); const res = await fetch('/api/history');
const data = await res.json(); const data = await res.json();
if (data.success && data.history) { if (data.success && data.history) {
const tbody = document.getElementById('history-table'); const tbody = document.getElementById('history-table');
if (data.history.length === 0) { if (data.history.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">暂无历史记录</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">暂无历史记录</td></tr>';
return; return;
} }
tbody.innerHTML = data.history.map(day => ` tbody.innerHTML = data.history.map(day => `
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3 text-sm text-slate-900">${day.date}</td> <td class="px-4 py-3 text-sm text-slate-900">${day.date}</td>
@@ -153,7 +153,7 @@ async function loadHistory() {
function updateServiceStatus(status) { function updateServiceStatus(status) {
const badge = document.getElementById('status-badge'); const badge = document.getElementById('status-badge');
const statusText = document.getElementById('service-status'); const statusText = document.getElementById('service-status');
if (status === 'running') { if (status === 'running') {
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-emerald-50 text-emerald-600 border-emerald-200'; badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-emerald-50 text-emerald-600 border-emerald-200';
badge.innerHTML = ` badge.innerHTML = `
@@ -178,17 +178,24 @@ function updateServiceStatus(status) {
//控制机器人 //控制机器人
async function controlBot(action) { async function controlBot(action) {
try { try {
const res = await fetch('/api/control', { const res = await fetch('/api/restart', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }) body: JSON.stringify({ action })
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert(`操作成功: ${data.message || action}`); alert(`操作成功: ${data.message || action}`);
loadDashboardData(); //服务重启后延迟刷新页面
if (action === 'restart') {
setTimeout(() => {
location.reload();
}, 3000);
} else {
loadDashboardData();
}
} else { } else {
alert(`操作失败: ${data.error}`); alert(`操作失败: ${data.error}`);
} }
@@ -202,14 +209,14 @@ async function clearLogs() {
if (!confirm('确定要清空日志吗?此操作不可恢复。')) { if (!confirm('确定要清空日志吗?此操作不可恢复。')) {
return; return;
} }
try { try {
const res = await fetch('/api/logs/clear', { const res = await fetch('/api/logs/clear', {
method: 'POST' method: 'POST'
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert('日志已清空'); alert('日志已清空');
if (document.getElementById('content-logs').classList.contains('hidden') === false) { if (document.getElementById('content-logs').classList.contains('hidden') === false) {
@@ -230,11 +237,11 @@ function refreshStatus() {
async function loadEnvFile() { async function loadEnvFile() {
const container = document.getElementById('envEditor'); const container = document.getElementById('envEditor');
try { try {
const res = await fetch('/api/env'); const res = await fetch('/api/env');
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
const envContent = data.content; const envContent = data.content;
const envItems = parseEnvContent(envContent); const envItems = parseEnvContent(envContent);
@@ -250,10 +257,10 @@ async function loadEnvFile() {
function parseEnvContent(content) { function parseEnvContent(content) {
const lines = content.split('\n'); const lines = content.split('\n');
const items = []; const items = [];
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) { if (trimmed === '' || trimmed.startsWith('#')) {
items.push({ type: 'comment', value: line }); items.push({ type: 'comment', value: line });
} else if (trimmed.includes('=')) { } else if (trimmed.includes('=')) {
@@ -265,7 +272,7 @@ function parseEnvContent(content) {
items.push({ type: 'comment', value: line }); items.push({ type: 'comment', value: line });
} }
} }
return items; return items;
} }
@@ -275,10 +282,10 @@ function renderEnvForm(items) {
if (item.type === 'comment') { if (item.type === 'comment') {
return `<div class="text-slate-400 text-xs font-mono py-1">${escapeHtml(item.value)}</div>`; return `<div class="text-slate-400 text-xs font-mono py-1">${escapeHtml(item.value)}</div>`;
} else { } else {
const isSecret = item.key.toLowerCase().includes('token') || const isSecret = item.key.toLowerCase().includes('token') ||
item.key.toLowerCase().includes('secret') || item.key.toLowerCase().includes('secret') ||
item.key.toLowerCase().includes('password') || item.key.toLowerCase().includes('password') ||
item.key.toLowerCase().includes('pat'); item.key.toLowerCase().includes('pat');
return ` return `
<div class="flex items-center gap-3 bg-slate-50 hover:bg-slate-100 p-3 rounded border border-slate-200 transition-colors"> <div class="flex items-center gap-3 bg-slate-50 hover:bg-slate-100 p-3 rounded border border-slate-200 transition-colors">
<label class="text-slate-700 font-mono text-sm flex-shrink-0 w-48 font-medium">${escapeHtml(item.key)}</label> <label class="text-slate-700 font-mono text-sm flex-shrink-0 w-48 font-medium">${escapeHtml(item.key)}</label>
@@ -296,25 +303,25 @@ function renderEnvForm(items) {
async function saveEnvFile() { async function saveEnvFile() {
const btn = document.getElementById('saveEnvBtn'); const btn = document.getElementById('saveEnvBtn');
if (!confirm('确定要保存配置吗?保存后需要重启服务才能生效。')) { if (!confirm('确定要保存配置吗?保存后需要重启服务才能生效。')) {
return; return;
} }
btn.disabled = true; btn.disabled = true;
btn.textContent = '保存中...'; btn.textContent = '保存中...';
try { try {
const content = buildEnvContent(); const content = buildEnvContent();
const res = await fetch('/api/env', { const res = await fetch('/api/env', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }) body: JSON.stringify({ content })
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert(`保存成功!\n\n${data.message}`); alert(`保存成功!\n\n${data.message}`);
loadEnvFile(); loadEnvFile();
@@ -332,7 +339,7 @@ async function saveEnvFile() {
function buildEnvContent() { function buildEnvContent() {
const container = document.getElementById('envEditor'); const container = document.getElementById('envEditor');
const lines = []; const lines = [];
container.childNodes.forEach(node => { container.childNodes.forEach(node => {
if (node.classList && node.classList.contains('text-slate-400')) { if (node.classList && node.classList.contains('text-slate-400')) {
lines.push(node.textContent); lines.push(node.textContent);
@@ -343,17 +350,17 @@ function buildEnvContent() {
lines.push(`${key}=${value}`); lines.push(`${key}=${value}`);
} }
}); });
return lines.join('\n'); return lines.join('\n');
} }
async function loadGuide() { async function loadGuide() {
const container = document.getElementById('guide-content'); const container = document.getElementById('guide-content');
try { try {
const res = await fetch('/api/guide'); const res = await fetch('/api/guide');
const data = await res.json(); const data = await res.json();
if (data.success && data.content) { if (data.success && data.content) {
//使用marked.js渲染markdown //使用marked.js渲染markdown
if (typeof marked !== 'undefined') { if (typeof marked !== 'undefined') {
@@ -391,10 +398,10 @@ async function loadGuide() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
//默认显示dashboard //默认显示dashboard
switchTab('dashboard'); switchTab('dashboard');
//加载初始数据 //加载初始数据
loadDashboardData(); loadDashboardData();
//定期刷新仪表盘数据每30秒 //定期刷新仪表盘数据每30秒
setInterval(loadDashboardData, 30000); setInterval(loadDashboardData, 30000);
}); });

View File

@@ -1,268 +1,326 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gitea-Jira 映射配置生成器</title> <title>Gitea-Jira 映射配置生成器</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
body { font-family: 'Inter', system-ui, sans-serif; } body {
.step-card { transition: all 0.3s ease; } font-family: 'Inter', system-ui, sans-serif;
.step-active { border-left: 4px solid #3b82f6; } }
.jira-icon { width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-right: 6px; }
.step-card {
transition: all 0.3s ease;
}
.step-active {
border-left: 4px solid #3b82f6;
}
.jira-icon {
width: 16px;
height: 16px;
display: inline-block;
vertical-align: middle;
margin-right: 6px;
}
</style> </style>
</head> </head>
<body class="bg-gray-50 text-gray-800 min-h-screen p-6"> <body class="bg-gray-50 text-gray-800 min-h-screen p-6">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<header class="mb-8 flex items-center justify-between"> <header class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Gitea-Jira 映射生成器</h1>
<p class="text-sm text-gray-500 mt-1">连接 Jira自动提取 ID生成 mappings.json 配置</p>
</div>
<div class="flex items-center gap-2">
<button onclick="openSettings()" class="text-sm bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded transition">
全局设置
</button>
<div class="text-xs bg-blue-100 text-blue-800 px-3 py-1 rounded-full">v2.0</div>
</div>
</header>
<!-- 全局设置模态窗口 -->
<div id="settingsModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold mb-4">全局设置</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jira 地址 (Base URL)</label>
<input type="text" id="settingsJiraUrl" placeholder="https://your-domain.atlassian.net" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jira 用户名/邮箱</label>
<input type="text" id="settingsJiraUser" placeholder="email@example.com" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">使用 PAT 时可留空</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Token / 密码</label>
<input type="password" id="settingsJiraToken" placeholder="ATATT3xFfGF0..." class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">推荐使用 PAT (Personal Access Token)</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeSettings()" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
取消
</button>
<button onclick="saveSettings()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
保存设置
</button>
</div>
</div>
</div>
<!-- 改名模态窗口 -->
<div id="renameModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold mb-4">重命名仓库</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">当前名称</label>
<input type="text" id="renameOldName" readonly class="w-full border rounded px-3 py-2 text-sm bg-gray-100 text-gray-600">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">新名称</label>
<input type="text" id="renameNewName" placeholder="例如: owner/repo" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">格式: owner/repo</p>
</div>
</div>
<div id="renameError" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeRenameModal()" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
取消
</button>
<button onclick="confirmRename()" id="renameConfirmBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
确认改名
</button>
</div>
</div>
</div>
<!-- Step 0: 选择/新建仓库 -->
<div id="step0" class="bg-white rounded-lg shadow p-6 mb-6 step-card step-active">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">0</span>
选择仓库配置
</h2>
<div class="space-y-4">
<div> <div>
<button onclick="loadExistingMappings()" id="loadBtn" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition"> <h1 class="text-2xl font-bold text-gray-900">Gitea-Jira 映射生成器</h1>
加载现有配置 <p class="text-sm text-gray-500 mt-1">连接 Jira自动提取 ID生成 mappings.json 配置</p>
</button>
<button onclick="createNewMapping()" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition ml-2">
新建仓库配置
</button>
</div> </div>
<div class="flex items-center gap-2">
<div id="repoListContainer" class="hidden"> <button onclick="openSettings()"
<h3 class="text-sm font-semibold text-gray-700 mb-2">现有仓库配置:</h3> class="text-sm bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded transition">
<div id="repoList" class="space-y-2 max-h-64 overflow-y-auto"></div> 全局设置
</div>
<div id="newRepoContainer" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">新仓库名称</label>
<input type="text" id="newRepoName" placeholder="例如: loren/issueBotTest" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="confirmNewRepo()" class="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
确认并继续
</button> </button>
<div class="text-xs bg-blue-100 text-blue-800 px-3 py-1 rounded-full">v2.0</div>
</div> </div>
</div> </header>
<div id="step0Error" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
</div>
<!-- Step 1: 项目配置 --> <!-- 全局设置模态窗口 -->
<div id="step1" class="bg-white rounded-lg shadow p-6 mb-6 step-card hidden"> <div id="settingsModal"
<h2 class="text-lg font-semibold mb-4 flex items-center"> class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">1</span> <div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4">
项目配置 <h2 class="text-xl font-bold mb-4">全局设置</h2>
</h2> <div class="space-y-4">
<div class="mb-3 bg-blue-50 border border-blue-200 rounded p-3 flex items-center justify-between"> <div>
<p class="text-sm text-blue-800"> <label class="block text-sm font-medium text-gray-700 mb-1">Jira 地址 (Base URL)</label>
<span class="font-semibold">当前配置仓库:</span> <span id="currentRepoName" class="font-mono">-</span> <input type="text" id="settingsJiraUrl" placeholder="https://your-domain.atlassian.net"
</p> class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="backToStart()" class="text-xs bg-white hover:bg-gray-50 text-gray-700 px-3 py-1 rounded border"> </div>
返回
</button>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">目标 Jira 项目 Key</label>
<input type="text" id="projectKey" placeholder="例如: TEST" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase font-bold text-gray-700">
</div>
<!-- Sprint ID 提取区域 --> <div>
<div class="mt-4 border-t pt-4"> <label class="block text-sm font-medium text-gray-700 mb-1">Jira 用户名/邮箱</label>
<h3 class="text-sm font-semibold text-gray-700 mb-2">配置 Sprint 映射(可选)</h3> <input type="text" id="settingsJiraUser" placeholder="email@example.com"
<p class="text-xs text-gray-500 mb-3">输入一个已在目标 Sprint 中的工单 Key系统将自动提取 Sprint ID 并建立映射</p> class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<div class="flex gap-2"> <p class="text-xs text-gray-400 mt-1">使用 PAT 时可留空</p>
<input type="text" id="issueKey" placeholder="例如: TEST-196" class="w-1/5 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase"> </div>
<button onclick="fetchSprintId()" id="fetchSprintBtn" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
提取 Sprint <div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Token / 密码</label>
<input type="password" id="settingsJiraToken" placeholder="ATATT3xFfGF0..."
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">推荐使用 PAT (Personal Access Token)</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeSettings()"
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
取消
</button> </button>
<div id="sprintResult" class="flex-1 flex items-center px-3 text-sm"></div> <button onclick="saveSettings()"
</div> class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
<div id="sprintMilestone" class="hidden mt-3 flex gap-2 items-center"> 保存设置
<label class="text-xs font-medium text-gray-600 whitespace-nowrap">里程碑名称:</label>
<input type="text" id="milestoneName" placeholder="例如: v1.0.1" class="flex-1 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="addSprintMapping()" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
添加映射
</button> </button>
</div> </div>
<div id="sprintError" class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div>
<!-- Sprint 映射列表 -->
<div id="sprintListContainer" class="hidden mt-4">
<h4 class="text-xs font-semibold text-gray-600 mb-2">已添加的 Sprint 映射:</h4>
<div id="sprintList" class="space-y-2"></div>
</div>
</div> </div>
</div> </div>
<div class="mt-4 flex justify-end">
<button onclick="scanJira()" id="scanBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded shadow text-sm font-medium transition flex items-center">
<span id="scanBtnText">开始扫描</span>
</button>
</div>
<div id="scanError" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
</div>
<!-- Step 2: 交互映射 --> <!-- 改名模态窗口 -->
<div id="step2" class="bg-white rounded-lg shadow p-6 mb-6 hidden"> <div id="renameModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<h2 class="text-lg font-semibold mb-4 flex items-center"> <div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4">
<span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">2</span> <h2 class="text-xl font-bold mb-4">重命名仓库</h2>
配置映射关系 <div class="space-y-4">
</h2> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">当前名称</label>
<div class="space-y-6"> <input type="text" id="renameOldName" readonly
<!-- 优先级 --> class="w-full border rounded px-3 py-2 text-sm bg-gray-100 text-gray-600">
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">优先级映射 (Priority)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="priorityContainer">
<!-- 动态生成 -->
</div>
</div>
<!-- 工单类型 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">类型映射 (不填写的类型不会同步)(Issue Types)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="typeContainer">
<!-- 动态生成 -->
</div>
<div class="mt-2 text-sm text-gray-600 flex items-center gap-2">
<label>默认类型设置后即使Gitea没打标签也会被同步到Jira:</label>
<select id="defaultTypeSelect" class="border rounded px-2 py-1 text-sm bg-gray-50"></select>
</div>
</div>
<!-- 状态流转 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">状态动作 (Transitions)</h3>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3 mb-3 text-xs text-yellow-800 hidden" id="transWarning"></div>
<div class="grid grid-cols-1 gap-3">
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">关闭 (Close)</span>
<select id="transClose" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div> </div>
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border"> <div>
<span class="font-mono text-sm font-medium text-gray-700">重开 (Reopen)</span> <label class="block text-sm font-medium text-gray-700 mb-1">新名称</label>
<select id="transReopen" class="border rounded px-2 py-1 text-sm w-1/2"></select> <input type="text" id="renameNewName" placeholder="例如: owner/repo"
</div> class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border"> <p class="text-xs text-gray-400 mt-1">格式: owner/repo</p>
<span class="font-mono text-sm font-medium text-gray-700">处理中 (In Progress)</span>
<select id="transProgress" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div> </div>
</div> </div>
<div id="renameError"
class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeRenameModal()"
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
取消
</button>
<button onclick="confirmRename()" id="renameConfirmBtn"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
确认改名
</button>
</div>
</div> </div>
</div> </div>
<!-- JSON 预览和编辑区 --> <!-- Step 0: 选择/新建仓库 -->
<div class="mt-6 border-t pt-6"> <div id="step0" class="bg-white rounded-lg shadow p-6 mb-6 step-card step-active">
<div class="flex items-center justify-between mb-3"> <h2 class="text-lg font-semibold mb-4 flex items-center">
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide">配置预览(可编辑)</h3> <span
<button onclick="updatePreview()" class="text-xs bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded transition"> class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">0</span>
刷新预览 选择仓库配置
</h2>
<div class="space-y-4">
<div>
<button onclick="loadExistingMappings()" id="loadBtn"
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition">
加载现有配置
</button>
<button onclick="createNewMapping()"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition ml-2">
新建仓库配置
</button>
</div>
<div id="repoListContainer" class="hidden">
<h3 class="text-sm font-semibold text-gray-700 mb-2">现有仓库配置:</h3>
<div id="repoList" class="space-y-2 max-h-64 overflow-y-auto"></div>
</div>
<div id="newRepoContainer" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">新仓库名称</label>
<input type="text" id="newRepoName" placeholder="例如: loren/issueBotTest"
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="confirmNewRepo()"
class="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
确认并继续
</button>
</div>
</div>
<div id="step0Error" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200">
</div>
</div>
<!-- Step 1: 项目配置 -->
<div id="step1" class="bg-white rounded-lg shadow p-6 mb-6 step-card hidden">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<span
class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">1</span>
项目配置
</h2>
<div class="mb-3 bg-blue-50 border border-blue-200 rounded p-3 flex items-center justify-between">
<p class="text-sm text-blue-800">
<span class="font-semibold">当前配置仓库:</span> <span id="currentRepoName" class="font-mono">-</span>
</p>
<button onclick="backToStart()"
class="text-xs bg-white hover:bg-gray-50 text-gray-700 px-3 py-1 rounded border">
返回
</button> </button>
</div> </div>
<div class="relative"> <div class="grid grid-cols-1 gap-4">
<textarea id="jsonPreview" class="w-full h-96 bg-gray-900 text-gray-100 font-mono text-xs p-4 rounded outline-none border-2 border-gray-700 focus:border-blue-500 transition" spellcheck="false" placeholder="配置将在此显示,可直接编辑..."></textarea> <div>
<div id="jsonError" class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div> <label class="block text-sm font-medium text-gray-700 mb-1">目标 Jira 项目 Key</label>
<input type="text" id="projectKey" placeholder="例如: TEST"
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase font-bold text-gray-700">
</div>
<!-- Sprint ID 提取区域 -->
<div class="mt-4 border-t pt-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">配置 Sprint 映射(可选)</h3>
<p class="text-xs text-gray-500 mb-3">输入一个已在目标 Sprint 中的工单 Key系统将自动提取 Sprint ID 并建立映射</p>
<div class="flex gap-2">
<input type="text" id="issueKey" placeholder="例如: TEST-196"
class="w-1/5 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase">
<button onclick="fetchSprintId()" id="fetchSprintBtn"
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
提取 Sprint
</button>
<div id="sprintResult" class="flex-1 flex items-center px-3 text-sm"></div>
</div>
<div id="sprintMilestone" class="hidden mt-3 flex gap-2 items-center">
<label class="text-xs font-medium text-gray-600 whitespace-nowrap">里程碑名称:</label>
<input type="text" id="milestoneName" placeholder="例如: v1.0.1"
class="flex-1 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="addSprintMapping()"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
添加映射
</button>
</div>
<div id="sprintError"
class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div>
<!-- Sprint 映射列表 -->
<div id="sprintListContainer" class="hidden mt-4">
<h4 class="text-xs font-semibold text-gray-600 mb-2">已添加的 Sprint 映射:</h4>
<div id="sprintList" class="space-y-2"></div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button onclick="scanJira()" id="scanBtn"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded shadow text-sm font-medium transition flex items-center">
<span id="scanBtnText">开始扫描</span>
</button>
</div>
<div id="scanError" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200">
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end border-t pt-4"> <!-- Step 2: 交互映射 -->
<button onclick="saveToFile()" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded shadow text-sm font-medium transition"> <div id="step2" class="bg-white rounded-lg shadow p-6 mb-6 hidden">
保存到 mappings.json <h2 class="text-lg font-semibold mb-4 flex items-center">
</button> <span
</div> class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">2</span>
</div> 配置映射关系
</h2>
<!-- Step 3: 保存结果 --> <div class="space-y-6">
<div id="step3" class="bg-white rounded-lg shadow p-6 mb-12 hidden"> <!-- 优先级 -->
<h2 class="text-lg font-semibold mb-4 flex items-center justify-between"> <div>
<div class="flex items-center"> <h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">优先级映射
<span class="bg-green-100 text-green-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">3</span> (Priority)</h3>
保存成功 <div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="priorityContainer">
<!-- 动态生成 -->
</div>
</div>
<!-- 工单类型 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">类型映射
(不填写的类型不会同步)(Issue Types)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="typeContainer">
<!-- 动态生成 -->
</div>
<div class="mt-2 text-sm text-gray-600 flex items-center gap-2">
<label>默认类型设置后即使Gitea没打标签也会被同步到Jira:</label>
<select id="defaultTypeSelect" class="border rounded px-2 py-1 text-sm bg-gray-50"></select>
</div>
</div>
<!-- 状态流转 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">状态动作
(Transitions)</h3>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3 mb-3 text-xs text-yellow-800 hidden"
id="transWarning"></div>
<div class="grid grid-cols-1 gap-3">
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">关闭 (Close)</span>
<select id="transClose" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div>
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">重开 (Reopen)</span>
<select id="transReopen" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div>
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">处理中 (In Progress)</span>
<select id="transProgress" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div>
</div>
</div>
</div>
<!-- JSON 预览和编辑区 -->
<div class="mt-6 border-t pt-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide">配置预览(可编辑)</h3>
<button onclick="updatePreview()"
class="text-xs bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded transition">
刷新预览
</button>
</div>
<div class="relative">
<textarea id="jsonPreview"
class="w-full h-96 bg-gray-900 text-gray-100 font-mono text-xs p-4 rounded outline-none border-2 border-gray-700 focus:border-blue-500 transition"
spellcheck="false" placeholder="配置将在此显示,可直接编辑..."></textarea>
<div id="jsonError"
class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div>
</div>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<button onclick="saveToFile()"
class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded shadow text-sm font-medium transition">
保存到 mappings.json
</button>
</div>
</div>
<!-- Step 3: 保存结果 -->
<div id="step3" class="bg-white rounded-lg shadow p-6 mb-12 hidden">
<h2 class="text-lg font-semibold mb-4 flex items-center justify-between">
<div class="flex items-center">
<span
class="bg-green-100 text-green-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">3</span>
保存成功
</div>
</h2>
<div id="saveResult" class="text-sm"></div>
<div class="mt-4">
<button onclick="location.reload()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
继续配置其他仓库
</button>
</div> </div>
</h2>
<div id="saveResult" class="text-sm"></div>
<div class="mt-4">
<button onclick="location.reload()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
继续配置其他仓库
</button>
</div> </div>
</div> </div>
</div>
<script src="app.js"></script> <script src="mappingsEditor.js"></script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,8 @@ const config = {
rate: process.env.RATE_LIMIT_WINDOW || 10000, rate: process.env.RATE_LIMIT_WINDOW || 10000,
maxRequests: process.env.MAX_REQUESTS_PER_WINDOW || 20, maxRequests: process.env.MAX_REQUESTS_PER_WINDOW || 20,
debugMode: process.env.DEBUG_MODE === 'true', debugMode: process.env.DEBUG_MODE === 'true',
logRetentionDays: process.env.LOG_RETENTION_DAYS || 30 logRetentionDays: process.env.LOG_RETENTION_DAYS || 30,
dashboardAllowedHosts: (process.env.DASHBOARD_ALLOWED_HOSTS || 'localhost,127.0.0.1').split(',').map(h => h.trim())
}, },
gitea: { gitea: {
baseUrl: process.env.GITEA_BASE_URL, baseUrl: process.env.GITEA_BASE_URL,

View File

@@ -1,20 +1,21 @@
/** /**
* 映射关系编辑器路由模块 * 控制面板路由模块
* 提供映射配置的 CRUD 操作 Jira API 代理 * 提供映射配置的 CRUD 操作Jira API 代理和服务控制
*/ */
const { Hono } = require('hono'); const { Hono } = require('hono');
const { exec } = require('child_process');
const axios = require('axios'); const axios = require('axios');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const editor = new Hono(); const control = new Hono();
const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json'); const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json');
const LOGS_DIR = path.join(__dirname, '../../logs'); const LOGS_DIR = path.join(__dirname, '../../logs');
const README_PATH = path.join(__dirname, '../../how-to-use.md'); const README_PATH = path.join(__dirname, '../../how-to-use.md');
editor.get('/status', (c) => { control.get('/status', (c) => {
try { try {
let repoCount = 0; let repoCount = 0;
if (fs.existsSync(MAPPINGS_PATH)) { if (fs.existsSync(MAPPINGS_PATH)) {
@@ -58,7 +59,7 @@ editor.get('/status', (c) => {
}); });
//获取历史统计数据 //获取历史统计数据
editor.get('/history', (c) => { control.get('/history', (c) => {
try { try {
const history = []; const history = [];
@@ -104,7 +105,7 @@ editor.get('/history', (c) => {
}); });
//获取当日日志 //获取当日日志
editor.get('/logs', (c) => { control.get('/logs', (c) => {
try { try {
//获取今天的日志文件 //获取今天的日志文件
const today = new Date().toISOString().split('T')[0]; //YYYY-MM-DD const today = new Date().toISOString().split('T')[0]; //YYYY-MM-DD
@@ -135,7 +136,7 @@ editor.get('/logs', (c) => {
}); });
//清空当日日志 //清空当日日志
editor.post('/logs/clear', (c) => { control.post('/logs/clear', (c) => {
try { try {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${today}.log`); const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
@@ -152,35 +153,38 @@ editor.post('/logs/clear', (c) => {
} }
}); });
//控制机器人(重启 //控制机器人(支持 PM2 软重启)
editor.post('/control', async (c) => { control.post('/restart', async (c) => {
try { try {
const { action } = await c.req.json(); const { action } = await c.req.json();
logger.info(`[Control] Action received: ${action}`);
logger.info(`[Editor] Control action received: ${action}`);
//注意:实际的重启需要外部进程管理器(如 PM2
//这里只是记录日志
if (action === 'restart') { if (action === 'restart') {
logger.info('[Editor] Restart requested (requires PM2 or similar)'); logger.info('[Control] PM2 restart requested via dashboard');
return c.json({
success: true, //延迟执行,让响应先返回给客户端
message: '重启请求已记录,请使用 PM2 或其他进程管理器执行重启' setTimeout(() => {
}); exec('pm2 restart gitea-jira-sync', (err, stdout, stderr) => {
if (err) {
logger.error('[Control] PM2 restart failed:', err.message);
} else {
logger.info('[Control] PM2 restart executed successfully');
}
});
}, 500);
return c.json({ success: true, message: '正在重启服务,请稍候刷新页面...' });
} }
return c.json({ return c.json({ success: false, error: '不支持的操作' });
success: false,
error: '不支持的操作'
});
} catch (e) { } catch (e) {
logger.error('[Editor] Control error:', e.message); logger.error('[Control] Restart error:', e.message);
return c.json({ success: false, error: e.message }, 500); return c.json({ success: false, error: e.message }, 500);
} }
}); });
//读取 .env 文件 //读取 .env 文件
editor.get('/env', (c) => { control.get('/env', (c) => {
try { try {
const envPath = path.join(__dirname, '../../.env'); const envPath = path.join(__dirname, '../../.env');
@@ -200,7 +204,7 @@ editor.get('/env', (c) => {
}); });
//保存 .env 文件 //保存 .env 文件
editor.post('/env', async (c) => { control.post('/env', async (c) => {
try { try {
const { content } = await c.req.json(); const { content } = await c.req.json();
const envPath = path.join(__dirname, '../../.env'); const envPath = path.join(__dirname, '../../.env');
@@ -226,7 +230,7 @@ editor.post('/env', async (c) => {
} }
}); });
editor.get('/guide', (c) => { control.get('/guide', (c) => {
try { try {
if (!fs.existsSync(README_PATH)) { if (!fs.existsSync(README_PATH)) {
return c.json({ return c.json({
@@ -245,7 +249,7 @@ editor.get('/guide', (c) => {
}); });
//读取现有的 mappings.json //读取现有的 mappings.json
editor.get('/mappings', (c) => { control.get('/mappings', (c) => {
try { try {
if (!fs.existsSync(MAPPINGS_PATH)) { if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: true, data: { repositories: {} } }); return c.json({ success: true, data: { repositories: {} } });
@@ -262,7 +266,7 @@ editor.get('/mappings', (c) => {
}); });
//保存/更新 mappings.json //保存/更新 mappings.json
editor.post('/mappings', async (c) => { control.post('/mappings', async (c) => {
try { try {
const { repoName, config } = await c.req.json(); const { repoName, config } = await c.req.json();
@@ -292,7 +296,7 @@ editor.post('/mappings', async (c) => {
}); });
//删除仓库配置 //删除仓库配置
editor.delete('/mappings/:repoName', async (c) => { control.delete('/mappings/:repoName', async (c) => {
try { try {
const repoName = decodeURIComponent(c.req.param('repoName')); const repoName = decodeURIComponent(c.req.param('repoName'));
@@ -324,7 +328,7 @@ editor.delete('/mappings/:repoName', async (c) => {
}); });
//改名仓库配置 //改名仓库配置
editor.post('/mappings/rename', async (c) => { control.post('/mappings/rename', async (c) => {
try { try {
const { oldName, newName } = await c.req.json(); const { oldName, newName } = await c.req.json();
@@ -368,7 +372,7 @@ editor.post('/mappings/rename', async (c) => {
}); });
//保存配置接口(兼容旧版) //保存配置接口(兼容旧版)
editor.post('/save', async (c) => { control.post('/save', async (c) => {
try { try {
const newConfigObj = await c.req.json(); const newConfigObj = await c.req.json();
const repoName = Object.keys(newConfigObj)[0]; const repoName = Object.keys(newConfigObj)[0];
@@ -405,7 +409,7 @@ editor.post('/save', async (c) => {
}); });
//扫描 Jira 项目信息 //扫描 Jira 项目信息
editor.post('/scan', async (c) => { control.post('/scan', async (c) => {
const { baseUrl, auth, projectKey: rawKey } = await c.req.json(); const { baseUrl, auth, projectKey: rawKey } = await c.req.json();
const inputKey = rawKey ? rawKey.trim() : ''; const inputKey = rawKey ? rawKey.trim() : '';
@@ -482,7 +486,7 @@ editor.post('/scan', async (c) => {
}); });
//扫描 Sprint 信息 //扫描 Sprint 信息
editor.post('/scan-sprint', async (c) => { control.post('/scan-sprint', async (c) => {
const { baseUrl, auth, issueKey } = await c.req.json(); const { baseUrl, auth, issueKey } = await c.req.json();
let headers = { 'Accept': 'application/json' }; let headers = { 'Accept': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
@@ -510,7 +514,7 @@ editor.post('/scan-sprint', async (c) => {
}); });
//代理 Jira API 请求 //代理 Jira API 请求
editor.post('/proxy-jira', async (c) => { control.post('/proxy-jira', async (c) => {
const { url, auth } = await c.req.json(); const { url, auth } = await c.req.json();
try { try {
@@ -532,4 +536,4 @@ editor.post('/proxy-jira', async (c) => {
} }
}); });
module.exports = editor; module.exports = control;