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
+105
-30
@@ -103,9 +103,14 @@ const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
||||
{ label: '通用', path: '/settings/general' },
|
||||
];
|
||||
|
||||
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项 */
|
||||
/**
|
||||
* 一级菜单项:2026-10-05 起「学习」分组已拆掉,课表/作业/通知/成绩**直接铺在侧栏一级**。
|
||||
* 下面的结构自检会确认它们真的不在任何 `el-sub-menu` 里。
|
||||
*/
|
||||
const PRIMARY_MENU_ITEMS = ['课表', '作业', '通知', '成绩'];
|
||||
|
||||
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项。设置组自成一类,保留分组 */
|
||||
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
|
||||
{ title: '学习', labels: ['课表', '作业', '通知', '成绩'] },
|
||||
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
|
||||
];
|
||||
|
||||
@@ -122,16 +127,18 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
||||
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
|
||||
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
|
||||
Array.from(element.querySelectorAll('span')).some(
|
||||
(span) => (span.textContent ?? '').trim() === label,
|
||||
),
|
||||
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === label),
|
||||
);
|
||||
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
|
||||
const findGroupTitle = (title: string): HTMLElement | undefined =>
|
||||
findByLabel('.el-sub-menu__title', title);
|
||||
|
||||
if (!findGroupTitle('学习') || !findGroupTitle('设置')) {
|
||||
return { ok: false, detail: '侧边栏分组未渲染(当前不在主布局或未登录)' };
|
||||
if (!findGroupTitle('设置')) {
|
||||
return { ok: false, detail: '侧边栏「设置」分组未渲染(当前不在主布局或未登录)' };
|
||||
}
|
||||
const missingPrimary = PRIMARY_MENU_ITEMS.filter((label) => !findMenuItem(label));
|
||||
if (missingPrimary.length > 0) {
|
||||
return { ok: false, detail: `侧边栏缺少一级项:${missingPrimary.join('、')}` };
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
@@ -150,7 +157,13 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// 分组结构自检:两个分组各自应包含全部子项
|
||||
// 结构自检:
|
||||
// ① 一级项必须**真的在一级** —— 不能还挂在某个 el-sub-menu 里(用户要求把"学习"拆掉);
|
||||
// ② 分组各自应包含全部子项
|
||||
for (const label of PRIMARY_MENU_ITEMS) {
|
||||
const element = findMenuItem(label);
|
||||
if (element?.closest('.el-sub-menu')) failures.push(`${label}(仍挂在分组里,应在一级)`);
|
||||
}
|
||||
for (const group of MENU_GROUPS) {
|
||||
for (const label of group.labels) {
|
||||
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
|
||||
@@ -874,12 +887,8 @@ export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
|
||||
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
|
||||
const clicks: string[] = [];
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const action = document.querySelector('.onboarding-welcome [data-action="finish"]')
|
||||
? 'finish'
|
||||
: 'next';
|
||||
const button = document.querySelector<HTMLElement>(
|
||||
`.onboarding-welcome [data-action="${action}"]`,
|
||||
);
|
||||
const action = document.querySelector('.onboarding-welcome [data-action="finish"]') ? 'finish' : 'next';
|
||||
const button = document.querySelector<HTMLElement>(`.onboarding-welcome [data-action="${action}"]`);
|
||||
if (!button) break;
|
||||
clicks.push(action);
|
||||
button.click();
|
||||
@@ -957,20 +966,48 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||
const configAfterFlip = await window.desktop?.getConfig?.();
|
||||
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
|
||||
|
||||
// 2) 侧边栏:点汉堡折叠 → collapse 态 + 宽度收窄 → 配置落盘 → 再展开还原
|
||||
// 1.5) 顶栏右侧必须水平对齐(用户反馈"深色模式没对齐",两种主题其实都偏)。
|
||||
// Element Plus 给按钮的是 `vertical-align:middle`、给下拉容器的是 `vertical-align:top`,
|
||||
// 容器不是 flex 的话两个 inline-level 子元素会各按各的规则落位,错开几像素。
|
||||
const centerYOf = (element: Element | null | undefined): number | null => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 ? rect.top + rect.height / 2 : null;
|
||||
};
|
||||
const themeToggleY = centerYOf(document.querySelector('[data-test="theme-toggle"]'));
|
||||
const userChipY = centerYOf(document.querySelector('.user-chip'));
|
||||
const headerCenterDelta =
|
||||
themeToggleY !== null && userChipY !== null ? Math.abs(themeToggleY - userChipY) : null;
|
||||
const headerAligned = headerCenterDelta !== null && headerCenterDelta <= 1.5;
|
||||
|
||||
// 2) 侧边栏:点汉堡折叠 → 图标导轨态 + 宽度收窄(是动画,要等它走完)→ 配置落盘 → 再展开还原
|
||||
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const widthBefore = asideBefore?.offsetWidth ?? 0;
|
||||
// 2.0) 折叠必须是**动画**而不是瞬切(用户要求"重写菜单的动画")。
|
||||
// 不去抓中间帧(时序敏感),直接断言侧栏上挂着 width 的过渡 —— 确定性的。
|
||||
const asideComputed = asideBefore ? getComputedStyle(asideBefore) : null;
|
||||
const asideTransitionProperty = asideComputed?.transitionProperty ?? '';
|
||||
const asideTransitionSeconds = asideComputed ? Number.parseFloat(asideComputed.transitionDuration) : 0;
|
||||
const widthAnimated =
|
||||
asideTransitionProperty.includes('width') &&
|
||||
Number.isFinite(asideTransitionSeconds) &&
|
||||
asideTransitionSeconds > 0.05;
|
||||
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
|
||||
const collapsed = await waitUntil(() => Boolean(document.querySelector('.el-menu--collapse')), 2500);
|
||||
const collapsed = await waitUntil(() => {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 80;
|
||||
}, 4000);
|
||||
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const widthAfter = asideAfter?.offsetWidth ?? 0;
|
||||
const configAfterCollapse = await window.desktop?.getConfig?.();
|
||||
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
|
||||
|
||||
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
|
||||
// a) 菜单里不允许残留任何**可见**的文字(Element Plus 靠宽度归零隐藏,一旦被外层样式
|
||||
// 干扰就会留"半截字");
|
||||
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px),否则看起来"没对齐"。
|
||||
// a) 菜单里不允许残留任何**可见**的文字。折叠实现是"文字宽度被 flex 压到 0 + overflow:hidden
|
||||
// 裁掉 + opacity 归零"(`display:none` 无法过渡,用不了),所以判据是"看得见"
|
||||
// —— 有宽度且未透明 —— 而不是"根本没参与布局";
|
||||
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px)。
|
||||
// 分组标题里还有个绝对定位的折叠箭头,它同样带 el-icon 类但已淡出,量它没有意义,排除。
|
||||
const centerXOf = (element: Element | null | undefined): number | null => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -983,24 +1020,48 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||
const element = node as HTMLElement;
|
||||
if (element.classList.contains('menu-icon-slot')) continue;
|
||||
const text = (element.textContent ?? '').trim();
|
||||
if (text && element.offsetParent !== null && element.getClientRects().length > 0) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
// 宽高都归零但 display 不为 none 的,正是"0 宽溢出"的残影来源
|
||||
if (rect.width < 1) leftoverText.push(`0宽:${text.slice(0, 6)}`);
|
||||
else leftoverText.push(`可见:${text.slice(0, 6)}`);
|
||||
}
|
||||
if (!text || element.getClientRects().length === 0) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const opacity = Number(getComputedStyle(element).opacity || '1');
|
||||
if (rect.width >= 1 && opacity > 0.05) leftoverText.push(`可见:${text.slice(0, 6)}`);
|
||||
}
|
||||
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
|
||||
const iconCenters = Array.from(
|
||||
document.querySelectorAll('.aside .el-menu--collapse .el-sub-menu__title .el-icon'),
|
||||
document.querySelectorAll(
|
||||
'.aside.is-collapsed .menu-icon-slot .el-icon, ' +
|
||||
'.aside.is-collapsed .el-sub-menu__title > .el-icon:not([class*="icon-arrow"])',
|
||||
),
|
||||
).map((node) => centerXOf(node));
|
||||
const iconDeltas = iconCenters
|
||||
.filter((value): value is number => value !== null && toggleCenter !== null)
|
||||
.map((value) => Math.abs(value - (toggleCenter as number)));
|
||||
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
|
||||
|
||||
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
|
||||
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
|
||||
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
|
||||
const settingsItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
|
||||
).find((element) =>
|
||||
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === '通用'),
|
||||
);
|
||||
const submenuOpen = (): boolean => Boolean(settingsItem && settingsItem.offsetParent !== null);
|
||||
if (!submenuOpen()) {
|
||||
// el-sub-menu__title 的点击是 **toggle**:先判"子项是否可见"再点,
|
||||
// 盲点一次会把已展开的组收起来(AGENTS §5 第 49 条)
|
||||
document.querySelector<HTMLElement>('.aside .el-sub-menu__title')?.click();
|
||||
await waitUntil(() => submenuOpen(), 2000);
|
||||
}
|
||||
const railMenu = document.querySelector<HTMLElement>('.aside .menu');
|
||||
const railOverflowPx = railMenu ? railMenu.scrollHeight - railMenu.clientHeight : Number.NaN;
|
||||
const railFits = Number.isFinite(railOverflowPx) && railOverflowPx <= 1;
|
||||
|
||||
clickToggle('[data-test="sidebar-toggle"]');
|
||||
await waitUntil(() => !document.querySelector('.el-menu--collapse'), 2500);
|
||||
// 展开同样是动画:等宽度回到 200 再继续,否则下面量页脚时侧栏还停在中间宽度
|
||||
const expandedBack = await waitUntil(() => {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
return !aside?.classList.contains('is-collapsed') && (aside?.offsetWidth ?? 0) >= 190;
|
||||
}, 4000);
|
||||
|
||||
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
|
||||
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
|
||||
@@ -1018,20 +1079,27 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||
const ok =
|
||||
flipped &&
|
||||
themePersisted &&
|
||||
headerAligned &&
|
||||
widthAnimated &&
|
||||
collapsed &&
|
||||
collapsePersisted &&
|
||||
widthAfter < widthBefore &&
|
||||
widthAfter <= 80 &&
|
||||
expandedBack &&
|
||||
leftoverText.length === 0 &&
|
||||
iconsAligned &&
|
||||
railFits &&
|
||||
footerVisible;
|
||||
return {
|
||||
ok,
|
||||
detail:
|
||||
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
|
||||
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 落盘=${collapsePersisted} ` +
|
||||
`顶栏对齐=${headerAligned}(中线差=${headerCenterDelta === null ? '-' : headerCenterDelta.toFixed(1)}px,要求 ≤1.5px) ` +
|
||||
`宽度过渡=${widthAnimated}(${asideTransitionProperty} ${asideTransitionSeconds}s) ` +
|
||||
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 展开还原=${expandedBack} 落盘=${collapsePersisted} ` +
|
||||
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
|
||||
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
|
||||
`导轨无溢出=${railFits}(溢出=${Number.isFinite(railOverflowPx) ? `${railOverflowPx}px` : '-'}) ` +
|
||||
`侧栏底部可见=${footerVisible}`,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1063,10 +1131,17 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
|
||||
const info = await window.desktop?.getAppInfo?.();
|
||||
const hasConfigPath = Boolean(info?.configPath);
|
||||
|
||||
// 检查更新入口(新功能回归):关于页要有这一块,且按钮可点。
|
||||
// 这里只断言"入口存在"——是否真有新版本取决于 GitHub,不该绑进冒烟(见 main/update.ts)
|
||||
const updateBlock = document.querySelector('[data-test="update-check"]');
|
||||
const updateButton = document.querySelector('[data-test="update-check-button"]');
|
||||
|
||||
await router.push('/schedule');
|
||||
return {
|
||||
ok: hasThanks && hasDiagnostics && hasConfigPath,
|
||||
detail: `鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'}`,
|
||||
ok: hasThanks && hasDiagnostics && hasConfigPath && Boolean(updateBlock) && Boolean(updateButton),
|
||||
detail:
|
||||
`鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'} ` +
|
||||
`检查更新块=${updateBlock ? '有' : '无'} 按钮=${updateButton ? '有' : '无'}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user