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:
LoveChengke committed 2026-10-05 16:39:38 +08:00
1 parent aad22df4fd
commit 9a38f86cd3
77 files changed
+8021 -899

No files matched your search

@@ -19,22 +19,24 @@ const notifications = useNotificationStore();
const ui = useUiStore();
/**
* 主侧边栏:ClassIsland 式「分组 + 子菜单」。
* - 学习:课表 / 作业 / 通知 / 成绩
* - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
* 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
* 主侧边栏。
*
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
*
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/
const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
const menuGroups = [
{
title: '学习',
icon: 'Reading',
items: [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
],
},
{
title: '设置',
icon: 'Setting',
@@ -60,19 +62,19 @@ const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
return group?.title ?? '学习';
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null;
});
/**
* 分组展开控制:默认只展开**当前路由所在的分组**。
* 分组展开控制:默认只展开**当前路由所在的分组**(一级项不属于任何分组,此时一个都不展开)。
*
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
* 用户手动展开其它分组不受限制(可多个同时展开)。
* 窗口默认高度与 ClassIsland 对齐(582px),设置组 6 个子项展开后会超出可视高度、
* 把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的观感(它也只展开当前组),
* 又让小窗口下的侧栏始终完整可见。用户手动展开其它分组不受限制(可多个同时展开)。
*/
const menuRef = ref<MenuInstance>();
const openedGroups = ref<string[]>([activeGroupTitle.value]);
const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.value] : []);
watch(
() => route.path,
@@ -166,7 +168,11 @@ onUnmounted(() => {
<template>
<el-container class="layout">
<el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
<el-aside
:width="ui.sidebarCollapsed ? '64px' : '200px'"
class="aside ch-nav"
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
>
<div class="brand">
<button
type="button"
@@ -178,22 +184,35 @@ onUnmounted(() => {
>
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
</button>
<template v-if="!ui.sidebarCollapsed">
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</template>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div>
<el-menu
ref="menuRef"
:default-active="activeMenu"
:default-openeds="openedGroups"
:collapse="ui.sidebarCollapsed"
:collapse-width="64"
class="menu"
@select="handleMenuSelect"
>
<!-- 一级项:直接铺在侧栏上(不再套「学习」分组)。
下面 el-sub-menu 里的菜单项与本段是同一套标记(图标槽 + 未读红点 + 标题),
改动时两处要一起改。 -->
<el-menu-item v-for="item in primaryItems" :key="item.path" :index="item.path">
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
@@ -203,13 +222,19 @@ onUnmounted(() => {
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge v-if="showBadge(item.path)" :value="notifications.unreadCount" :max="99" class="menu-badge" />
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
<div v-if="!ui.sidebarCollapsed" class="aside-footer">
<!-- 同上:折叠时不删节点,只淡出并收掉内边距 -->
<div class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
</div>
@@ -285,6 +310,11 @@ onUnmounted(() => {
.aside {
display: flex;
flex-direction: column;
/* 展开/收回动画:宽度来自 el-aside 的内联 --el-aside-width,这里只负责过渡。
overflow:hidden 是必需的 —— 收窄过程中文字要被栏边裁掉,不能溢出到主区域;
菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */
overflow: hidden;
transition: width var(--nav-dur) var(--nav-ease);
}
.brand {
@@ -296,6 +326,9 @@ onUnmounted(() => {
font-weight: 600;
font-size: 14px;
color: var(--ch-text);
transition:
padding-left var(--nav-dur) var(--nav-ease),
gap var(--nav-dur) var(--nav-ease);
}
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
@@ -318,10 +351,14 @@ onUnmounted(() => {
background: var(--ch-hover-soft);
}
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
.aside.ch-nav:has(.el-menu--collapse) .brand {
justify-content: center;
padding: 0;
/* 折叠态:侧栏只剩 64px,汉堡按钮水平居中。
用**位移 padding-left** 而不是 `justify-content: center` —— 后者不可过渡,会在动画中途"跳"一下。
汉堡宽 28px,padding-left 18px ⇒ 中心 = 18 + 14 = 32px,正好是 64/2,与折叠后的图标导轨同列
(冒烟断言中心差 ≤ 2px)。
这里刻意用**自己的类名**(`.is-collapsed`)而不是 `:has(.el-menu--collapse)`:
`:has()` 的失效会让 Chromium 标记整棵文档树重新计算样式。 */
.aside.ch-nav.is-collapsed .brand {
padding-left: 18px;
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
@@ -331,13 +368,56 @@ onUnmounted(() => {
justify-content: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
overflow: hidden;
transition:
width var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/*
* 品牌文字 / 菜单文字在折叠时**不靠 max-width 归零**(那要么写死一个魔法数字,要么
* `none → 0` 根本不可过渡),而是:
* - `min-width: 0` 让这个 flex 子项能被压缩(默认的 `min-width: auto` 会撑住内容宽度);
* - 宽度自然跟着栏宽收缩,多出来的字被 `overflow: hidden` 裁掉;
* - 再叠一层 opacity 淡出,避免在中间帧里留下"半截字"。
* 于是文字宽度是**跟着侧栏宽度连续变化**的,不需要给它单独设动画终点。
*/
.brand-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
letter-spacing: 0.2px;
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 折叠:品牌区收成 0 宽并淡出(不是删节点,理由见模板上的注释) */
.aside.ch-nav.is-collapsed .brand {
gap: 0;
}
/*
* 图标导轨里**不显示滚动条**:所有图标应当一眼看全(用户要求"不要用滚轮、删掉滚动条")。
* 导轨之所以放得下,靠的是页脚在折叠态把高度也收成 0(见 .is-collapsed .aside-footer)——
* 不再有那 ~70px 的留白,11 行图标(462px)在 540px 的最小窗口里也够。
* 这里只隐藏滚动条本身,`overflow-y: auto` 保留:窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
* 展开态**不隐藏** —— 那一栏确实会超(设置组展开时约 606px > 582px),
* 滚动条是"下面还有内容"的唯一提示,不能拿掉。
*/
.aside.ch-nav.is-collapsed .menu {
scrollbar-width: none;
}
.aside.ch-nav.is-collapsed .brand-logo {
width: 0;
opacity: 0;
}
.aside.ch-nav.is-collapsed .brand-text {
opacity: 0;
}
.menu {
@@ -393,6 +473,30 @@ onUnmounted(() => {
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid var(--ch-divider);
overflow: hidden;
/* max-height 是给折叠动画用的终点:`* { box-sizing: border-box }`,展开态给一个略高于
内容(约 70px)的值,折叠时归零 —— 导轨里**一个像素都不给页脚留**。
不这么做的话,文字淡出后仍占着 ~70px,10 个图标在 540px 的最小窗口里就放不下、被迫滚动
(用户反馈:"图标无法完全显示、需要上下滑动、滚动条碍眼")。 */
max-height: 96px;
transition:
opacity calc(var(--nav-dur) * 0.7) var(--nav-ease),
padding var(--nav-dur) var(--nav-ease),
max-height var(--nav-dur) var(--nav-ease),
border-top-color var(--nav-dur) var(--nav-ease);
}
/* 折叠:页脚淡出、内边距与高度一起收到 0,分隔线也透明掉。
`min-height: 0` 是必需的:flex 子项默认 `min-height: auto`(= 内容高度),
而 CSS 里 min-height 优先于 max-height —— 不显式归零的话 max-height:0 根本压不下去。 */
.aside.ch-nav.is-collapsed .aside-footer {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
max-height: 0;
min-height: 0;
border-top-color: transparent;
pointer-events: none;
}
.aside-meta {
@@ -425,6 +529,17 @@ onUnmounted(() => {
color: var(--ch-text-tertiary);
}
.header-right {
/* 必须是 flex:Element Plus 的按钮是 `vertical-align: middle`、下拉容器是 `vertical-align: top`,
放在普通 block 容器里两个 inline-level 子元素会按各自的垂直对齐规则落位,主题切换按钮与
用户名牌就会错开几像素(用户反馈的"深色模式没对齐",其实两种主题下都偏,只是深色下更显眼)。
与 Web 管理端 AdminLayout 的同名容器保持同一套写法。 */
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.user-chip {
display: flex;
align-items: center;
@@ -441,7 +556,7 @@ onUnmounted(() => {
}
.theme-toggle {
margin-right: 2px;
/* 与用户名牌的间距由 .header-right 的 gap 负责,这里不再自带 margin */
color: var(--ch-text-secondary);
}