feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
(保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
`--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。
三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。
更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。
Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
(原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。
版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
This commit is contained in:
1 parent
aad22df4fd
commit
9a38f86cd3
77 files changed
+8021
-899
No files matched your search
@@ -958,9 +958,9 @@ class IslandController {
|
||||
}
|
||||
|
||||
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
|
||||
private targetDisplay(): Electron.Display {
|
||||
private targetDisplay(cursor?: { x: number; y: number } | null): Electron.Display {
|
||||
return this.appearance.followCursorDisplay
|
||||
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||
? screen.getDisplayNearestPoint(cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||
: screen.getPrimaryDisplay();
|
||||
}
|
||||
|
||||
@@ -970,10 +970,13 @@ class IslandController {
|
||||
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
|
||||
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
|
||||
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
|
||||
*
|
||||
* 不需要额外监听 `screen` 的 display-* 事件:目标屏是由**当前光标位置**或主屏推出来的,
|
||||
* 插拔/切主屏之后下一轮 tick 自然会算出不同的 id,一样能触发重排。
|
||||
*/
|
||||
private syncAnchorDisplay(): void {
|
||||
private syncAnchorDisplay(cursor?: { x: number; y: number } | null): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const displayId = this.targetDisplay().id;
|
||||
const displayId = this.targetDisplay(cursor).id;
|
||||
if (this.anchorDisplayId === displayId) return;
|
||||
this.anchorDisplayId = displayId;
|
||||
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
|
||||
@@ -1409,11 +1412,14 @@ class IslandController {
|
||||
/**
|
||||
* 光标相对窗口左上角的本地坐标(CSS px / DIP,与渲染进程上报的岛体矩形同一坐标系)。
|
||||
* 冒烟里用 `setHitTestCursor` 注入,真实场景读系统光标。
|
||||
*
|
||||
* `cursor`:调用方在本轮 tick 里已经读过的光标位置(见 `startHitPoll`)。
|
||||
* 传进来就复用它,避免同一个 tick 里重复调 `screen.getCursorScreenPoint()`。
|
||||
*/
|
||||
private cursorLocal(): { x: number; y: number } | null {
|
||||
private cursorLocal(cursor?: { x: number; y: number } | null): { x: number; y: number } | null {
|
||||
if (!this.win || this.win.isDestroyed()) return null;
|
||||
const bounds = this.win.getBounds();
|
||||
const point = this.cursorOverride ?? screen.getCursorScreenPoint();
|
||||
const point = cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint();
|
||||
return { x: point.x - bounds.x, y: point.y - bounds.y };
|
||||
}
|
||||
|
||||
@@ -1494,7 +1500,7 @@ class IslandController {
|
||||
}
|
||||
|
||||
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
|
||||
private syncHitFromCursor(): void {
|
||||
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
|
||||
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
|
||||
this.setInteractive(false, '窗口不可见');
|
||||
return;
|
||||
@@ -1505,18 +1511,34 @@ class IslandController {
|
||||
this.setInteractive(false, '隐藏态');
|
||||
return;
|
||||
}
|
||||
this.setInteractive(this.isInsideHitRect(this.cursorLocal()), '光标轮询');
|
||||
this.setInteractive(this.isInsideHitRect(this.cursorLocal(cursor)), '光标轮询');
|
||||
}
|
||||
|
||||
private startHitPoll(): void {
|
||||
if (this.hitTimer) return;
|
||||
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
||||
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
||||
//
|
||||
// 每一跳的成本要压到最低(这个定时器从岛初始化一直跑到进程退出):
|
||||
// 1) 光标位置**每跳只读一次**,命中判定与"跟随鼠标屏幕"共用 —— 原先 cursorLocal() 与
|
||||
// targetDisplay() 各读一次,等于每跳两次 `screen.getCursorScreenPoint()`;
|
||||
// 2) 窗口不可见时直接返回:此时命中判定与心跳检查本来就各自早退(窗口不可见 ⇒ 无命中可言),
|
||||
// 留着那两次系统调用纯属白跑。
|
||||
this.hitTimer = setInterval(() => {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const visible = this.win.isVisible();
|
||||
// 不可见、且不跟随鼠标屏幕时,连读光标都可省掉(targetDisplay 会走主屏分支)
|
||||
const cursor =
|
||||
visible || this.appearance.followCursorDisplay
|
||||
? (this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||
: null;
|
||||
|
||||
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
|
||||
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
|
||||
this.syncAnchorDisplay();
|
||||
this.syncHitFromCursor();
|
||||
this.syncAnchorDisplay(cursor);
|
||||
if (!visible) return;
|
||||
|
||||
this.syncHitFromCursor(cursor);
|
||||
this.checkRendererAlive();
|
||||
}, 60);
|
||||
// 定时器不应拖住进程退出
|
||||
@@ -1748,12 +1770,12 @@ const MIN_CARD_HEIGHT = 120;
|
||||
|
||||
|
||||
/**
|
||||
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
|
||||
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
|
||||
* 超过这个时间没上报就认为它已经卡死/不响应。
|
||||
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
|
||||
* 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后
|
||||
* 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。
|
||||
*/
|
||||
const RENDERER_ALIVE_INTERVAL_MS = 5_000;
|
||||
const RENDERER_ALIVE_TIMEOUT_MS = 20_000;
|
||||
/** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */
|
||||
const RECOVER_MAX_TIMES = 3;
|
||||
|
||||
Reference in new issue
Block a user