Files
nj/scripts/dev.mjs
iven c253c8ddcf
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
chore(scripts): 开发脚本支持 Flutter Windows 桌面端
- pnpm start:dev 默认在 Windows 上启动桌面端(非 Web)
- 新增 pnpm start:dev:app:win 强制 Windows 桌面
- 新增 pnpm start:dev:app:web 强制 Chrome Web
- SIGINT 清理时同时终止 nuanji_app.exe
- 汇总输出区分桌面/Web 模式
2026-06-04 20:35:39 +08:00

521 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}
/** 启动学生端 FlutterWindows 桌面 或 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);
});