diff --git a/package.json b/package.json index 4ac019e..31b9ba6 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,10 @@ { + "scripts": { + "dev": "node scripts/dev.js", + "dev:web": "node scripts/dev.js web", + "dev:mall": "node scripts/dev.js mall", + "dev:kill": "node scripts/dev.js --kill" + }, "dependencies": { "playwright": "^1.58.1" } diff --git a/scripts/dev.bat b/scripts/dev.bat new file mode 100644 index 0000000..829586a --- /dev/null +++ b/scripts/dev.bat @@ -0,0 +1,11 @@ +@echo off +chcp 65001 >nul +:: 前端开发服务启动脚本 +:: 用法: +:: dev.bat 同时启动 web + mall +:: dev.bat web 仅启动健康APP +:: dev.bat mall 仅启动商城 +:: dev.bat --kill 仅关闭端口占用进程 + +cd /d "%~dp0.." +node scripts\dev.js %* diff --git a/scripts/dev.js b/scripts/dev.js new file mode 100644 index 0000000..14be3dc --- /dev/null +++ b/scripts/dev.js @@ -0,0 +1,374 @@ +#!/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); +}); diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 0000000..48b32cf --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# 前端开发服务启动脚本 +# 用法: +# ./scripts/dev.sh 同时启动 web + mall +# ./scripts/dev.sh web 仅启动健康APP +# ./scripts/dev.sh mall 仅启动商城 +# ./scripts/dev.sh --kill 仅关闭端口占用进程 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR/.." +node scripts/dev.js "$@"