Files
ClassHelper/website/index.html
T
LoveChengke 817af02357 feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

726 lines
34 KiB
HTML
Raw Permalink 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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ClassHelper Class Helper — 知道现在是第几节课的班级信息系统</title>
<meta
name="description"
content="ClassHelper是一套自托管的班级信息管理系统:课表、作业、通知、成绩、叫人合在一个后端,教室大屏与每台学生机实时收到,上课时段自动不打扰课堂。支持 Windows 单机、Linux 服务器与 Docker 部署。"
/>
<meta name="theme-color" content="#16231e" />
<meta property="og:title" content="ClassHelper Class Helper" />
<meta
property="og:description"
content="课表、作业、通知、成绩、叫人合在一个后端,教室大屏与每台学生机实时收到,上课时段自动不打扰课堂。"
/>
<meta property="og:type" content="website" />
<link rel="icon" href="assets/icon.png" />
<link rel="apple-touch-icon" href="assets/icon.png" />
<!-- 标题与引文用的楷体(霞鹜文楷 Screen)。加载失败时自动回落到系统的楷体 / 衬线 -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.7.0/style.css"
media="print"
onload="this.media = 'all'"
/>
<link rel="stylesheet" href="assets/styles.css" />
<!-- 脚本在,先把标题藏起来准备逐字上浮;4 秒兜底,脚本坏在半路也不会把标题永久藏住 -->
<script>
document.documentElement.classList.add('reveal-pending');
setTimeout(function () {
document.documentElement.classList.remove('reveal-pending');
}, 4000);
</script>
</head>
<body>
<a class="skip" href="#main">跳到正文</a>
<header class="topbar">
<div class="wrap topbar-inner">
<a class="brand" href="#top" data-press="0.96">
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
<span class="brand-name">ClassHelper</span>
<span class="brand-ver">1.3.0</span>
</a>
<nav class="topnav" aria-label="页内导航">
<span class="nav-pill" aria-hidden="true"></span>
<a href="#journey">一条通知的旅程</a>
<a href="#policy">上课时段策略</a>
<a href="#parts">组成部分</a>
<a href="#shots">实机</a>
<a href="#deploy">部署</a>
<a href="#verify">验收</a>
<a href="docs/index.html">文档站</a>
</nav>
<a class="btn btn-quiet" href="download.html" data-press="0.95">下载</a>
</div>
<!-- 阅读进度:beUI ScrollProgressBar(2px,origin-left,弹簧缓跟) -->
<div class="scroll-progress" aria-hidden="true"></div>
</header>
<main id="main">
<!-- ═══════════════════════ 黑板:教室那台机器 ═══════════════════════ -->
<section class="band band-board hero" id="top">
<div class="wrap">
<div class="hero-intro">
<h1 class="display" data-reveal>
<span class="reveal-line">它知道现在是第几节课,</span>
<span class="reveal-line reveal-grad">不会在课上打扰学生</span>
</h1>
<p class="hero-sub intro-seq" style="--intro-delay: 150ms">课表、作业、通知、成绩、叫人</p>
<p class="lead intro-seq" style="--intro-delay: 200ms">
一个后端全管,老师在电脑或手机上发布,教室大屏和每台学生机同时收到;
断网时用本地缓存接着看,网络恢复自动补齐。
</p>
<div class="actions intro-seq" style="--intro-delay: 250ms">
<a class="btn btn-solid" href="download.html" data-press="0.93">立即下载</a>
<a
class="btn btn-line"
href="https://github.com/LoveChengke/ClassHelper"
target="_blank"
rel="noreferrer"
data-press="0.93"
>
GitHub 仓库
</a>
</div>
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
<li>v1.3.0</li>
<li>Windows 10/11</li>
<li>Linux · Docker</li>
<li>触摸屏可用</li>
</ul>
</div>
<div
class="screen"
role="img"
aria-label="教室机器上的界面示意:屏幕顶部中央浮着一个黑色灵动岛,正在展开老师发来的通知;下面是大时钟、正在上的数学课与下一节语文。"
>
<div class="screen-bar">
<span class="screen-dot" aria-hidden="true"></span>
<span class="screen-title">教室机器 · 高一(1)班</span>
<span class="screen-live">实时连接正常</span>
</div>
<div class="screen-stage" id="stage">
<div class="island-slot">
<div class="island" id="island" data-kind="pill">
<svg class="island-shape" id="island-shape" width="268" height="44" aria-hidden="true">
<defs>
<!-- 紧急/叫人卡片顶部那层呼吸光晕。真实产品用的是卡片内部的 inset 阴影,
这里用同一个 path 做一层渐变,效果一致且不会被窗口裁掉 -->
<radialGradient id="island-tint-urgent" cx="50%" cy="0" r="120%">
<stop offset="0" stop-color="#ff453a" stop-opacity="0.28" />
<stop offset="0.68" stop-color="#ff453a" stop-opacity="0" />
</radialGradient>
<radialGradient id="island-tint-call" cx="50%" cy="0" r="120%">
<stop offset="0" stop-color="#ff9f0a" stop-opacity="0.26" />
<stop offset="0.68" stop-color="#ff9f0a" stop-opacity="0" />
</radialGradient>
</defs>
<path class="island-base" id="island-path" d="" />
<path class="island-tint" id="island-tint" d="" />
</svg>
<div class="island-layers">
<div class="island-pill" id="island-pill">
<span class="pill-icon" id="pill-icon"></span>
<span class="pill-title" id="pill-title">新消息</span>
<span class="pill-sub" id="pill-sub">张老师 · 点击查看</span>
</div>
<!-- 卡片内容由 main.js 按 beUI DynamicIsland 的 Slot 机制挂进来 -->
</div>
</div>
</div>
<div class="screen-body">
<div class="clock">
<p class="clock-hm">
08:42
<span class="clock-s">07</span>
</p>
<p class="clock-week">第 6 教学周 · 单周 · 周二</p>
</div>
<div class="clock-now">
<p class="clock-current">
正在上
<b>数学</b>
</p>
<p class="clock-sub">08:00–08:45 · 教学楼 A301 · 距下课 3 分钟</p>
<div class="clock-bar"><i style="width: 88%"></i></div>
</div>
<div class="clock-next">
<p class="next-head">下一节</p>
<p class="next-name">
语文
<span>08:55–09:40</span>
</p>
<p class="next-sub">教学楼 A302 · 还有 13 分钟</p>
</div>
</div>
</div>
</div>
<p class="hero-cap">
上面这个灵动岛不是截图 —— 圆角路径与尺寸都是产品的(胶囊 268×44、展开 424×230、连续圆角 4.2
次方), 形变用的是 beUI 的 Dynamic Island 那套弹簧:壳 0.8 秒、回弹 0.2,内容层回弹 0.35。
</p>
<ul class="facts">
<li>装着 SQLite 就能跑,需要时一键换到 MySQL —— 数据也跟着搬过去。</li>
<li>课表由教室的 ClassIsland 上报,不用在小助手这边再排一遍。</li>
<li>教室把通知交给哪个端,由教室那台机器自己选:客户端、大屏,或者两边都弹。</li>
</ul>
</div>
</section>
<!-- ═══════════════════════ 下载:首屏下面直接给安装包 ═══════════════════════
与 /download.html 是同一段(同一套类名、同一个 assets/download.js)——
放这儿是为了让访客不跳页就能下;完整的下载页仍然保留(顶栏「下载」指着它)。 -->
<section class="band band-paper" id="download">
<div class="wrap">
<h2 class="h2">装什么,挑一下</h2>
<p class="lead narrow">
四个交付物互不依赖安装顺序,但
<strong>服务端要先有一台机器跑着</strong>
,另外三个才连得上。 下面的版本与文件清单是当场从本仓库的 GitHub Releases
读出来的:显示什么,就真能下到什么。
</p>
<!-- 版本选择:Tab 由 assets/download.js 按发布列表建出来 -->
<div class="dl-pick">
<p class="sr-only" id="ver-label">选择版本</p>
<div class="dl-seg" role="tablist" aria-labelledby="ver-label" id="ver-tabs">
<span class="dl-seg-indicator" id="ver-indicator" aria-hidden="true"></span>
</div>
<p class="dl-pick-note" id="ver-note" role="status">正在读取版本列表……</p>
</div>
<div class="dl-groups">
<p class="dl-pick-label" id="group-label">装什么</p>
<div class="dl-seg" role="tablist" aria-labelledby="group-label" id="group-tabs">
<span class="dl-seg-indicator" id="group-indicator" aria-hidden="true"></span>
<button class="dl-seg-item" type="button" role="tab" data-value="client">客户端</button>
<button class="dl-seg-item" type="button" role="tab" data-value="server">服务端</button>
<button class="dl-seg-item" type="button" role="tab" data-value="plugin">插件</button>
</div>
<p class="dl-pick-note" id="group-note" role="status"></p>
</div>
<!-- 卡片由 assets/download.js 按「当前版本 + 当前分组」渲染 -->
<div class="dl-grid" id="dl-grid" role="tabpanel" tabindex="0" aria-live="polite"></div>
<p class="dl-note" id="dl-note"></p>
</div>
</section>
<!-- 分幕标题:居中大标题 + 中间一段渐变高亮(照 ClassIsland 官网的分节写法) -->
<section class="band act" aria-label="第一部分:它是怎么工作的">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="act-title">它是<span class="act-hl">怎么工作</span>的</h2>
</div>
</section>
<section class="band band-board" id="journey">
<div class="wrap">
<h2 class="h2">从按下「发布」到教室屏幕上弹出来</h2>
<p class="lead narrow">
中间只隔着一条 WebSocket 广播。实测从老师提交到教室机器收到,是 30 到 50 毫秒 —— 这个数字由
<code>pnpm verify:e2e</code>
每次跑的时候重新量,不是写在文档里的估计值。
</p>
<ol class="pipeline">
<li>
<p class="pipe-step">老师发布</p>
<p class="pipe-body">Web 管理端或手机浏览器。通知、作业、叫人、成绩都在这里录。</p>
</li>
<li>
<p class="pipe-step">后端落库</p>
<p class="pipe-body">Express 5 + Prisma 7 写进库,再按班级房间广播一条事件。</p>
</li>
<li>
<p class="pipe-step">教室收到</p>
<p class="pipe-body">
Socket.IO 推到
<code>class:班级</code>
房间 —— 教室机器、学生机、大屏同时收到。
</p>
</li>
<li>
<p class="pipe-step">判一次时间</p>
<p class="pipe-body">课表服务回答「现在上不上课」,当场弹还是先收着,这一步就定下来了。</p>
</li>
<li>
<p class="pipe-step">两端弹出</p>
<p class="pipe-body">灵动岛展开成卡片,ClassIsland 同时全屏提醒,可以语音朗读。</p>
</li>
</ol>
</div>
</section>
<!-- ═══════════════════════ 上课时段策略 ═══════════════════════ -->
<section class="band band-board" id="policy">
<div class="wrap">
<h2 class="h2">上课的时候,通知会先排队</h2>
<p class="lead narrow">
判定来自课表本身,不是按时钟拍脑袋。把下面这条时间轴拨一下,看看同样的通知在课上和课间分别会怎样。
</p>
<div class="policy" id="policy-widget">
<div class="policy-clock">
<p class="policy-time" id="policy-time">09:20</p>
<p class="policy-state" id="policy-state">正在上 第三节 · 数学</p>
</div>
<div class="day">
<div class="day-track" id="day-track" aria-hidden="true"></div>
<div class="day-marker" id="day-marker" aria-hidden="true"></div>
<input
class="day-input"
id="day-input"
type="range"
min="420"
max="1030"
step="1"
value="560"
aria-label="拖动选择一天中的时刻,查看通知在两个端上的表现"
/>
<div class="day-scale" aria-hidden="true">
<span>07:00</span>
<span>10:00</span>
<span>13:00</span>
<span>16:00</span>
<span>17:10</span>
</div>
</div>
<div class="outcomes">
<div class="outcome">
<p class="outcome-what">普通通知</p>
<p class="outcome-where">学生机 · 灵动岛</p>
<p class="outcome-say" id="o-normal-client">先收着,打下课铃自动弹出来</p>
</div>
<div class="outcome">
<p class="outcome-what">普通通知</p>
<p class="outcome-where">教室大屏 · ClassIsland</p>
<p class="outcome-say" id="o-normal-screen">先收着,下课补弹</p>
</div>
<div class="outcome outcome-alert">
<p class="outcome-what">紧急通知 / 叫人</p>
<p class="outcome-where">教室大屏 · ClassIsland</p>
<p class="outcome-say" id="o-urgent-screen">立刻全屏弹出,不打断也拦不住</p>
</div>
<div class="outcome">
<p class="outcome-what">不管什么时候</p>
<p class="outcome-where">通知中心</p>
<p class="outcome-say">照常记一条,未读红点随时可查,不会被吞掉</p>
</div>
</div>
<p class="policy-note">
普通叫人(默认级别)上课时只进队列,下课后自动弹出详情;标成紧急的才当场打断课堂 ——
那是老师正在等学生。老师在 Web 端发紧急通知时会先撞上全屏二次确认,确认按钮带 3 秒倒计时,
防止手滑。这一层拦在服务端,绕过界面直接调接口也一样会被拒。
</p>
</div>
</div>
</section>
<!-- 分幕标题:居中大标题 + 中间一段渐变高亮(照 ClassIsland 官网的分节写法) -->
<section class="band act" aria-label="第二部分:它是怎么构成的">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="act-title">它是<span class="act-hl">怎么构成</span>的</h2>
</div>
</section>
<section class="band band-paper" id="parts">
<div class="wrap">
<h2 class="h2">一份代码,四个交付物</h2>
<p class="lead narrow">
一个 pnpm monorepo。三端共用同一份类型与权限矩阵,所以不会出现「Web 端说有、客户端说没有」。
</p>
<div class="table-scroll">
<table class="sheet">
<caption class="sr-only">四个交付物、它们的形态与使用者</caption>
<thead>
<tr>
<th scope="col">交付物</th>
<th scope="col">形态</th>
<th scope="col">谁在用</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">后端服务</th>
<td>
Express 5 + Prisma 7 + Socket.IO,接口前缀
<code>/api</code>
</td>
<td>其它三端</td>
</tr>
<tr>
<th scope="row">Web 管理端</th>
<td>Vue 3 + Element Plus,手机可用,可在浏览器里装成应用</td>
<td>老师、管理员</td>
</tr>
<tr>
<th scope="row">桌面客户端</th>
<td>Electron + Vue 3,带灵动岛与系统托盘,断网可用</td>
<td>教室机器、学生机</td>
</tr>
<tr>
<th scope="row">ClassIsland 联动插件</th>
<td>.NET 8 插件,装进教室已有的 ClassIsland 里</td>
<td>教室大屏</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ═══════════════════════ 实机截图 ═══════════════════════ -->
<section class="band band-board" id="shots">
<div class="wrap">
<h2 class="h2">每个界面都是跑起来截的</h2>
<p class="lead narrow">
下面没有一张是画的示意。这些图是脚本真的把后端、Web 管理端、教室客户端和灵动岛跑起来之后
一屏一屏截下来的 —— 数据也是库里真实的数据。
</p>
<div class="shots" id="shots-widget">
<div class="shots-tabs" role="tablist" aria-label="按端筛选截图">
<span class="shots-indicator" data-tabs-indicator aria-hidden="true"></span>
</div>
<div class="shots-grid" id="shots-grid" role="tabpanel" tabindex="0"></div>
</div>
<p class="shots-hint">点任意一张看大图。</p>
</div>
</section>
<!-- 大图浮层:从被点的那张缩略图原地展开(beUI 的 morphing lightbox 用共享 layoutId,这里用 FLIP 等价实现) -->
<div class="lightbox" id="lightbox" hidden>
<div class="lightbox-backdrop" id="lightbox-backdrop"></div>
<div class="lightbox-panel" id="lightbox-panel">
<img class="lightbox-img" id="lightbox-img" alt="" />
</div>
<p class="lightbox-cap" id="lightbox-cap"></p>
<button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭大图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<!-- ═══════════════════════ 纸张:功能 ═══════════════════════ -->
<section class="band band-paper" id="features">
<div class="wrap">
<h2 class="h2">老师那边,教室这边</h2>
<div class="feature-groups">
<div class="feature-group">
<h3 class="h3">老师与管理员</h3>
<div class="feature-blocks">
<div class="feature-block">
<p class="feature-tag">#班级人员</p>
<h4 class="feature-title">建班、换班主任、录学生</h4>
<p class="feature-body">
名单可以直接导入表格。教师录入与重置密码只对管理员开放,班主任也不行。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#课表</p>
<h4 class="feature-title">按周排课,支持单双周</h4>
<p class="feature-body">
也可以直接导入 ClassIsland 的档案 JSON —— 节次时间跟着课表一起进来,不用排两遍。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#作业</p>
<h4 class="feature-title">按「所属日期」归类</h4>
<p class="feature-body">
晚上八点留的作业算今天。教室机器也能录;未交名单勾一下,其余同学自动标成已完成。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#通知与叫人</p>
<h4 class="feature-title">分优先级发布,点名分两档</h4>
<p class="feature-body">
普通叫人上课时先收着,紧急的才当场打断课堂 —— 那是老师正在等学生。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#成绩</p>
<h4 class="feature-title">单条、批量,或者整张表导进来</h4>
<p class="feature-body">
重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号,改完再导。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#数据库</p>
<h4 class="feature-title">备份、导入导出、一键换库</h4>
<p class="feature-body">
SQLite 与 MySQL 互相搬数据,中途失败会自动回滚 —— 现有的库不会被动到。
</p>
</div>
</div>
</div>
<div class="feature-group">
<h3 class="h3">教室机器与学生机</h3>
<div class="feature-blocks">
<div class="feature-block">
<p class="feature-tag">#课表时间轴</p>
<h4 class="feature-title">大时钟、正在上的课、距下课倒计时</h4>
<p class="feature-body">已结束的置灰,正在上的带进度条,下一节还有多久开始 —— 一眼看完。</p>
</div>
<div class="feature-block">
<p class="feature-tag">#作业看板</p>
<h4 class="feature-title">按科目分卡,整屏缩放到刚好放下</h4>
<p class="feature-body">
标题和要求全文不截断,也不用上下翻页;全屏看板会自己算最合适的列数。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#灵动岛</p>
<h4 class="feature-title">消息到达的第一现场</h4>
<p class="feature-body">
无边框透明置顶的小窗,通知、作业、叫人在里面展开成卡片;鼠标穿透,不占任务栏。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#离线缓存</p>
<h4 class="feature-title">断网的时候接着看</h4>
<p class="feature-body">
课表、作业、通知、成绩先落本地,断网顶部提示离线、照旧显示,网络恢复自己刷新。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#外观</p>
<h4 class="feature-title">深浅两套,岛也能整</h4>
<p class="feature-body">
高度、宽度、圆角、字号、停靠位置、视觉风格都能调;越界的值在主进程统一夹紧。
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 分幕标题:居中大标题 + 中间一段渐变高亮(照 ClassIsland 官网的分节写法) -->
<section class="band act" aria-label="第三部分:它是怎么到你手里的">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="act-title">它是<span class="act-hl">怎么到你手里</span>的</h2>
</div>
</section>
<section class="band band-paper" id="deploy">
<div class="wrap">
<h2 class="h2">四种装法,按场景挑</h2>
<div class="table-scroll">
<table class="sheet">
<caption class="sr-only">四种部署形态与各自入口</caption>
<thead>
<tr>
<th scope="col">场景</th>
<th scope="col">形态</th>
<th scope="col">入口</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">教师电脑、学校机房单机</th>
<td>Windows 服务端安装程序,内置 Node 运行时</td>
<td>双击安装,自动建库建号、开机自启</td>
</tr>
<tr>
<th scope="row">云服务器、多终端共享</th>
<td>Docker Compose,MySQL 与 SQLite 两种编排都在仓库里</td>
<td><code>deploy/docker-compose.yml</code></td>
</tr>
<tr>
<th scope="row">已有 Linux 服务器</th>
<td>
一行命令装成 systemd 服务,附带
<code>classhelper</code>
运维命令
</td>
<td>见下面的命令</td>
</tr>
<tr>
<th scope="row">已有 Node 环境</th>
<td>免安装目录,自带 node.exe</td>
<td>
<code>pnpm dist:server</code>
的产物
</td>
</tr>
</tbody>
</table>
</div>
<div class="snippet">
<div class="snippet-head">
<span class="snippet-label">Linux 服务器上跑这一条</span>
<button
class="copy"
type="button"
data-press="0.97"
data-copy="#cmd-linux"
data-copy-done="已复制"
>
<span class="copy-label">复制</span>
</button>
</div>
<pre
id="cmd-linux"
><code>curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh &amp;&amp; sudo bash /tmp/classhelper-install.sh</code></pre>
</div>
<p class="lead narrow">
脚本自己从 GitHub Release 取最新版、核对发布页上的
<code>.sha256</code>
,再装成 systemd 服务。 想先不改动系统,把末尾换成
<code>--check</code>
就只体检。装完之后,
<code>classhelper upgrade</code>
、
<code>backup</code>
、
<code>doctor</code>
、
<code>password</code>
这些命令都在
<code>/usr/local/bin</code>
里等着。
</p>
<p class="lead narrow">
学生机这边是安装包和单文件版两种:安装包走 NSIS,单文件版不用装、放 U 盘里就能分发。
两个都不写死服务器地址 —— 第一次打开时填教室的地址和班级码就行。
</p>
<div class="actions">
<a class="btn btn-line" href="download.html" data-press="0.93">去下载页挑一份</a>
</div>
</div>
</section>
<!-- ═══════════════════════ 验收 ═══════════════════════ -->
<section class="band band-board" id="verify">
<div class="wrap">
<h2 class="h2">每个功能都配了一条能复跑的用例</h2>
<p class="lead narrow">
仓库里带着这几套脚本,跑到哪儿、过了几条由脚本自己数,不是文档里写死的数字。 交付前它们必须全绿 ——
包括对
<strong>已经打包装好</strong>
的那份客户端再跑一遍。
</p>
<div class="ledger">
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:e2e</code>
<span class="ledger-what">
接口、实时推送、权限越权、上课时段、表格导入、班级账号、数据库管理、更新检查
</span>
<span class="ledger-num"><span data-ticker="190">190</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:desktop</code>
<span class="ledger-what">客户端冒烟:登录、离线回退、灵动岛开合与命中、设置页真实点击</span>
<span class="ledger-num"><span data-ticker="105">105</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:packaged</code>
<span class="ledger-what">对打包后 / 已安装的那份 EXE 复跑同一套冒烟</span>
<span class="ledger-num"><span data-ticker="105">105</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:classisland</code>
<span class="ledger-what">设备令牌、课表上报、提醒下发与回执、镜像契约、幽灵行清理</span>
<span class="ledger-num"><span data-ticker="42">42</span></span>
</div>
<div class="ledger-row">
<code class="ledger-cmd">pnpm verify:classisland-plugin</code>
<span class="ledger-what">插件清单、注册完整性、C# DTO 与服务端校验逐字段对齐</span>
<span class="ledger-num"><span data-ticker="68">68</span></span>
</div>
</div>
<p class="ledger-foot">
上面是 2026-10-05 那一次的结果。这几套脚本每天都在跑,数字会变;跑到多少条,以脚本自己打印的为准。
</p>
</div>
</section>
<!-- 收尾 CTA:和 ClassIsland 官网一样,末尾放一整块「下一步做什么」 -->
<section class="band cta" aria-label="开始使用">
<div class="wrap">
<!-- prettier-ignore -->
<h2 class="cta-title">剩下的,<span class="act-hl">留给你自己试</span></h2>
<p class="cta-lead">
一台机器装完就能跑起来,学生机和教室大屏接上同一个地址就行。装好之后还有什么, 都在界面里 ——
不用先读一遍文档。
</p>
<div class="cta-actions">
<a class="btn btn-solid" href="download.html" data-press="0.93">取安装包</a>
<a
class="btn btn-line"
href="https://github.com/LoveChengke/ClassHelper"
target="_blank"
rel="noreferrer"
data-press="0.93"
>
看源码
</a>
</div>
</div>
</section>
</main>
<!-- ═══════════════════════ 页脚 ═══════════════════════ -->
<footer class="foot">
<div class="wrap foot-inner">
<div class="foot-brand">
<img src="assets/icon.png" alt="" width="36" height="36" />
<div>
<p class="foot-name">
ClassHelper
<span>Class Helper</span>
</p>
<p class="foot-ver">版本 1.3.0 · 源码公开 · 装在你自己的机器上</p>
</div>
</div>
<nav class="foot-nav" aria-label="相关链接">
<a href="https://github.com/LoveChengke/ClassHelper" target="_blank" rel="noreferrer">源码仓库</a>
<a href="docs/index.html">文档站</a>
<a href="docs/management/production.html">生产部署</a>
<a href="docs/management/linux-deploy.html">Linux 运维</a>
<a href="docs/management/mysql.html">切换 MySQL</a>
<a href="https://classisland.tech" target="_blank" rel="noreferrer">ClassIsland</a>
</nav>
</div>
</footer>
<script src="assets/motion.js" defer></script>
<script src="assets/main.js" defer></script>
<script src="assets/download.js" defer></script>
</body>
</html>