- pnpm start:dev 默认在 Windows 上启动桌面端(非 Web) - 新增 pnpm start:dev:app:win 强制 Windows 桌面 - 新增 pnpm start:dev:app:web 强制 Chrome Web - SIGINT 清理时同时终止 nuanji_app.exe - 汇总输出区分桌面/Web 模式
521 lines
15 KiB
JavaScript
521 lines
15 KiB
JavaScript
#!/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);
|
||
});
|