feat(app): pnpm 一键启动 + Flutter Web 编译修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

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:
iven
2026-06-03 09:50:19 +08:00
parent b81a972245
commit 11d0971a67
23 changed files with 2034 additions and 888 deletions

442
scripts/dev.mjs Normal file
View 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
View 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();