mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
- 添加 docker/Dockerfile 多阶段构建(前端+后端) - 添加 docker-compose.yml 支持阿里云镜像部署 - 添加 .github/workflows/release.yml 自动发布到阿里云 - 添加 .dockerignore 优化构建 - 添加 VERSION 版本管理文件 - 添加 start.mjs 本地开发启动脚本
614 lines
17 KiB
JavaScript
614 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* DS2API 启动脚本 - 交互式菜单
|
||
*
|
||
* 使用方法:
|
||
* node start.mjs # 显示交互式菜单
|
||
* node start.mjs dev # 开发模式(后端+前端)
|
||
* node start.mjs prod # 生产模式
|
||
*/
|
||
|
||
import { spawn, execSync } from 'child_process';
|
||
import { createInterface } from 'readline';
|
||
import { existsSync } from 'fs';
|
||
import { fileURLToPath } from 'url';
|
||
import { dirname, join } from 'path';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = dirname(__filename);
|
||
|
||
// 判断是否为 Windows
|
||
const isWindows = process.platform === 'win32';
|
||
|
||
// 配置
|
||
const CONFIG = {
|
||
backendPort: process.env.PORT || 5001,
|
||
frontendPort: 5173,
|
||
host: process.env.HOST || '0.0.0.0',
|
||
logLevel: process.env.LOG_LEVEL || 'info',
|
||
adminKey: process.env.DS2API_ADMIN_KEY || 'ds2api',
|
||
webuiDir: join(__dirname, 'webui'),
|
||
venvDir: join(__dirname, '.venv'),
|
||
};
|
||
|
||
// venv 中的可执行文件路径
|
||
const VENV = {
|
||
python: isWindows
|
||
? join(CONFIG.venvDir, 'Scripts', 'python.exe')
|
||
: join(CONFIG.venvDir, 'bin', 'python'),
|
||
pip: isWindows
|
||
? join(CONFIG.venvDir, 'Scripts', 'pip.exe')
|
||
: join(CONFIG.venvDir, 'bin', 'pip'),
|
||
};
|
||
|
||
// 存储子进程
|
||
const processes = [];
|
||
|
||
// 颜色输出
|
||
const colors = {
|
||
reset: '\x1b[0m',
|
||
bright: '\x1b[1m',
|
||
dim: '\x1b[2m',
|
||
red: '\x1b[31m',
|
||
green: '\x1b[32m',
|
||
yellow: '\x1b[33m',
|
||
blue: '\x1b[34m',
|
||
magenta: '\x1b[35m',
|
||
cyan: '\x1b[36m',
|
||
};
|
||
|
||
const log = {
|
||
info: (msg) => console.log(`${colors.cyan}[INFO]${colors.reset} ${msg}`),
|
||
success: (msg) => console.log(`${colors.green}[OK]${colors.reset} ${msg}`),
|
||
warn: (msg) => console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`),
|
||
error: (msg) => console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`),
|
||
title: (msg) => console.log(`\n${colors.bright}${colors.magenta}${msg}${colors.reset}`),
|
||
};
|
||
|
||
// 清理并退出
|
||
function cleanup() {
|
||
console.log('\n');
|
||
log.info('正在关闭所有服务...');
|
||
processes.forEach(proc => {
|
||
if (proc && !proc.killed) {
|
||
proc.kill('SIGTERM');
|
||
}
|
||
});
|
||
log.success('已退出');
|
||
process.exit(0);
|
||
}
|
||
|
||
// 注册退出处理
|
||
process.on('SIGINT', cleanup);
|
||
process.on('SIGTERM', cleanup);
|
||
|
||
// 检查命令是否存在
|
||
function commandExists(cmd) {
|
||
try {
|
||
execSync(`${isWindows ? 'where' : 'which'} ${cmd}`, { stdio: 'ignore' });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 获取系统 Python 命令
|
||
function getSystemPython() {
|
||
const candidates = isWindows
|
||
? ['python', 'python3', 'py']
|
||
: ['python3', 'python'];
|
||
|
||
for (const cmd of candidates) {
|
||
if (commandExists(cmd)) {
|
||
return cmd;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 系统 Python 命令
|
||
const SYSTEM_PYTHON = getSystemPython();
|
||
|
||
// 检查 venv 是否存在
|
||
function venvExists() {
|
||
return existsSync(VENV.python);
|
||
}
|
||
|
||
// 检查 Python 依赖是否已安装
|
||
function checkPythonDeps() {
|
||
if (!venvExists()) return false;
|
||
try {
|
||
execSync(`"${VENV.python}" -c "import fastapi, uvicorn"`, {
|
||
stdio: 'ignore',
|
||
shell: true,
|
||
});
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 检查前端依赖是否已安装
|
||
function checkFrontendDeps() {
|
||
if (!existsSync(CONFIG.webuiDir)) return null;
|
||
return existsSync(join(CONFIG.webuiDir, 'node_modules'));
|
||
}
|
||
|
||
// 获取依赖状态
|
||
function getDepsStatus() {
|
||
return {
|
||
venv: venvExists(),
|
||
python: checkPythonDeps(),
|
||
frontend: checkFrontendDeps(),
|
||
};
|
||
}
|
||
|
||
// 查找占用端口的进程 PID
|
||
function findPidByPort(port) {
|
||
try {
|
||
if (isWindows) {
|
||
const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
|
||
encoding: 'utf-8',
|
||
shell: true,
|
||
stdio: ['pipe', 'pipe', 'ignore'],
|
||
});
|
||
const pids = new Set();
|
||
for (const line of output.trim().split('\n')) {
|
||
const parts = line.trim().split(/\s+/);
|
||
const pid = parts[parts.length - 1];
|
||
if (pid && pid !== '0') pids.add(pid);
|
||
}
|
||
return [...pids];
|
||
} else {
|
||
const output = execSync(`lsof -ti :${port}`, {
|
||
encoding: 'utf-8',
|
||
stdio: ['pipe', 'pipe', 'ignore'],
|
||
});
|
||
return output.trim().split('\n').filter(Boolean);
|
||
}
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 获取运行中的服务状态
|
||
function getRunningStatus() {
|
||
const backendPids = findPidByPort(CONFIG.backendPort);
|
||
const frontendPids = findPidByPort(CONFIG.frontendPort);
|
||
return {
|
||
backend: backendPids,
|
||
frontend: frontendPids,
|
||
isRunning: backendPids.length > 0 || frontendPids.length > 0,
|
||
};
|
||
}
|
||
|
||
// 停止服务
|
||
async function stopServices() {
|
||
const running = getRunningStatus();
|
||
|
||
if (!running.isRunning) {
|
||
log.warn('没有检测到正在运行的服务');
|
||
return;
|
||
}
|
||
|
||
log.title('========== 停止服务 ==========');
|
||
|
||
const killProcess = async (pid) => {
|
||
try {
|
||
if (isWindows) {
|
||
try {
|
||
execSync(`taskkill /PID ${pid}`, { stdio: 'ignore', shell: true });
|
||
} catch {
|
||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', shell: true });
|
||
}
|
||
} else {
|
||
execSync(`kill -15 ${pid}`, { stdio: 'ignore' });
|
||
await new Promise(r => setTimeout(r, 500));
|
||
try {
|
||
execSync(`kill -0 ${pid}`, { stdio: 'ignore' });
|
||
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
||
} catch { /* 进程已退出 */ }
|
||
}
|
||
} catch { /* 进程可能已退出 */ }
|
||
};
|
||
|
||
if (running.backend.length > 0) {
|
||
log.info(`停止后端服务 (端口 ${CONFIG.backendPort}, PID: ${running.backend.join(', ')})...`);
|
||
for (const pid of running.backend) {
|
||
await killProcess(pid);
|
||
}
|
||
log.success('后端服务已停止');
|
||
}
|
||
|
||
if (running.frontend.length > 0) {
|
||
log.info(`停止前端服务 (端口 ${CONFIG.frontendPort}, PID: ${running.frontend.join(', ')})...`);
|
||
for (const pid of running.frontend) {
|
||
await killProcess(pid);
|
||
}
|
||
log.success('前端服务已停止');
|
||
}
|
||
}
|
||
|
||
// 创建 venv
|
||
async function createVenv() {
|
||
if (venvExists()) {
|
||
log.info('虚拟环境已存在');
|
||
return true;
|
||
}
|
||
|
||
if (!SYSTEM_PYTHON) {
|
||
throw new Error('未找到 Python,请先安装 Python');
|
||
}
|
||
|
||
log.info('创建 Python 虚拟环境...');
|
||
return new Promise((resolve, reject) => {
|
||
const proc = spawn(SYSTEM_PYTHON, ['-m', 'venv', CONFIG.venvDir], {
|
||
cwd: __dirname,
|
||
stdio: 'inherit',
|
||
shell: true,
|
||
});
|
||
proc.on('close', code => {
|
||
if (code === 0) {
|
||
log.success('虚拟环境创建成功');
|
||
resolve(true);
|
||
} else {
|
||
reject(new Error('虚拟环境创建失败'));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 确保 venv 存在
|
||
async function ensureVenv() {
|
||
if (!venvExists()) {
|
||
await createVenv();
|
||
}
|
||
}
|
||
|
||
// 确保 Python 依赖已安装
|
||
async function ensurePythonDeps() {
|
||
await ensureVenv();
|
||
if (!checkPythonDeps()) {
|
||
log.warn('检测到 Python 依赖未安装,正在安装...');
|
||
await installPythonDeps();
|
||
}
|
||
}
|
||
|
||
// 确保前端依赖已安装
|
||
async function ensureFrontendDeps() {
|
||
if (checkFrontendDeps() === false) {
|
||
log.warn('检测到前端依赖未安装,正在安装...');
|
||
await installFrontendDeps();
|
||
}
|
||
}
|
||
|
||
// 安装 Python 依赖
|
||
async function installPythonDeps() {
|
||
await ensureVenv();
|
||
log.info('安装 Python 依赖...');
|
||
return new Promise((resolve, reject) => {
|
||
const proc = spawn(VENV.pip, ['install', '-r', 'requirements.txt'], {
|
||
cwd: __dirname,
|
||
stdio: 'inherit',
|
||
shell: true,
|
||
});
|
||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Python 依赖安装失败')));
|
||
});
|
||
}
|
||
|
||
// 安装前端依赖
|
||
async function installFrontendDeps() {
|
||
if (!existsSync(CONFIG.webuiDir)) {
|
||
log.warn('webui 目录不存在,跳过前端依赖安装');
|
||
return;
|
||
}
|
||
log.info('安装前端依赖...');
|
||
return new Promise((resolve, reject) => {
|
||
const proc = spawn('npm', ['install'], {
|
||
cwd: CONFIG.webuiDir,
|
||
stdio: 'inherit',
|
||
shell: true,
|
||
});
|
||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败')));
|
||
});
|
||
}
|
||
|
||
// 安装所有依赖
|
||
async function installAll() {
|
||
log.title('========== 安装依赖 ==========');
|
||
try {
|
||
await installPythonDeps();
|
||
log.success('Python 依赖安装完成');
|
||
await installFrontendDeps();
|
||
log.success('前端依赖安装完成');
|
||
log.success('所有依赖安装完成!');
|
||
} catch (e) {
|
||
log.error(e.message);
|
||
}
|
||
}
|
||
|
||
// 启动后端
|
||
async function startBackend(devMode = true) {
|
||
await ensurePythonDeps();
|
||
|
||
log.info(`启动后端服务... http://localhost:${CONFIG.backendPort}`);
|
||
|
||
const args = [
|
||
'-m', 'uvicorn',
|
||
'app:app',
|
||
'--host', CONFIG.host,
|
||
'--port', String(CONFIG.backendPort),
|
||
'--log-level', CONFIG.logLevel,
|
||
];
|
||
|
||
if (devMode) {
|
||
args.push('--reload', '--reload-dir', __dirname);
|
||
}
|
||
|
||
const proc = spawn(VENV.python, args, {
|
||
cwd: __dirname,
|
||
stdio: 'inherit',
|
||
shell: true,
|
||
env: {
|
||
...process.env,
|
||
DS2API_ADMIN_KEY: CONFIG.adminKey,
|
||
},
|
||
});
|
||
|
||
processes.push(proc);
|
||
return proc;
|
||
}
|
||
|
||
// 启动前端
|
||
async function startFrontend() {
|
||
if (!existsSync(CONFIG.webuiDir)) {
|
||
log.warn('webui 目录不存在,跳过前端启动');
|
||
return null;
|
||
}
|
||
|
||
await ensureFrontendDeps();
|
||
|
||
log.info(`启动前端服务... http://localhost:${CONFIG.frontendPort}`);
|
||
|
||
const proc = spawn('npm', ['run', 'dev'], {
|
||
cwd: CONFIG.webuiDir,
|
||
stdio: 'inherit',
|
||
shell: true,
|
||
});
|
||
|
||
processes.push(proc);
|
||
return proc;
|
||
}
|
||
|
||
// 构建前端
|
||
async function buildFrontend() {
|
||
if (!existsSync(CONFIG.webuiDir)) {
|
||
log.warn('webui 目录不存在');
|
||
return;
|
||
}
|
||
|
||
log.info('构建前端...');
|
||
return new Promise((resolve, reject) => {
|
||
const proc = spawn('npm', ['run', 'build'], {
|
||
cwd: CONFIG.webuiDir,
|
||
stdio: 'inherit',
|
||
shell: true,
|
||
});
|
||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败')));
|
||
});
|
||
}
|
||
|
||
// 显示状态信息
|
||
function showStatus() {
|
||
console.log('\n' + '─'.repeat(50));
|
||
log.success(`后端 API: http://localhost:${CONFIG.backendPort}`);
|
||
if (existsSync(CONFIG.webuiDir)) {
|
||
log.success(`管理界面: http://localhost:${CONFIG.frontendPort}`);
|
||
}
|
||
console.log('─'.repeat(50));
|
||
log.info('按 Ctrl+C 停止所有服务\n');
|
||
}
|
||
|
||
// 等待进程
|
||
function waitForProcesses() {
|
||
return new Promise(resolve => {
|
||
const checkInterval = setInterval(() => {
|
||
const alive = processes.filter(p => !p.killed);
|
||
if (alive.length === 0) {
|
||
clearInterval(checkInterval);
|
||
resolve();
|
||
}
|
||
}, 1000);
|
||
});
|
||
}
|
||
|
||
// 交互式菜单
|
||
async function showMenu() {
|
||
const rl = createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
});
|
||
|
||
const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
|
||
|
||
console.clear();
|
||
log.title('╔══════════════════════════════════════════╗');
|
||
log.title('║ DS2API 启动脚本 ║');
|
||
log.title('╚══════════════════════════════════════════╝');
|
||
|
||
// 获取依赖状态
|
||
const deps = getDepsStatus();
|
||
const running = getRunningStatus();
|
||
|
||
const statusText = (ok) => ok ? `${colors.green}已安装${colors.reset}` : `${colors.yellow}未安装${colors.reset}`;
|
||
|
||
console.log(`\n${colors.bright}环境状态:${colors.reset}`);
|
||
console.log(` Python: ${SYSTEM_PYTHON || `${colors.red}未找到${colors.reset}`}`);
|
||
console.log(` 虚拟环境: ${deps.venv ? `${colors.green}已创建${colors.reset}` : `${colors.yellow}未创建${colors.reset}`} (${CONFIG.venvDir})`);
|
||
console.log(` 后端依赖: ${statusText(deps.python)}`);
|
||
if (deps.frontend !== null) {
|
||
console.log(` 前端依赖: ${statusText(deps.frontend)}`);
|
||
}
|
||
|
||
console.log(`\n${colors.bright}服务状态:${colors.reset}`);
|
||
console.log(` 后端 (${CONFIG.backendPort}): ${running.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
|
||
console.log(` 前端 (${CONFIG.frontendPort}): ${running.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
|
||
|
||
console.log(`\n${colors.bright}管理员密钥:${colors.reset} ${colors.cyan}${CONFIG.adminKey}${colors.reset}`);
|
||
console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=你的密钥 node start.mjs${colors.reset}`);
|
||
|
||
console.log(`
|
||
${colors.bright}请选择操作:${colors.reset}
|
||
|
||
${colors.cyan}1.${colors.reset} 开发模式 (后端 + 前端热重载)
|
||
${colors.cyan}2.${colors.reset} 仅启动后端 (开发模式)
|
||
${colors.cyan}3.${colors.reset} 仅启动前端
|
||
${colors.cyan}4.${colors.reset} 生产模式 (仅后端,无热重载)
|
||
${colors.cyan}5.${colors.reset} 构建前端
|
||
${colors.cyan}6.${colors.reset} 安装依赖 (创建venv + 安装包)
|
||
${colors.red}7.${colors.reset} 停止所有服务
|
||
${colors.cyan}0.${colors.reset} 退出
|
||
`);
|
||
|
||
const choice = await question(`${colors.yellow}请输入选项 [1]: ${colors.reset}`);
|
||
rl.close();
|
||
|
||
switch (choice.trim() || '1') {
|
||
case '1':
|
||
log.title('========== 开发模式 ==========');
|
||
await startBackend(true);
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
await startFrontend();
|
||
showStatus();
|
||
await waitForProcesses();
|
||
break;
|
||
|
||
case '2':
|
||
log.title('========== 仅后端 (开发模式) ==========');
|
||
await startBackend(true);
|
||
showStatus();
|
||
await waitForProcesses();
|
||
break;
|
||
|
||
case '3':
|
||
log.title('========== 仅前端 ==========');
|
||
await startFrontend();
|
||
showStatus();
|
||
await waitForProcesses();
|
||
break;
|
||
|
||
case '4':
|
||
log.title('========== 生产模式 ==========');
|
||
await startBackend(false);
|
||
showStatus();
|
||
await waitForProcesses();
|
||
break;
|
||
|
||
case '5':
|
||
await buildFrontend();
|
||
log.success('前端构建完成!');
|
||
break;
|
||
|
||
case '6':
|
||
await installAll();
|
||
break;
|
||
|
||
case '7':
|
||
await stopServices();
|
||
break;
|
||
|
||
case '0':
|
||
log.info('再见!');
|
||
process.exit(0);
|
||
break;
|
||
|
||
default:
|
||
log.warn('无效选项');
|
||
await showMenu();
|
||
}
|
||
}
|
||
|
||
// 命令行参数处理
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const cmd = args[0];
|
||
|
||
// 检查必要工具
|
||
if (!SYSTEM_PYTHON) {
|
||
log.error('未找到 Python,请先安装 Python (尝试了 python, python3, py)');
|
||
process.exit(1);
|
||
}
|
||
|
||
switch (cmd) {
|
||
case 'dev':
|
||
log.title('========== 开发模式 ==========');
|
||
await startBackend(true);
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
await startFrontend();
|
||
showStatus();
|
||
await waitForProcesses();
|
||
break;
|
||
|
||
case 'prod':
|
||
log.title('========== 生产模式 ==========');
|
||
await startBackend(false);
|
||
showStatus();
|
||
await waitForProcesses();
|
||
break;
|
||
|
||
case 'build':
|
||
await buildFrontend();
|
||
log.success('前端构建完成!');
|
||
break;
|
||
|
||
case 'install':
|
||
await installAll();
|
||
break;
|
||
|
||
case 'stop':
|
||
await stopServices();
|
||
break;
|
||
|
||
case 'status':
|
||
const status = getRunningStatus();
|
||
console.log(`\n${colors.bright}服务状态:${colors.reset}`);
|
||
console.log(` 后端 (${CONFIG.backendPort}): ${status.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
|
||
console.log(` 前端 (${CONFIG.frontendPort}): ${status.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}\n`);
|
||
break;
|
||
|
||
case 'help':
|
||
case '-h':
|
||
case '--help':
|
||
console.log(`
|
||
${colors.bright}DS2API 启动脚本${colors.reset}
|
||
|
||
${colors.cyan}使用方法:${colors.reset}
|
||
node start.mjs 显示交互式菜单
|
||
node start.mjs dev 开发模式 (后端 + 前端)
|
||
node start.mjs prod 生产模式 (无热重载)
|
||
node start.mjs build 构建前端
|
||
node start.mjs install 安装所有依赖 (自动创建venv)
|
||
node start.mjs stop 停止所有服务
|
||
node start.mjs status 查看服务状态
|
||
|
||
${colors.cyan}环境变量:${colors.reset}
|
||
PORT 后端端口 (默认: 5001)
|
||
HOST 监听地址 (默认: 0.0.0.0)
|
||
LOG_LEVEL 日志级别 (默认: info)
|
||
|
||
${colors.cyan}虚拟环境:${colors.reset}
|
||
默认路径: .venv/
|
||
首次运行 install 时自动创建
|
||
`);
|
||
break;
|
||
|
||
default:
|
||
await showMenu();
|
||
}
|
||
}
|
||
|
||
main().catch(e => {
|
||
log.error(e.message);
|
||
process.exit(1);
|
||
});
|