Files
ClassHelper/scripts/lib/node-runtime.mjs
T
LoveChengke 3a9f8812fc feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷
## 客户端灵动岛(动态岛)

- 新增独立透明置顶窗口:桌面中上方常驻、不占任务栏、不抢焦点(268x44 胶囊 / 404x316 详情 / 424x344 紧急)
- 三种形态与动画:隐藏 ↔ 胶囊 ↔ 详情,尺寸用 easeOutCubic 260ms 逐帧缓动、透明度单独淡入淡出
  (尺寸与淡入使用各自动画令牌,避免互相打断导致窗口卡在胶囊尺寸)
- 行为:普通通知变「新消息 · 共 N 条」胶囊,点击展开详情;上课时段自动隐藏并进入待发队列,
  下课后自动弹出;紧急通知无论是否上课立即展开(红色呼吸光晕 + 紧急角标,无需点击)
- 上课状态来自 GET /api/schedules/current(10s 心跳 + 下课时长定时器),
  新增 src/main/island.ts(控制器/IPC)、src/preload/island.ts、src/island/IslandApp.vue、
  src/renderer/island/bridge.ts、island.html 与多页 Vite 构建

## 上课时段发布紧急通知的二次确认

- 服务端:priority=URGENT 且当前处于上课时间段且未带 confirmDuringClass 时返回
  409 URGENT_DURING_CLASS(携带 current/next/week/serverTime),绕过界面调接口同样被拦截
- 新增 GET /api/schedules/current(inClass/current/next,at 参数用于诊断与测试)
- Web 管理端:全屏警告遮罩(UrgentClassWarning.vue),显示正在上的课时段与待发布标题,
  确认按钮带 3 秒倒计时(圆环进度,倒计时结束前禁用),取消即不发;并发命中 409 同样兜底弹窗

## 打包与验证脚本修复(Electron 宿主环境)

- scripts/lib/node-runtime.mjs:定位真正的 node.exe。此前经 pnpm 运行时 process.execPath 指向
  Electron,dist:server 会把 Electron 复制成安装包内的 node.exe,dist:win 会让 electron-builder 报
  Unknown argument: .../cli.js(改为显式使用真实 Node)
- scripts/lib/electron-env.mjs:启动 Electron 前清理继承的 ELECTRON_RUN_AS_NODE(否则 Electron 退化为
  纯 Node,require('electron') 直接 MODULE_NOT_FOUND),并从 path.txt 定位 electron.exe
  (process.versions.electron 存在时 import 'electron' 返回的是 API 对象而非路径)
- scripts/dist-server.mjs:新增构建产物新鲜度校验(曾出现 server 已重建但打进安装包的 shared/dist 是旧的,
  安装后服务启动即报 does not provide an export named MAX_TERM_WEEK)
- scripts/ui-smoke:新增 live-probe.mjs 真实制造/清理上课时段,Electron 驱动真实点击断言全屏警告文案、
  按钮禁用与 3 秒倒计时时长(实测 2966ms),测试后自动删除探针课表与测试通知

## 验证结果

- pnpm verify:e2e 62/62(新增上课时段拦截、二次确认后发布、普通通知不受限等 8 项)
- pnpm verify:web 8/8(新增上课时段紧急通知全屏警告 + 倒计时)
- pnpm verify:desktop 18/18(新增灵动岛窗口/上课隐藏/下课弹出/紧急立即展开/胶囊点击展开 + 截图)
- 打包后 EXE 冒烟 15/15(packaged: true,退出码 0)
- 服务端安装程序升级安装:.env 与数据库保持不变,/api/schedules/current 正常
- pnpm lint / format:check / typecheck 全部通过
2026-09-12 08:41:08 +08:00

99 lines
3.9 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.
/**
* 定位"真正的 Node 运行时"。
*
* 为什么需要它:本机的 `pnpm` 运行在 Electron 内置的 Node 上(宿主终端本身是 Electron 应用,
* 例如 DSH 桌面端内置终端)。此时 `process.execPath` 指向 **Electron 可执行文件**,
* 用它去跑 Node CLI 会得到两种典型故障:
*
* 1. 没有 `ELECTRON_RUN_AS_NODE=1` 时:Electron 把脚本当成"应用入口"启动,
* `process.argv` 里多出一个位置参数 —— 例如 electron-builder 直接报
* `Unknown argument: .../electron-builder/out/cli/cli.js`。
* 2. 有该变量时虽然能跑,但会把 Electron 当成 Node 复制进安装包,
* 目标机上 `node.exe` 其实是 Electron,服务根本起不来。
*
* 因此凡是"需要纯 Node 去执行脚本"的地方(打包 CLI、内置运行时分发),
* 都用 `resolveNodeRuntime()` 拿一个真实的 node 可执行文件。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
/** 判断候选可执行文件能否以"纯 Node 身份"运行 */
function isNodeRuntime(candidate) {
const probe = spawnSync(
candidate,
['-e', "process.stdout.write(process.versions.electron ? 'electron' : 'node')"],
{
encoding: 'utf8',
timeout: 20_000,
// 若候选其实是 Electron,则让它以 Node 模式运行,避免真的弹出应用窗口
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
},
);
const kind = String(probe.stdout ?? '').trim();
return { ok: probe.status === 0 && kind === 'node', kind: kind || probe.error?.message || '未知' };
}
/**
* 返回 `{ path, version }`;找不到时抛错(附带尝试过的候选,便于排查)。
* @param {{ extraCandidates?: string[] }} [options]
*/
export function resolveNodeRuntime(options = {}) {
const candidates = [];
const push = (value) => {
if (value && !candidates.includes(value)) candidates.push(value);
};
// 1) 显式覆盖(打包机/CI 容易配置)
push(process.env.CLASSHELPER_NODE_EXE);
push(process.env.NODE_EXE);
// 2) 当前进程本身就是 node.exe 时直接复用
if (path.basename(process.execPath).toLowerCase() === 'node.exe') push(process.execPath);
for (const candidate of options.extraCandidates ?? []) push(candidate);
// 3) PATH 中的 node(where node / which node)
try {
const finder = process.platform === 'win32' ? 'where' : 'which';
const found = spawnSync(finder, ['node'], { encoding: 'utf8' });
if (found.status === 0) {
for (const line of String(found.stdout ?? '').split(/\r?\n/)) push(line.trim());
}
} catch {
/* 忽略:继续尝试常见安装路径 */
}
// 4) 常见安装路径
push(path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'));
push(path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'nodejs', 'node.exe'));
push(path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'nodejs', 'node.exe'));
push(path.join(process.env.NVM_SYMLINK ?? '', 'node.exe'));
push(path.join(process.env.APPDATA ?? '', 'nvm', 'current', 'node.exe'));
push('D:\\node.js\\node.exe');
push('/usr/local/bin/node');
push('/usr/bin/node');
const rejected = [];
for (const candidate of candidates) {
try {
if (!fs.statSync(candidate).isFile()) continue;
} catch {
continue;
}
const verdict = isNodeRuntime(candidate);
if (verdict.ok) {
const version = spawnSync(candidate, ['--version'], { encoding: 'utf8' }).stdout?.trim() ?? '';
return { path: candidate, version };
}
rejected.push(`${candidate}(${verdict.kind})`);
}
throw new Error(
'未找到可用的 Node 运行时。\n' +
` 已尝试:\n - ${rejected.join('\n - ') || '(无候选)'}\n` +
' 请安装 Node.js,或用环境变量 CLASSHELPER_NODE_EXE 指定 node.exe 的完整路径。',
);
}