You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
374 lines
11 KiB
374 lines
11 KiB
#!/usr/bin/env node
|
|
/**
|
|
* 前端开发服务启动脚本
|
|
*
|
|
* 用法:
|
|
* node scripts/dev.js # 同时启动 web + mall
|
|
* node scripts/dev.js web # 仅启动健康APP (web)
|
|
* node scripts/dev.js mall # 仅启动商城 (mall)
|
|
* node scripts/dev.js --kill # 仅关闭占用端口的进程
|
|
*
|
|
* 启动前会自动检查端口占用并清理。
|
|
*/
|
|
|
|
const { execSync, spawn } = require('child_process');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
// ─── 配置 ────────────────────────────────────────────
|
|
const PROJECTS = {
|
|
web: {
|
|
name: '健康APP (web)',
|
|
dir: 'web',
|
|
port: 5173,
|
|
color: '\x1b[36m', // cyan
|
|
url: 'http://localhost:5173'
|
|
},
|
|
mall: {
|
|
name: '健康商城 (mall)',
|
|
dir: 'mall',
|
|
port: 5174,
|
|
color: '\x1b[32m', // green
|
|
url: 'http://localhost:5174'
|
|
}
|
|
};
|
|
|
|
const RESET = '\x1b[0m';
|
|
const BOLD = '\x1b[1m';
|
|
const RED = '\x1b[31m';
|
|
const YELLOW = '\x1b[33m';
|
|
const GREEN = '\x1b[32m';
|
|
const CYAN = '\x1b[36m';
|
|
const DIM = '\x1b[2m';
|
|
|
|
// ─── 工具函数 ────────────────────────────────────────
|
|
function log(msg) { console.log(msg); }
|
|
function info(msg) { log(`${CYAN}ℹ${RESET} ${msg}`); }
|
|
function success(msg) { log(`${GREEN}✓${RESET} ${msg}`); }
|
|
function warn(msg) { log(`${YELLOW}⚠${RESET} ${msg}`); }
|
|
function error(msg) { log(`${RED}✗${RESET} ${msg}`); }
|
|
|
|
function banner() {
|
|
log('');
|
|
log(`${BOLD}╔═══════════════════════════════════════════════╗${RESET}`);
|
|
log(`${BOLD}║ 健康AI助手 · 前端开发服务启动脚本 ║${RESET}`);
|
|
log(`${BOLD}╚═══════════════════════════════════════════════╝${RESET}`);
|
|
log('');
|
|
}
|
|
|
|
/**
|
|
* 获取占用指定端口的进程 PID
|
|
* 使用 netstat(Windows/Linux 通用)进行精确检测
|
|
* 返回 PID 或 null
|
|
*/
|
|
function getPortPid(port) {
|
|
try {
|
|
const isWin = os.platform() === 'win32';
|
|
if (isWin) {
|
|
// Windows: netstat -ano,查找 LISTENING 状态
|
|
const result = execSync(`netstat -ano`, { encoding: 'utf-8', timeout: 5000 });
|
|
const lines = result.split('\n');
|
|
for (const line of lines) {
|
|
// 匹配精确端口(避免 51730 匹配 5173)
|
|
const match = line.match(new RegExp(`:\\s*${port}\\s+.*LISTENING\\s+(\\d+)`));
|
|
if (match) {
|
|
const pid = parseInt(match[1], 10);
|
|
if (pid > 0) return pid;
|
|
}
|
|
}
|
|
// 兜底:用 findstr
|
|
try {
|
|
const result2 = execSync(`netstat -ano | findstr ":${port} "`, { encoding: 'utf-8', timeout: 5000 });
|
|
const lines2 = result2.trim().split('\n');
|
|
for (const line of lines2) {
|
|
if (line.includes('LISTENING')) {
|
|
const parts = line.trim().split(/\s+/);
|
|
const pid = parseInt(parts[parts.length - 1], 10);
|
|
if (pid > 0) return pid;
|
|
}
|
|
}
|
|
} catch { /* no match */ }
|
|
} else {
|
|
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', timeout: 5000 });
|
|
const pid = parseInt(result.trim().split('\n')[0], 10);
|
|
if (pid > 0) return pid;
|
|
}
|
|
} catch {
|
|
// 没有找到进程
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 获取进程名称
|
|
*/
|
|
function getProcessName(pid) {
|
|
try {
|
|
const isWin = os.platform() === 'win32';
|
|
if (isWin) {
|
|
const result = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf-8', timeout: 5000 });
|
|
const match = result.match(/"([^"]+)"/);
|
|
return match ? match[1] : '未知进程';
|
|
} else {
|
|
return execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
}
|
|
} catch {
|
|
return '未知进程';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 终止进程
|
|
*/
|
|
function killProcess(pid) {
|
|
try {
|
|
const isWin = os.platform() === 'win32';
|
|
if (isWin) {
|
|
// 先尝试终止进程树
|
|
execSync(`taskkill /PID ${pid} /T /F`, { encoding: 'utf-8', timeout: 5000, stdio: 'ignore' });
|
|
} else {
|
|
execSync(`kill -9 ${pid}`, { encoding: 'utf-8', timeout: 5000, stdio: 'ignore' });
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 检查并释放端口
|
|
*/
|
|
async function ensurePortFree(port, projectName) {
|
|
const pid = getPortPid(port);
|
|
if (!pid) {
|
|
success(`端口 ${BOLD}${port}${RESET} 空闲 ${DIM}(${projectName})${RESET}`);
|
|
return true;
|
|
}
|
|
|
|
const procName = getProcessName(pid);
|
|
warn(`端口 ${BOLD}${port}${RESET} 被占用 → PID: ${pid} (${procName}) ${DIM}[${projectName}]${RESET}`);
|
|
|
|
if (killProcess(pid)) {
|
|
// 等待端口释放(最多重试 3 次)
|
|
for (let i = 0; i < 3; i++) {
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
if (!getPortPid(port)) {
|
|
success(`已终止进程 PID ${pid},端口 ${port} 已释放`);
|
|
return true;
|
|
}
|
|
}
|
|
error(`端口 ${port} 仍被占用,无法释放`);
|
|
return false;
|
|
} else {
|
|
error(`无法终止进程 PID ${pid}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 检查项目依赖是否已安装
|
|
*/
|
|
function checkNodeModules(projectDir) {
|
|
const nmPath = path.join(projectDir, 'node_modules');
|
|
try {
|
|
require('fs').accessSync(nmPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 安装项目依赖
|
|
*/
|
|
function installDeps(projectDir, projectName) {
|
|
info(`${projectName}: 正在安装依赖 (npm install)...`);
|
|
try {
|
|
execSync('npm install', {
|
|
cwd: projectDir,
|
|
stdio: 'inherit',
|
|
timeout: 120000
|
|
});
|
|
success(`${projectName}: 依赖安装完成`);
|
|
return true;
|
|
} catch {
|
|
error(`${projectName}: 依赖安装失败`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 启动项目开发服务
|
|
*/
|
|
function startProject(project, rootDir) {
|
|
const projectDir = path.join(rootDir, project.dir);
|
|
const label = `${project.color}[${project.name}]${RESET}`;
|
|
|
|
info(`${label} 启动中...`);
|
|
|
|
const child = spawn('npm', ['run', 'dev'], {
|
|
cwd: projectDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env },
|
|
shell: true
|
|
});
|
|
|
|
child.stdout.on('data', (data) => {
|
|
const lines = data.toString().split('\n').filter(l => l.trim());
|
|
for (const line of lines) {
|
|
log(` ${label} ${line}`);
|
|
}
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
const lines = data.toString().split('\n').filter(l => l.trim());
|
|
for (const line of lines) {
|
|
// Vite 的 deprecation warning 降级显示
|
|
if (line.includes('Deprecation Warning') || line.includes('More info:')) {
|
|
log(` ${label} ${DIM}${line}${RESET}`);
|
|
} else {
|
|
log(` ${label} ${YELLOW}${line}${RESET}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
error(`${label} 启动失败: ${err.message}`);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (code !== null && code !== 0) {
|
|
warn(`${label} 进程退出,退出码: ${code}`);
|
|
}
|
|
});
|
|
|
|
return child;
|
|
}
|
|
|
|
// ─── 主流程 ──────────────────────────────────────────
|
|
async function main() {
|
|
banner();
|
|
|
|
const args = process.argv.slice(2);
|
|
const killOnly = args.includes('--kill');
|
|
const targets = [];
|
|
|
|
if (args.includes('web')) targets.push('web');
|
|
if (args.includes('mall')) targets.push('mall');
|
|
if (targets.length === 0 && !killOnly) {
|
|
targets.push('web', 'mall');
|
|
}
|
|
|
|
const rootDir = path.resolve(__dirname, '..');
|
|
|
|
// ── 步骤 1:显示计划 ──
|
|
if (killOnly) {
|
|
info('模式: 仅关闭端口占用进程');
|
|
} else if (targets.length === 2) {
|
|
info('模式: 同时启动 健康APP + 商城');
|
|
} else {
|
|
info(`模式: 仅启动 ${PROJECTS[targets[0]].name}`);
|
|
}
|
|
log('');
|
|
|
|
// ── 步骤 2:检查端口 ──
|
|
log(`${BOLD}[1/3] 检查端口占用${RESET}`);
|
|
log('─'.repeat(48));
|
|
|
|
const portProjects = killOnly
|
|
? Object.values(PROJECTS)
|
|
: targets.map(t => PROJECTS[t]);
|
|
|
|
for (const proj of portProjects) {
|
|
const freed = await ensurePortFree(proj.port, proj.name);
|
|
if (!freed && !killOnly) {
|
|
error(`无法释放端口 ${proj.port},请手动处理后重试`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
log('');
|
|
|
|
if (killOnly) {
|
|
success('端口清理完成');
|
|
process.exit(0);
|
|
}
|
|
|
|
// ── 步骤 3:检查依赖 ──
|
|
log(`${BOLD}[2/3] 检查项目依赖${RESET}`);
|
|
log('─'.repeat(48));
|
|
|
|
for (const key of targets) {
|
|
const proj = PROJECTS[key];
|
|
const projDir = path.join(rootDir, proj.dir);
|
|
if (checkNodeModules(projDir)) {
|
|
success(`${proj.name}: node_modules 已就绪`);
|
|
} else {
|
|
warn(`${proj.name}: 未安装依赖`);
|
|
const ok = installDeps(projDir, proj.name);
|
|
if (!ok) {
|
|
error('依赖安装失败,退出');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
log('');
|
|
|
|
// ── 步骤 4:启动服务 ──
|
|
log(`${BOLD}[3/3] 启动开发服务${RESET}`);
|
|
log('─'.repeat(48));
|
|
|
|
const children = [];
|
|
for (const key of targets) {
|
|
const child = startProject(PROJECTS[key], rootDir);
|
|
children.push(child);
|
|
}
|
|
|
|
// 等待所有服务就绪后显示汇总
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
log('');
|
|
log(`${BOLD}╔═══════════════════════════════════════════════╗${RESET}`);
|
|
log(`${BOLD}║ 开发服务已启动 ║${RESET}`);
|
|
log(`${BOLD}╠═══════════════════════════════════════════════╣${RESET}`);
|
|
for (const key of targets) {
|
|
const proj = PROJECTS[key];
|
|
log(`${BOLD}║${RESET} ${proj.color}${proj.name}${RESET}: ${BOLD}${proj.url}${RESET}`);
|
|
}
|
|
log(`${BOLD}╠═══════════════════════════════════════════════╣${RESET}`);
|
|
log(`${BOLD}║${RESET} 后端 API: ${BOLD}http://localhost:8080${RESET}`);
|
|
log(`${BOLD}║${RESET} 测试账号: ${DIM}13800138000 / 123456${RESET}`);
|
|
log(`${BOLD}╠═══════════════════════════════════════════════╣${RESET}`);
|
|
log(`${BOLD}║${RESET} 按 ${RED}Ctrl+C${RESET} 停止所有服务`);
|
|
log(`${BOLD}╚═══════════════════════════════════════════════╝${RESET}`);
|
|
log('');
|
|
|
|
// ── 优雅退出处理 ──
|
|
function cleanup() {
|
|
log('');
|
|
info('正在停止所有服务...');
|
|
for (const child of children) {
|
|
try {
|
|
const isWin = os.platform() === 'win32';
|
|
if (isWin) {
|
|
execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: 'ignore', timeout: 5000 });
|
|
} else {
|
|
child.kill('SIGTERM');
|
|
}
|
|
} catch {
|
|
// 进程可能已退出
|
|
}
|
|
}
|
|
success('所有服务已停止');
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGINT', cleanup);
|
|
process.on('SIGTERM', cleanup);
|
|
|
|
// 保持主进程运行
|
|
await new Promise(() => {});
|
|
}
|
|
|
|
main().catch(err => {
|
|
error(`脚本执行出错: ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
|