feat(app): pnpm 一键启动 + Flutter Web 编译修复
1. 新增 pnpm start:dev / pnpm start:stop 命令 - scripts/dev.mjs: 跨平台启动脚本(后端+管理端+学生端) - scripts/stop.mjs: 端口清理停止脚本 - 根 package.json 定义 pnpm 脚本 2. 修复 Flutter Web 编译(Isar 3.x + flutter_secure_storage 不兼容) - isar_database: 条件导出,Web 用空 stub - isar_journal_repository: 条件导出,Web 用空 stub - sync_engine: 条件导出,Web 用内存队列(无 Isar 持久化) - 移除 flutter_secure_storage(v9 web 插件用 dart:html) - 新增 SecureTokenStore 接口 + shared_preferences 实现 - auth_repository 改用 SecureTokenStore 接口
This commit is contained in:
442
scripts/dev.mjs
Normal file
442
scripts/dev.mjs
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 暖记开发环境启动脚本 — 跨平台 (Windows/macOS/Linux)
|
||||
*
|
||||
* 用法:
|
||||
* pnpm start:dev # 启动全部 (后端+管理端+学生端)
|
||||
* pnpm start:dev:backend # 只启动后端
|
||||
* pnpm start:dev:admin # 只启动管理端前端
|
||||
* pnpm start:dev:app # 只启动学生端 Flutter
|
||||
* 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 Web */
|
||||
async function startApp() {
|
||||
log.info("清理旧学生端进程...");
|
||||
killPort(PORTS.app, "学生端前端");
|
||||
|
||||
log.info("编译并启动 Flutter Web...");
|
||||
const appDir = resolve(ROOT, "app");
|
||||
const flutterBin = IS_WIN ? "D:\\flutter\\bin\\flutter.bat" : "flutter";
|
||||
|
||||
const child = spawn(`${flutterBin} run -d chrome --web-port=${PORTS.app}`, {
|
||||
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]") + " " + 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]") + " " + 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() {
|
||||
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)`);
|
||||
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";
|
||||
const services =
|
||||
arg === "all"
|
||||
? ["backend", "admin", "app"]
|
||||
: [arg];
|
||||
|
||||
const validServices = ["backend", "admin", "app", "all"];
|
||||
if (!validServices.includes(arg)) {
|
||||
log.err(`未知参数: ${arg}`);
|
||||
console.log("用法: pnpm start:dev [all|backend|admin|app]");
|
||||
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());
|
||||
}
|
||||
|
||||
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") {
|
||||
printSummary();
|
||||
}
|
||||
|
||||
// 保持进程存活(不退出 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 {}
|
||||
}
|
||||
log.ok("所有服务已停止");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 保持 Node 进程不退出
|
||||
setInterval(() => {}, 60_000);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log.err(`启动失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
111
scripts/stop.mjs
Normal file
111
scripts/stop.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 暖记开发环境停止脚本 — 清理所有服务进程
|
||||
*
|
||||
* 用法:
|
||||
* pnpm start:stop
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
|
||||
// ===== 配置 =====
|
||||
const SERVICES = [
|
||||
{ port: 3000, name: "后端 (Rust/Axum)" },
|
||||
{ port: 5174, name: "管理端前端 (React/Vite)" },
|
||||
{ port: 8080, name: "学生端前端 (Flutter Web)" },
|
||||
];
|
||||
|
||||
// ===== 颜色输出 =====
|
||||
const log = {
|
||||
info: (msg) => console.log(`\x1b[34m[INFO]\x1b[0m ${msg}`),
|
||||
ok: (msg) => console.log(`\x1b[32m[OK]\x1b[0m ${msg}`),
|
||||
warn: (msg) => console.log(`\x1b[33m[WARN]\x1b[0m ${msg}`),
|
||||
};
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
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")
|
||||
),
|
||||
];
|
||||
}
|
||||
const out = execSync(`lsof -ti :${port}`, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return out.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主流程 =====
|
||||
|
||||
console.log();
|
||||
console.log("\x1b[1m\x1b[36m ═══ 暖记 — 停止所有服务 ═══\x1b[0m");
|
||||
console.log();
|
||||
|
||||
log.info("正在停止所有暖记服务...");
|
||||
|
||||
for (const { port, name } of SERVICES) {
|
||||
killPort(port, name);
|
||||
}
|
||||
|
||||
// Windows: 额外清理 erp-server.exe
|
||||
if (IS_WIN) {
|
||||
try {
|
||||
execSync("taskkill /F /IM erp-server.exe", { stdio: "pipe" });
|
||||
log.ok("已停止 erp-server.exe");
|
||||
} catch {
|
||||
// 没有运行中的进程,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// Windows: 清理可能的残留 node 进程(仅清理 pnpm 启动的)
|
||||
// 注意:不盲目杀所有 node 进程,只清理端口的即可
|
||||
|
||||
console.log();
|
||||
log.ok("所有服务已停止");
|
||||
console.log();
|
||||
Reference in New Issue
Block a user