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
@@ -27,6 +27,8 @@ export const API_PATHS = {
|
||||
integrations: '/integrations',
|
||||
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
|
||||
database: '/database',
|
||||
/** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */
|
||||
update: '/update',
|
||||
dashboard: '/dashboard/summary',
|
||||
} as const;
|
||||
|
||||
@@ -496,3 +498,29 @@ export const MAX_PAGE_SIZE = 200;
|
||||
|
||||
/** 教师与管理员角色集合,便于 RBAC 判断 */
|
||||
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
|
||||
|
||||
/* ------------------------------------------------------------------ 更新检查 */
|
||||
|
||||
/**
|
||||
* 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包、便携版、插件 .cipx),
|
||||
* 因此"有没有新版本"就是问 GitHub 上最新的那个 Release。
|
||||
*
|
||||
* 仓库是**公开**的,匿名调用即可(不需要 token);但匿名限流是 60 次/小时/IP,
|
||||
* 所以服务端与客户端都把结果缓存 `UPDATE_CHECK_CACHE_TTL_MS`,不要每次开页面都打一次。
|
||||
*/
|
||||
export const GITHUB_REPO = { owner: 'LoveChengke', repo: 'ClassHelper' } as const;
|
||||
|
||||
/** 最新 Release 的 API 地址(GitHub 保证它不含草稿与预发布版) */
|
||||
export const GITHUB_RELEASES_LATEST_API = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`;
|
||||
|
||||
/** 给人看的 Release 列表页(「前往下载」按钮打开它) */
|
||||
export const GITHUB_RELEASES_PAGE = `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases`;
|
||||
|
||||
/** GitHub API 强制要求 User-Agent,不带会直接 403 */
|
||||
export const UPDATE_CHECK_USER_AGENT = 'ClassHelper-UpdateCheck';
|
||||
|
||||
/** 检查结果缓存时长(30 分钟):既避开匿名限流,也不至于让用户等太久才知道有新版本 */
|
||||
export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */
|
||||
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
|
||||
@@ -41,7 +41,7 @@ export function canAssignTeachers(role: UserRole): boolean {
|
||||
return role === 'ADMIN';
|
||||
}
|
||||
|
||||
/** 学生名单(增删学生、重置密码、导入名单):仅管理员 */
|
||||
/** 学生名单(增删学生、导入名单):仅管理员(学生没有账号,因此没有"重置密码"这一步) */
|
||||
export function canManageRoster(role: UserRole): boolean {
|
||||
return role === 'ADMIN';
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export const PERMISSION_MATRIX: readonly {
|
||||
}[] = [
|
||||
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
|
||||
{ action: '分配班主任与科任老师', admin: true, head: false, subject: false },
|
||||
{ action: '学生名单管理(增删 / 重置密码 / 导入)', admin: true, head: false, subject: false },
|
||||
{ action: '学生名单管理(增删 / 导入)', admin: true, head: false, subject: false },
|
||||
{ action: '教师录入(新建 / 导入名单 / 重置密码)', admin: true, head: false, subject: false },
|
||||
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
|
||||
{ action: '布置作业', admin: true, head: true, subject: true },
|
||||
|
||||
@@ -1098,3 +1098,41 @@ export interface DatabaseSwitchStepDto {
|
||||
status: 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 更新检查 */
|
||||
|
||||
/** Release 里的一个附件(安装包 / 便携版 / 插件包) */
|
||||
export interface UpdateAssetDto {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检查结果(服务端 `GET /api/update/check` 与桌面客户端 IPC 共用同一结构)。
|
||||
*
|
||||
* 两点约定:
|
||||
* 1. **`ok:false` 是正常结果,不是异常** —— 服务器没有外网、GitHub 限流、请求超时都会走到这里,
|
||||
* 调用方按"暂时查不到"呈现即可(与 ClassIsland 推送失败只记日志是同一种处理思路)。
|
||||
* 2. **`currentVersion` 由调用方各自提供**:服务端比的是服务端版本、客户端比的是客户端版本。
|
||||
* 两边安装版本可能不同(例如教室机没跟着升级),所以不能由一端替另一端判断。
|
||||
*/
|
||||
export interface UpdateInfo {
|
||||
ok: boolean;
|
||||
/** 失败原因(给人看的一句话);ok=true 时为 null */
|
||||
error: string | null;
|
||||
/** 本机版本 */
|
||||
currentVersion: string;
|
||||
/** GitHub 上最新 Release 的版本号;ok=false 时为 null */
|
||||
latestVersion: string | null;
|
||||
hasUpdate: boolean;
|
||||
/** 给人看的 Release 页面地址(「前往下载」用) */
|
||||
releaseUrl: string;
|
||||
/** Release 标题 */
|
||||
releaseName: string | null;
|
||||
publishedAt: string | null;
|
||||
/** Release 说明(Markdown 原文,由界面自行截断展示) */
|
||||
notes: string | null;
|
||||
assets: UpdateAssetDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
@@ -28,9 +28,7 @@ export function isDayKey(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return (
|
||||
date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
|
||||
);
|
||||
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
|
||||
}
|
||||
|
||||
/** 在 YYYY-MM-DD 上加减天数(用于日期选择器的区间与"昨天/明天") */
|
||||
@@ -357,3 +355,65 @@ export function minutesUntil(time: string, now: Date = new Date()): number {
|
||||
if (target < 0) return 0;
|
||||
return target - (now.getHours() * 60 + now.getMinutes());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 版本比较(更新检查) */
|
||||
|
||||
/**
|
||||
* 版本号归一化:去掉首尾空白与前导的 `v` / `V`。
|
||||
*
|
||||
* 之所以需要:GitHub 的 tag 是 `v1.0.0`,而 package.json / `app.getVersion()` 给的是 `1.0.0`,
|
||||
* 两边不归一化就没法比。三端共用这一个实现,避免"客户端说有新版、服务端说没有"。
|
||||
*/
|
||||
export function normalizeVersion(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().replace(/^[vV]/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个版本号:`a` 比 `b` 新返回正数,旧返回负数,相同返回 0。
|
||||
*
|
||||
* 规则:
|
||||
* 1. 按 `.` 分段比数字(每段只取开头的连续数字,因此 `1.0.0-fix` 的第三段仍是 `0`);
|
||||
* 2. 数字全部相同、只有后缀不同时,**带后缀的一方视为更新**。
|
||||
*
|
||||
* 第 2 条是为 `v0.1.0-fix` 这种"同一个版本号的修复发布"定的:按标准 semver,
|
||||
* `0.1.0-fix` 是 `0.1.0` 的**预发布**(应当更旧),但本项目是拿它当"修完再补发一版"用的,
|
||||
* 照 semver 判会让那条修复版永远提示不出来。另外 `/releases/latest` 本身会跳过预发布,
|
||||
* 所以不用担心把 `-beta` 当成正式版。
|
||||
*/
|
||||
export function compareVersions(a: string | null | undefined, b: string | null | undefined): number {
|
||||
const left = normalizeVersion(a);
|
||||
const right = normalizeVersion(b);
|
||||
if (left === right) return 0;
|
||||
|
||||
const parse = (value: string): { numbers: number[]; suffix: string } => {
|
||||
const [core = '', ...rest] = value.split('-');
|
||||
const numbers = core.split('.').map((part) => {
|
||||
const matched = /^\d+/.exec(part.trim());
|
||||
return matched ? Number(matched[0]) : 0;
|
||||
});
|
||||
return { numbers, suffix: rest.join('-') };
|
||||
};
|
||||
|
||||
const l = parse(left);
|
||||
const r = parse(right);
|
||||
const length = Math.max(l.numbers.length, r.numbers.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (l.numbers[index] ?? 0) - (r.numbers[index] ?? 0);
|
||||
if (diff !== 0) return diff > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
if (l.suffix === r.suffix) return 0;
|
||||
// 数字相同时:裸版本更旧(1.0.0 < 1.0.0-fix),两个都有后缀才按字典序
|
||||
if (!l.suffix) return -1;
|
||||
if (!r.suffix) return 1;
|
||||
return l.suffix.localeCompare(r.suffix);
|
||||
}
|
||||
|
||||
/** `latest` 是否比 `current` 新(更新检查的判定入口;latest 为空一律视为"没有新版") */
|
||||
export function isNewerVersion(
|
||||
current: string | null | undefined,
|
||||
latest: string | null | undefined,
|
||||
): boolean {
|
||||
if (!normalizeVersion(latest)) return false;
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
Reference in new issue
Block a user