feat: 项目服务管理面板 v1.0.0

功能:
- 项目列表展示(Web服务、Cron任务、CLI工具、插件)
- 实时状态检测(端口检测、健康检查)
- 服务控制(启动、停止、重启)
- 日志查看
- 类型筛选
- 支持动态添加新项目(API)

项目配置存储在 projects.json,便于扩展新项目
This commit is contained in:
2026-04-11 13:06:15 +08:00
commit 328fd4c16c
2 changed files with 967 additions and 0 deletions

828
app.py Normal file
View File

@@ -0,0 +1,828 @@
#!/usr/bin/env python3
"""
项目服务管理面板
端口: 19013
"""
import os
import json
import subprocess
import signal
import threading
from datetime import datetime
from flask import Flask, render_template_string, jsonify, request
import urllib.request
import urllib.error
app = Flask(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WORKSPACE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECTS_FILE = os.path.join(BASE_DIR, 'projects.json')
# 进程状态缓存
process_cache = {}
def load_projects():
"""加载项目配置"""
with open(PROJECTS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)['projects']
def save_projects(projects):
"""保存项目配置"""
with open(PROJECTS_FILE, 'w', encoding='utf-8') as f:
json.dump({'projects': projects}, f, ensure_ascii=False, indent=2)
def check_port(port):
"""检查端口是否有进程监听"""
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(('localhost', port))
sock.close()
return result == 0
except:
return False
def check_health(url):
"""检查健康检查URL"""
try:
req = urllib.request.Request(url, method='GET')
with urllib.request.urlopen(req, timeout=3) as response:
return response.status == 200
except:
return False
def get_process_on_port(port):
"""获取占用端口的进程信息"""
try:
result = subprocess.run(
f"lsof -i :{port} -t 2>/dev/null | head -1",
shell=True, capture_output=True, text=True, timeout=5
)
pid = result.stdout.strip()
if pid:
# 获取进程信息
cmd_result = subprocess.run(
f"ps -p {pid} -o pid,ppid,user,cmd --no-headers 2>/dev/null",
shell=True, capture_output=True, text=True
)
return {
'pid': int(pid),
'info': cmd_result.stdout.strip()
}
except:
pass
return None
def get_project_status(project):
"""获取项目状态"""
project_type = project.get('type', 'web')
if project_type == 'web':
ports = project.get('ports', [])
if not ports:
return {'status': 'unknown', 'message': '未配置端口'}
port_status = {}
all_running = True
for port in ports:
running = check_port(port)
port_status[port] = {
'running': running,
'process': get_process_on_port(port) if running else None
}
if not running:
all_running = False
# 健康检查
health_ok = None
if all_running and project.get('health_url'):
health_ok = check_health(project['health_url'])
return {
'status': 'running' if all_running else ('partial' if any(p['running'] for p in port_status.values()) else 'stopped'),
'ports': port_status,
'health': health_ok
}
elif project_type == 'cron':
# 检查cron任务是否配置
cron_expr = project.get('cron', '')
cron_cmd = project.get('cron_cmd', '')
try:
result = subprocess.run(
f"crontab -l 2>/dev/null | grep -F '{cron_cmd}'",
shell=True, capture_output=True, text=True
)
cron_configured = bool(result.stdout.strip())
except:
cron_configured = False
return {
'status': 'configured' if cron_configured else 'not_configured',
'cron': cron_expr,
'cron_configured': cron_configured
}
elif project_type == 'extension':
return {'status': 'completed', 'message': '浏览器插件,手动安装'}
elif project_type == 'cli':
return {'status': 'ready', 'message': '命令行工具,按需运行'}
return {'status': 'unknown'}
def run_command(cmd, cwd, project_id, action):
"""异步执行命令"""
def _run():
try:
log_file = os.path.join(BASE_DIR, 'logs', f'{project_id}.log')
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, 'a') as f:
f.write(f"\n{'='*50}\n")
f.write(f"[{datetime.now().isoformat()}] {action}\n")
f.write(f"Command: {cmd}\n")
f.write(f"Directory: {cwd}\n")
process = subprocess.Popen(
cmd,
shell=True,
cwd=cwd,
stdout=open(log_file, 'a'),
stderr=subprocess.STDOUT,
start_new_session=True
)
process_cache[project_id] = process.pid
except Exception as e:
with open(os.path.join(BASE_DIR, 'logs', 'error.log'), 'a') as f:
f.write(f"[{datetime.now().isoformat()}] Error: {e}\n")
thread = threading.Thread(target=_run)
thread.daemon = True
thread.start()
def stop_process(port):
"""停止占用端口的进程"""
try:
result = subprocess.run(
f"lsof -i :{port} -t 2>/dev/null",
shell=True, capture_output=True, text=True, timeout=5
)
pids = result.stdout.strip().split('\n')
pids = [p for p in pids if p]
for pid in pids:
try:
os.kill(int(pid), signal.SIGTERM)
except:
pass
return len(pids)
except:
return 0
# HTML模板
HTML_TEMPLATE = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目服务管理面板</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
body { background: #0f172a; }
.card { background: #1e293b; border: 1px solid #334155; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; }
.status-running { background: #22c55e; box-shadow: 0 0 10px #22c55e; }
.status-stopped { background: #ef4444; }
.status-partial { background: #f59e0b; }
.status-ready { background: #3b82f6; }
.status-configured { background: #22c55e; }
.status-not-configured { background: #6b7280; }
.status-completed { background: #8b5cf6; }
.btn { transition: all 0.2s; }
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.type-badge { font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body class="min-h-screen text-gray-100">
<div class="container mx-auto px-4 py-8">
<!-- 头部 -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i class="ri-dashboard-3-line text-blue-400"></i>
项目服务管理面板
</h1>
<p class="text-gray-400 mt-1">统一管理所有项目和服务</p>
</div>
<div class="flex items-center gap-4">
<button onclick="refreshAll()" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg flex items-center gap-2">
<i class="ri-refresh-line"></i> 刷新状态
</button>
<span id="updateTime" class="text-gray-400 text-sm"></span>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">Web服务</p>
<p id="webCount" class="text-2xl font-bold text-blue-400">-</p>
</div>
<i class="ri-server-line text-3xl text-blue-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">运行中</p>
<p id="runningCount" class="text-2xl font-bold text-green-400">-</p>
</div>
<i class="ri-play-circle-line text-3xl text-green-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">Cron任务</p>
<p id="cronCount" class="text-2xl font-bold text-yellow-400">-</p>
</div>
<i class="ri-time-line text-3xl text-yellow-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">CLI工具</p>
<p id="cliCount" class="text-2xl font-bold text-purple-400">-</p>
</div>
<i class="ri-terminal-box-line text-3xl text-purple-400 opacity-50"></i>
</div>
</div>
</div>
<!-- 筛选器 -->
<div class="flex gap-2 mb-6">
<button onclick="filterType('all')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="all">
全部
</button>
<button onclick="filterType('web')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="web">
Web服务
</button>
<button onclick="filterType('cron')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="cron">
Cron任务
</button>
<button onclick="filterType('cli')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="cli">
CLI工具
</button>
<button onclick="filterType('extension')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="extension">
插件
</button>
</div>
<!-- 项目列表 -->
<div id="projectsList" class="space-y-4">
<div class="text-center py-12 text-gray-400">
<i class="ri-loader-4-line text-4xl animate-spin"></i>
<p class="mt-2">加载中...</p>
</div>
</div>
</div>
<!-- 日志模态框 -->
<div id="logModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="card rounded-xl w-11/12 max-w-4xl max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h3 id="logTitle" class="font-bold text-lg">日志</h3>
<button onclick="closeLogModal()" class="text-gray-400 hover:text-white">
<i class="ri-close-line text-xl"></i>
</button>
</div>
<div class="flex-1 overflow-auto p-4">
<pre id="logContent" class="text-sm text-gray-300 font-mono whitespace-pre-wrap"></pre>
</div>
</div>
</div>
<script>
let projects = [];
let currentFilter = 'all';
async function loadProjects() {
try {
const res = await fetch('/api/projects');
const data = await res.json();
projects = data.projects;
renderProjects();
updateStats();
} catch (e) {
console.error('加载失败:', e);
}
}
function updateStats() {
const webProjects = projects.filter(p => p.type === 'web');
const runningProjects = webProjects.filter(p => p.status?.status === 'running');
const cronProjects = projects.filter(p => p.type === 'cron');
const cliProjects = projects.filter(p => p.type === 'cli' || p.type === 'extension');
document.getElementById('webCount').textContent = webProjects.length;
document.getElementById('runningCount').textContent = `${runningProjects.length}/${webProjects.length}`;
document.getElementById('cronCount').textContent = cronProjects.length;
document.getElementById('cliCount').textContent = cliProjects.length;
document.getElementById('updateTime').textContent = new Date().toLocaleTimeString();
}
function filterType(type) {
currentFilter = type;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('bg-blue-600', btn.dataset.type === type);
btn.classList.toggle('bg-gray-700', btn.dataset.type !== type);
});
renderProjects();
}
function renderProjects() {
const list = document.getElementById('projectsList');
const filtered = currentFilter === 'all'
? projects
: projects.filter(p => p.type === currentFilter);
if (filtered.length === 0) {
list.innerHTML = `
<div class="text-center py-12 text-gray-400">
<i class="ri-folder-open-line text-4xl"></i>
<p class="mt-2">暂无项目</p>
</div>
`;
return;
}
// 按类型分组
const grouped = {};
filtered.forEach(p => {
if (!grouped[p.type]) grouped[p.type] = [];
grouped[p.type].push(p);
});
let html = '';
const typeNames = {
'web': { name: 'Web服务', icon: 'ri-server-line', color: 'blue' },
'cron': { name: 'Cron任务', icon: 'ri-time-line', color: 'yellow' },
'cli': { name: 'CLI工具', icon: 'ri-terminal-box-line', color: 'purple' },
'extension': { name: '浏览器插件', icon: 'ri-chrome-line', color: 'pink' }
};
for (const [type, projs] of Object.entries(grouped)) {
const typeInfo = typeNames[type] || { name: type, icon: 'ri-folder-line', color: 'gray' };
html += `
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2">
<i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>
${typeInfo.name}
<span class="text-sm text-gray-500">(${projs.length})</span>
</h2>
<div class="grid gap-4">
`;
projs.forEach(p => {
html += renderProjectCard(p);
});
html += `</div></div>`;
}
list.innerHTML = html;
}
function renderProjectCard(p) {
const statusInfo = getStatusInfo(p.status?.status);
const typeColors = {
'web': 'bg-blue-500/20 text-blue-400',
'cron': 'bg-yellow-500/20 text-yellow-400',
'cli': 'bg-purple-500/20 text-purple-400',
'extension': 'bg-pink-500/20 text-pink-400'
};
let portsHtml = '';
if (p.ports && p.ports.length > 0) {
portsHtml = `
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-400">端口:</span>
${p.ports.map(port => {
const portStatus = p.status?.ports?.[port];
const isRunning = portStatus?.running;
return `<span class="px-2 py-0.5 rounded ${isRunning ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">${port}</span>`;
}).join('')}
</div>
`;
}
let actionsHtml = '';
if (p.type === 'web') {
const isRunning = p.status?.status === 'running';
const isPartial = p.status?.status === 'partial';
actionsHtml = `
<div class="flex items-center gap-2 mt-3">
${isRunning || isPartial ? `
<button onclick="stopProject('${p.id}')" class="btn bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-stop-line"></i> 停止
</button>
<button onclick="restartProject('${p.id}')" class="btn bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-restart-line"></i> 重启
</button>
` : `
<button onclick="startProject('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-play-line"></i> 启动
</button>
`}
<button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-file-list-line"></i> 日志
</button>
</div>
`;
} else if (p.type === 'cli') {
actionsHtml = `
<div class="flex items-center gap-2 mt-3">
<button onclick="runCli('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-play-line"></i> 运行
</button>
<button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-file-list-line"></i> 日志
</button>
</div>
`;
} else if (p.type === 'cron') {
const configured = p.status?.cron_configured;
actionsHtml = `
<div class="flex items-center gap-2 mt-3">
<span class="text-sm ${configured ? 'text-green-400' : 'text-gray-400'}">
<i class="ri-${configured ? 'check' : 'close'}-line"></i>
${configured ? '已配置' : '未配置'}
</span>
<span class="text-sm text-gray-500">${p.cron || ''}</span>
</div>
`;
}
let linksHtml = '';
if (p.ports && p.ports.length > 0) {
const mainPort = p.ports[0];
linksHtml = `
<a href="http://localhost:${mainPort}" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm">
<i class="ri-external-link-line"></i> 访问
</a>
`;
if (p.admin_url) {
linksHtml += `
<span class="text-gray-600">|</span>
<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300 text-sm">
<i class="ri-settings-3-line"></i> 后台
</a>
`;
}
}
if (p.git_repo) {
linksHtml += `
<span class="text-gray-600">|</span>
<a href="${p.git_repo}" target="_blank" class="text-gray-400 hover:text-gray-300 text-sm">
<i class="ri-git-repository-line"></i> Git
</a>
`;
}
return `
<div class="card rounded-xl p-4 hover:border-gray-500 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="status-dot ${statusInfo.class}" title="${statusInfo.text}"></div>
<h3 class="font-semibold text-lg">${p.name}</h3>
<span class="type-badge ${typeColors[p.type]}">${p.type.toUpperCase()}</span>
${p.version ? `<span class="text-xs text-gray-500">${p.version}</span>` : ''}
</div>
<p class="text-gray-400 text-sm mb-2">${p.description || ''}</p>
${portsHtml}
<div class="flex items-center gap-4 mt-2 text-sm">
${linksHtml}
</div>
</div>
<div class="text-right">
<span class="text-sm ${statusInfo.textColor}">${statusInfo.text}</span>
${p.status?.health !== null && p.status?.health !== undefined ? `
<div class="text-xs mt-1 ${p.status.health ? 'text-green-400' : 'text-red-400'}">
<i class="ri-${p.status.health ? 'heart' : 'heart-line'}"></i>
${p.status.health ? '健康' : '异常'}
</div>
` : ''}
</div>
</div>
${actionsHtml}
</div>
`;
}
function getStatusInfo(status) {
const map = {
'running': { text: '运行中', class: 'status-running', textColor: 'text-green-400' },
'stopped': { text: '已停止', class: 'status-stopped', textColor: 'text-red-400' },
'partial': { text: '部分运行', class: 'status-partial', textColor: 'text-yellow-400' },
'ready': { text: '就绪', class: 'status-ready', textColor: 'text-blue-400' },
'configured': { text: '已配置', class: 'status-configured', textColor: 'text-green-400' },
'not_configured': { text: '未配置', class: 'status-not-configured', textColor: 'text-gray-400' },
'completed': { text: '已完成', class: 'status-completed', textColor: 'text-purple-400' },
'unknown': { text: '未知', class: 'bg-gray-500', textColor: 'text-gray-400' }
};
return map[status] || map['unknown'];
}
async function startProject(id) {
if (!confirm('确定要启动此服务吗?')) return;
try {
const res = await fetch(`/api/projects/${id}/start`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
setTimeout(loadProjects, 2000);
} catch (e) {
alert('启动失败: ' + e.message);
}
}
async function stopProject(id) {
if (!confirm('确定要停止此服务吗?')) return;
try {
const res = await fetch(`/api/projects/${id}/stop`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
setTimeout(loadProjects, 1000);
} catch (e) {
alert('停止失败: ' + e.message);
}
}
async function restartProject(id) {
if (!confirm('确定要重启此服务吗?')) return;
try {
await fetch(`/api/projects/${id}/stop`, { method: 'POST' });
setTimeout(async () => {
const res = await fetch(`/api/projects/${id}/start`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
loadProjects();
}, 2000);
} catch (e) {
alert('重启失败: ' + e.message);
}
}
async function runCli(id) {
if (!confirm('确定要运行此CLI工具吗')) return;
try {
const res = await fetch(`/api/projects/${id}/run`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
} catch (e) {
alert('运行失败: ' + e.message);
}
}
async function viewLog(id) {
const project = projects.find(p => p.id === id);
document.getElementById('logTitle').textContent = `${project?.name || id} - 日志`;
document.getElementById('logModal').classList.remove('hidden');
document.getElementById('logModal').classList.add('flex');
try {
const res = await fetch(`/api/projects/${id}/log`);
const data = await res.json();
document.getElementById('logContent').textContent = data.log || '暂无日志';
} catch (e) {
document.getElementById('logContent').textContent = '获取日志失败: ' + e.message;
}
}
function closeLogModal() {
document.getElementById('logModal').classList.add('hidden');
document.getElementById('logModal').classList.remove('flex');
}
function refreshAll() {
loadProjects();
}
// 初始化
loadProjects();
// 每30秒自动刷新
setInterval(loadProjects, 30000);
</script>
</body>
</html>
'''
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/projects')
def api_projects():
projects = load_projects()
for project in projects:
project['status'] = get_project_status(project)
return jsonify({'projects': projects})
@app.route('/api/projects/<project_id>')
def api_project(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
project['status'] = get_project_status(project)
return jsonify(project)
@app.route('/api/projects/<project_id>/start', methods=['POST'])
def api_start(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
if project['type'] != 'web':
return jsonify({'error': '只有Web服务支持启动'}), 400
directory = project.get('directory', '')
if directory.startswith('/'):
cwd = directory
else:
cwd = os.path.join(WORKSPACE_DIR, directory)
if not os.path.exists(cwd):
return jsonify({'error': f'目录不存在: {cwd}'}), 400
# 检查是否有多个启动命令
if project.get('start_cmds'):
for name, info in project['start_cmds'].items():
cmd = info['cmd']
run_command(cmd, cwd, project_id, f'start-{name}')
return jsonify({'message': '服务启动中...'})
else:
cmd = project.get('start_cmd', 'python3 app.py')
run_command(cmd, cwd, project_id, 'start')
return jsonify({'message': '服务启动中...'})
@app.route('/api/projects/<project_id>/stop', methods=['POST'])
def api_stop(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
if project['type'] != 'web':
return jsonify({'error': '只有Web服务支持停止'}), 400
ports = project.get('ports', [])
stopped = 0
for port in ports:
stopped += stop_process(port)
return jsonify({'message': f'已停止 {stopped} 个进程'})
@app.route('/api/projects/<project_id>/run', methods=['POST'])
def api_run(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
if project['type'] != 'cli':
return jsonify({'error': '只有CLI工具支持运行'}), 400
directory = project.get('directory', '')
if directory.startswith('/'):
cwd = directory
else:
cwd = os.path.join(WORKSPACE_DIR, directory)
cmd = project.get('run_cmd', '')
if not cmd:
return jsonify({'error': '未配置运行命令'}), 400
run_command(cmd, cwd, project_id, 'run')
return jsonify({'message': 'CLI工具已开始运行...'})
@app.route('/api/projects/<project_id>/log')
def api_log(project_id):
log_file = os.path.join(BASE_DIR, 'logs', f'{project_id}.log')
if os.path.exists(log_file):
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
# 最后1000行
lines = f.readlines()[-1000:]
return jsonify({'log': ''.join(lines)})
return jsonify({'log': '暂无日志'})
@app.route('/api/projects/add', methods=['POST'])
def api_add_project():
data = request.json
if not data.get('name') or not data.get('type'):
return jsonify({'error': '缺少必要字段'}), 400
projects = load_projects()
# 生成ID
import re
project_id = re.sub(r'[^a-z0-9-]', '-', data['name'].lower())
# 检查ID是否重复
if any(p['id'] == project_id for p in projects):
return jsonify({'error': '项目ID已存在'}), 400
new_project = {
'id': project_id,
'name': data['name'],
'type': data['type'],
'description': data.get('description', ''),
'directory': data.get('directory', ''),
'version': data.get('version', 'v1.0.0'),
'git_repo': data.get('git_repo'),
}
if data['type'] == 'web':
new_project['ports'] = data.get('ports', [])
new_project['start_cmd'] = data.get('start_cmd', 'python3 app.py')
new_project['health_url'] = data.get('health_url')
new_project['admin_url'] = data.get('admin_url')
elif data['type'] == 'cron':
new_project['cron'] = data.get('cron')
new_project['cron_cmd'] = data.get('cron_cmd')
elif data['type'] == 'cli':
new_project['run_cmd'] = data.get('run_cmd')
projects.append(new_project)
save_projects(projects)
return jsonify({'message': '项目添加成功', 'project': new_project})
@app.route('/api/projects/<project_id>', methods=['PUT'])
def api_update_project(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
data = request.json
for key, value in data.items():
if key != 'id': # 不允许修改ID
project[key] = value
save_projects(projects)
return jsonify({'message': '更新成功', 'project': project})
@app.route('/api/projects/<project_id>', methods=['DELETE'])
def api_delete_project(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
projects = [p for p in projects if p['id'] != project_id]
save_projects(projects)
return jsonify({'message': '删除成功'})
if __name__ == '__main__':
os.makedirs(os.path.join(BASE_DIR, 'logs'), exist_ok=True)
print("=" * 50)
print("项目服务管理面板")
print("访问地址: http://localhost:19013")
print("=" * 50)
app.run(host='0.0.0.0', port=19013, debug=False)