#!/usr/bin/env node /** * 暖记开发环境启动脚本 — 跨平台 (Windows/macOS/Linux) * * 用法: * pnpm start:dev # 启动全部 (后端+管理端+学生端) * pnpm start:dev:backend # 只启动后端 * pnpm start:dev:admin # 只启动管理端前端 * pnpm start:dev:app # 只启动学生端(Windows桌面/Web 自动检测) * pnpm start:dev:app:win # 强制学生端 Windows 桌面 * pnpm start:dev:app:web # 强制学生端 Web (Chrome) * pnpm start:stop # 停止所有服务 */ import { spawn, execSync } from "node:child_process"; import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); const IS_WIN = process.platform === "win32"; // ===== 配置 ===== const PORTS = { backend: 3000, admin: 5174, app: 8080, }; const PG = { host: "localhost", port: 5432, user: "postgres", pass: "123123", db: "nuanji", }; // ===== 颜色输出 ===== const color = { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, blue: (s) => `\x1b[34m${s}\x1b[0m`, cyan: (s) => `\x1b[36m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`, gray: (s) => `\x1b[90m${s}\x1b[0m`, }; const log = { info: (msg) => console.log(color.blue("[INFO]") + " " + msg), ok: (msg) => console.log(color.green("[OK]") + " " + msg), warn: (msg) => console.log(color.yellow("[WARN]") + " " + msg), err: (msg) => console.log(color.red("[ERROR]") + " " + msg), }; // ===== 工具函数 ===== /** 查找占用指定端口的进程 PID */ function findPidsOnPort(port) { try { if (IS_WIN) { const out = execSync( `netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] } ); return [ ...new Set( out .trim() .split("\n") .map((l) => l.trim().split(/\s+/).pop()) .filter((p) => p && p !== "0") ), ]; } // macOS / Linux const out = execSync(`lsof -ti :${port}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); return out .trim() .split("\n") .filter(Boolean); } catch { return []; } } /** 杀死指定 PID */ function killPid(pid) { try { if (IS_WIN) { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); } else { execSync(`kill -9 ${pid}`, { stdio: "pipe" }); } return true; } catch { return false; } } /** 清理端口上所有进程 */ function killPort(port, name) { const pids = findPidsOnPort(port); if (pids.length === 0) { log.info(`${name} 端口 ${port} 空闲`); return; } for (const pid of pids) { if (killPid(pid)) { log.ok(`已停止 ${name} (PID: ${pid}, 端口: ${port})`); } else { log.warn(`无法停止 ${name} (PID: ${pid})`); } } } /** 检查端口是否已被占用 */ function isPortInUse(port) { return findPidsOnPort(port).length > 0; } /** 等待端口可达 */ function waitForPort(port, name, maxAttempts = 30, intervalMs = 2000) { return new Promise((resolve) => { let attempts = 0; const check = () => { attempts++; if (isPortInUse(port)) { log.ok(`${name} 已就绪 → http://localhost:${port}`); resolve(true); return; } if (attempts >= maxAttempts) { log.err(`${name} 启动超时 (等待了 ${maxAttempts * intervalMs / 1000}s)`); resolve(false); return; } setTimeout(check, intervalMs); }; check(); }); } // ===== 前置检查 ===== function checkPostgreSQL() { try { const psqlPath = IS_WIN ? "D:\\postgreSQL\\bin\\psql.exe" : "psql"; execSync( `"${psqlPath}" -U ${PG.user} -h ${PG.host} -p ${PG.port} -d ${PG.db} -c "SELECT 1"`, { stdio: "pipe", env: { ...process.env, PGPASSWORD: PG.pass } } ); log.ok(`PostgreSQL 已连接 (${PG.db}@${PG.host}:${PG.port})`); return true; } catch { log.err(`无法连接 PostgreSQL (${PG.db}@${PG.host}:${PG.port})`); log.err("请确保 PostgreSQL 已启动且数据库存在"); return false; } } function checkRedis() { try { execSync("redis-cli ping", { stdio: "pipe" }); log.ok("Redis 已连接"); return true; } catch { log.err("无法连接 Redis (localhost:6379)"); return false; } } function checkFlutter() { const flutterBin = IS_WIN ? "D:\\flutter\\bin\\flutter.bat" : "flutter"; try { execSync(`${flutterBin} --version`, { stdio: "pipe" }); log.ok("Flutter SDK 可用"); return true; } catch { log.err("Flutter SDK 不可用"); return false; } } function runChecks(services) { const needPg = services.includes("backend"); const needRedis = services.includes("backend"); const needFlutter = services.includes("app"); if (needPg && !checkPostgreSQL()) return false; if (needRedis && !checkRedis()) return false; if (needFlutter && !checkFlutter()) return false; return true; } // ===== 启动服务 ===== /** 启动后端 (Rust/Axum) */ async function startBackend() { log.info("清理旧后端进程..."); killPort(PORTS.backend, "后端"); // Windows: 额外清理 erp-server.exe if (IS_WIN) { try { execSync("taskkill /F /IM erp-server.exe", { stdio: "pipe" }); } catch { // 忽略,可能没有运行 } } log.info("编译并启动后端 (diary feature)..."); const env = { ...process.env, ERP__DATABASE__URL: `postgres://${PG.user}:${PG.pass}@${PG.host}:${PG.port}/${PG.db}`, ERP__REDIS__URL: "redis://localhost:6379", ERP__JWT__SECRET: "nuanji-dev-jwt-secret-2024-warm-notes-hmac-key-32b", ERP__AUTH__SUPER_ADMIN_PASSWORD: "admin123", ERP__WECHAT__APPID: "wx_dev_placeholder", ERP__WECHAT__SECRET: "wx_dev_secret_placeholder", ERP__WECHAT__DEV_MODE: "true", ERP__CRYPTO__KEK: "0000000000000000000000000000000000000000000000000000000000000000", }; const child = spawn("cargo run -p erp-server --features diary", { cwd: ROOT, env, shell: true, stdio: "pipe", }); child.stdout?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.cyan("[backend]") + " " + line); } }); child.stderr?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.cyan("[backend]") + " " + line); } }); child.on("error", (err) => { log.err(`后端启动失败: ${err.message}`); }); child.on("exit", (code) => { if (code !== 0 && code !== null) { log.warn(`后端进程退出 (code: ${code})`); } }); return waitForPort(PORTS.backend, "后端", 60, 3000); } /** 启动管理端前端 (React + Vite) */ async function startAdmin() { log.info("清理旧管理端进程..."); killPort(PORTS.admin, "管理端前端"); log.info("启动管理端前端 (React + Vite)..."); const adminDir = resolve(ROOT, "apps", "web"); // 先检查依赖是否安装 if (!existsSync(resolve(adminDir, "node_modules"))) { log.info("管理端依赖未安装,正在安装..."); execSync("pnpm install", { cwd: adminDir, stdio: "inherit" }); } const child = spawn("pnpm dev", { cwd: adminDir, shell: true, stdio: "pipe", }); child.stdout?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.green("[admin]") + " " + line); } }); child.stderr?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.green("[admin]") + " " + line); } }); child.on("error", (err) => { log.err(`管理端启动失败: ${err.message}`); }); return waitForPort(PORTS.admin, "管理端", 20, 2000); } /** 启动学生端 Flutter(Windows 桌面 或 Web) */ async function startApp(mode = "auto") { const appDir = resolve(ROOT, "app"); const flutterBin = IS_WIN ? "D:\\flutter\\bin\\flutter.bat" : "flutter"; // 决定运行平台:auto → Windows 上用桌面,其他用 Web const useWindows = mode === "windows" || (mode === "auto" && IS_WIN); if (useWindows) { log.info("编译并启动 Flutter Windows 桌面端..."); const child = spawn(`${flutterBin} run -d windows --dart-define=API_BASE_URL=http://localhost:${PORTS.backend}/api/v1 --dart-define=SSE_BASE_URL=http://localhost:${PORTS.backend}/api/v1`, { cwd: appDir, shell: true, stdio: "pipe", }); child.stdout?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.yellow("[app-win]") + " " + line); } }); child.stderr?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.yellow("[app-win]") + " " + line); } }); child.on("error", (err) => { log.err(`学生端启动失败: ${err.message}`); }); // Windows 桌面不监听端口,等待进程启动即可 // flutter run 会输出 "Syncing files" 表示就绪 return new Promise((resolve) => { let ready = false; const check = (data) => { if (!ready && data.toString().includes("Syncing files to device Windows")) { ready = true; log.ok("学生端 (Windows 桌面) 已启动"); resolve(true); } }; child.stdout?.on("data", check); child.stderr?.on("data", check); // 超时保护 setTimeout(() => { if (!ready) { ready = true; log.warn("学生端启动超时,但进程仍在运行"); resolve(true); } }, 180_000); }); } else { // Web 模式 log.info("清理旧学生端进程..."); killPort(PORTS.app, "学生端前端"); log.info("编译并启动 Flutter Web..."); const child = spawn(`${flutterBin} run -d chrome --web-port=${PORTS.app} --dart-define=API_BASE_URL=http://localhost:${PORTS.backend}/api/v1 --dart-define=SSE_BASE_URL=http://localhost:${PORTS.backend}/api/v1`, { cwd: appDir, shell: true, stdio: "pipe", }); child.stdout?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.yellow("[app-web]") + " " + line); } }); child.stderr?.on("data", (data) => { const lines = data.toString().trim().split("\n"); for (const line of lines) { if (line.trim()) console.log(color.yellow("[app-web]") + " " + line); } }); child.on("error", (err) => { log.err(`学生端启动失败: ${err.message}`); }); return waitForPort(PORTS.app, "学生端", 40, 3000); } } // ===== 主流程 ===== function printBanner(services) { console.log(); console.log( color.bold(color.cyan(" ╔══════════════════════════════════════════╗")) ); console.log( color.bold(color.cyan(" ║ 暖记 — 开发环境启动 ║")) ); console.log( color.bold(color.cyan(" ╚══════════════════════════════════════════╝")) ); console.log(); } function printSummary(appMode) { const isWindows = appMode === "windows" || (appMode === "auto" && IS_WIN); console.log(); console.log(color.bold(color.green(" ═══ 暖记开发环境已启动 ═══"))); console.log(); console.log(` 后端 API: http://localhost:${PORTS.backend}`); console.log(` 管理端: http://localhost:${PORTS.admin} (admin/admin123)`); if (isWindows) { console.log(` 学生端: Flutter Windows 桌面`); } else { console.log(` 学生端: http://localhost:${PORTS.app}`); } console.log(); console.log(color.gray(" 停止所有服务: pnpm start:stop")); console.log(); } async function main() { const arg = process.argv[2] || "all"; // 解析参数 → 服务列表 + app 运行模式 let services; let appMode = "auto"; // auto | windows | web switch (arg) { case "all": services = ["backend", "admin", "app"]; break; case "app:win": services = ["app"]; appMode = "windows"; break; case "app:web": services = ["app"]; appMode = "web"; break; default: services = [arg]; } const validServices = ["backend", "admin", "app", "all", "app:win", "app:web"]; if (!validServices.includes(arg)) { log.err(`未知参数: ${arg}`); console.log("用法: pnpm start:dev [all|backend|admin|app|app:win|app:web]"); process.exit(1); } printBanner(services); // 前置检查 log.info("检查依赖..."); if (!runChecks(services)) { log.err("前置检查失败,请修复后重试"); process.exit(1); } // 按顺序启动(后端先启动,前端依赖 API) if (services.includes("backend")) { const ok = await startBackend(); if (!ok) { log.err("后端启动失败,终止"); process.exit(1); } } // 管理端和学生端可以并行启动 const frontendPromises = []; if (services.includes("admin")) { frontendPromises.push(startAdmin()); } if (services.includes("app")) { frontendPromises.push(startApp(appMode)); } if (frontendPromises.length > 0) { const results = await Promise.all(frontendPromises); const failed = results.filter((r) => !r).length; if (failed > 0) { log.warn(`${failed} 个前端服务启动失败`); } } // 打印汇总 if (arg === "all" || arg === "app:win" || arg === "app:web") { printSummary(appMode); } // 保持进程存活(不退出 Node,让子进程继续运行) // Ctrl+C 会触发 SIGINT,我们在下面处理清理 process.on("SIGINT", () => { console.log(); log.info("收到 SIGINT,正在停止所有服务..."); killPort(PORTS.backend, "后端"); killPort(PORTS.admin, "管理端前端"); killPort(PORTS.app, "学生端前端"); if (IS_WIN) { try { execSync("taskkill /F /IM erp-server.exe", { stdio: "pipe" }); } catch {} try { execSync("taskkill /F /IM nuanji_app.exe", { stdio: "pipe" }); } catch {} } log.ok("所有服务已停止"); process.exit(0); }); // 保持 Node 进程不退出 setInterval(() => {}, 60_000); } main().catch((err) => { log.err(`启动失败: ${err.message}`); process.exit(1); });