diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..157dcef --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,85 @@ +name: 发布文档站 + +# 把 docs/ 用 docfx 构建成静态站点,部署到 GitHub Pages。 +# +# 为什么单独一个 workflow: +# docfx 是 .NET 工具(`dotnet tool install -g docfx`),需要在 CI 里装 .NET SDK; +# 而文档站与三个交付物的发布节奏也完全不一样 —— 改一个错别字就该上线, +# 不该为此打个安装包、发个 Release。 +# +# 站点地址:https://lovechengke.github.io/ClassHelper/ +# 需要在仓库 Settings → Pages 里把 Source 选成「GitHub Actions」(只需做一次)。 +# +# 本地预览:pnpm docs:serve + +on: + push: + branches: [master] + paths: + # 文档内容与构建脚本 + - 'docs/**' + - '.github/workflows/docs.yml' + # 顶栏那个版本徽标取自根 package.json(docs/build.mjs 通过 --metadata 注入), + # 所以版本号变了也要重新发一次文档站 + - 'package.json' + workflow_dispatch: + +# Pages 部署需要的三件套:读代码、写 Pages、用 OIDC 拿部署令牌 +permissions: + contents: read + pages: write + id-token: write + +# 同一时间只跑一个部署;已经在跑的那次不取消(半途取消会留下一个坏掉的站点) +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 .NET SDK 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: 安装 docfx + # docfx 是 .NET 全局工具,装完在 ~/.dotnet/tools 下;setup-dotnet 会把它加进 PATH, + # 这里再显式补一次,免得换 runner 镜像后找不到命令。 + run: | + dotnet tool install --global docfx + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + + - name: 构建文档站 + # --check 把 docfx 的警告当错误:最常见的警告是"某个 .md 没被任何 toc.yml 引用", + # 那种页面会被构建出来但没有侧边栏 —— 属于必须修掉的疏漏,不该悄悄上线。 + # + # DOCS_BASE 是给 404 页用的:项目页挂在 /<仓库名>/ 子路径下,而浏览器按**被请求的路径** + # 解析 404 页里的相对链接,所以要把站点根钉成 。这里从仓库名算,改仓库名不用改脚本。 + env: + DOCS_BASE: /${{ github.event.repository.name }}/ + run: node docs/build.mjs --check + + - name: 配置 Pages + uses: actions/configure-pages@v5 + + - name: 上传构建产物 + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: 部署到 GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 78089ab..1185f16 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ releases/ .zcodeignore packages/server/prisma/backups/ packages/server/prisma/database-settings.json + +# 文档站构建产物(pnpm docs:build 生成,由 GitHub Pages 发布;见 docs/) +docs/_site/ diff --git a/.prettierignore b/.prettierignore index c5b00eb..802ea64 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ pnpm-lock.yaml .cache .pnpm-store .probe +docs/_site diff --git a/AGENTS.md b/AGENTS.md index d5590db..f930fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -354,7 +354,7 @@ gh workflow run release-linux-server.yml -f tag=v<版本> -f attach=true **不用做任何事。** 推 master 时只要 `docs/**`、`package.json` 或 `.github/workflows/docs.yml` 变过,`.github/workflows/docs.yml` 就会用 docfx 构建并部署到 GitHub Pages -(`https://lovechengke.github.io/classhelper/`)。 +(`https://lovechengke.github.io/ClassHelper/`)。 - 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入), **不要在文档里再写一份版本号**; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0a1690e..6e9ac52 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -45,7 +45,7 @@ services: environment: DATABASE_PROVIDER: mysql DATABASE_URL: mysql://${MYSQL_USER:-classhelper}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-classhelper} - # MySQL 需要 mariadb 驱动适配器(切换数据库时按 docs/mysql.md 安装) + # MySQL 需要 mariadb 驱动适配器(切换数据库时按 docs/management/mysql.md 安装) NODE_ENV: production command: > sh -c "pnpm --filter @classhelper/server add @prisma/adapter-mariadb && diff --git a/deploy/install.sh b/deploy/install.sh index a66b81e..ed4fa3b 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -182,7 +182,7 @@ usage() { 班级小助手 · 服务端 Linux 安装器 (本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数; - 完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/linux-deploy.md ) + 完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/management/linux-deploy.md ) --mode native|docker 安装形态(默认 native:systemd + 内置 Node) --dir /opt/classhelper 安装目录 --port 4000 服务端口 @@ -844,7 +844,7 @@ EOF link_env_file() { # Web 端「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是 config.env; - # 用软链把两者收成一份真身,否则切换看起来成功、重启后又连回旧库(详见 docs/linux-deploy.md) + # 用软链把两者收成一份真身,否则切换看起来成功、重启后又连回旧库(详见 docs/management/linux-deploy.md) local link="${INSTALL_DIR}/.env" if [ -e "$link" ] && [ ! -L "$link" ]; then warn "安装目录里存在真实文件 .env,已改名保留为 .env.bak.$(date +%s) 再建立软链" diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 0000000..5395b7c --- /dev/null +++ b/docs/404.html @@ -0,0 +1,147 @@ + + + + + + + + + 页面不存在 | 班级小助手文档 + + + + +
+
404
+

这里没有这一页

+

链接可能过期了,或者这个页面已经被重写进了别的章节。从下面这两处开始找会快一些。

+ +
+ + diff --git a/docs/app/appearance.md b/docs/app/appearance.md new file mode 100644 index 0000000..61cb89c --- /dev/null +++ b/docs/app/appearance.md @@ -0,0 +1,97 @@ +--- +title: 外观、主题与动效 +description: 深浅色主题、界面风格、设计令牌与动效语言,以及降低动态效果的降级行为。 +--- + +# 外观、主题与动效 + +## 深浅色主题 + +**两端都有深色主题,机制完全一致**:在 `` 上挂 `.dark` 类,由各自的样式表覆盖**语义色令牌**, +Element Plus 的深色变量提供组件层的默认值。两端的 `main.ts` 都在挂载之前就把主题落好, +所以深色用户打开时不会"先白后黑"闪一下。 + +| 行为 | 说明 | +| ------------ | -------------------------------------------------------- | +| 默认 | **跟随系统**(`prefers-color-scheme`,系统改了就跟着变) | +| 手动切换之后 | 以用户的选择为准(系统的偏好是默认值,不是命令) | +| 切换入口 | 客户端在顶栏(日月按钮),Web 管理端也在顶栏 | +| 切换效果 | 从点击位置**扩散的圆**铺满整页换肤 | + +**刻意不随主题的区域**:登录页的深色渐变底、桌面端的启动过渡页与全屏作业看板、灵动岛自身、 +Web 管理端的侧边栏(本来就是深色),以及深色底上的白色文字。 + +Web 端还会同步 ``,并设 `color-scheme` 让**原生控件** +(滚动条、日期选择器弹层、自动填充底色)也跟着走 —— 只改 CSS 变量的话它们会仍是浅色。 + +## 语义色令牌 + +组件里**一律引用令牌,不写死颜色**: + +| 令牌 | 用途 | +| ------------------------------------------------------ | -------- | +| `--ch-surface` / `-alt` / `-sunken` / `-hover` | 各种表面 | +| `--ch-text` / `-secondary` / `-muted` / `-placeholder` | 文字层级 | +| `--ch-radius-xl / lg / md / sm / pill` | 圆角 | +| `--ch-shadow-sm` / `-md` | 投影 | + +`html.dark` **只覆盖颜色,绝不覆盖圆角与动效令牌** —— 两套主题的几何必须完全一致, +否则切换时会看到布局跳一下。 + +> 改令牌时**只改值**,不要对样式表跑批量替换。"把 `#303133` 全量替换成 `var(--ch-text)`" +> 会把令牌**定义本身**也换掉,自定义属性一旦自引用就整条失效、解析成空串, +> 全站文字颜色静默回落成继承色。 + +## 界面风格 + +- 浅色底 `#f3f3f3` + 半透明卡片(`rgba(255,255,255,.7)` + `backdrop-filter: blur(20px)`); +- 强调色 `#0F6CBD`(悬停 `#115EA3`);选中的导航项 = 强调色淡底 + 左侧 3px 强调条; +- 圆角统一:卡片 8px / 控件 4px;描边用半透明黑代替灰线;阴影极轻; +- 字体 `Segoe UI Variable / 微软雅黑 UI`;Element Plus 的变量整体对齐这套规范。 + +Web 管理端走的是另一套(1Panel 风格:深色侧边栏 + 白色圆角卡片), +圆角与投影集中定义在它的 `:root` 令牌里。 + +## 动效语言 + +两端的动画与过渡统一走一套自研的动效语言,**移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`**。 +beUI 的组件本身是 React,用不到这里,但它的动效语言与框架无关。 + +- **令牌单一来源**:`packages/shared/src/motion.ts`。三组缓动曲线 + 六组具名弹簧; +- **弹簧由数值积分算出落定时间**,再生成 CSS `linear()` 缓动字面量 —— + 于是弹簧可以纯 CSS 驱动,不必给每个组件挂一个 rAF 循环; +- **运行时注入**:两端在挂载前把令牌写进 `:root` 的自定义属性。CSS 里只引用变量,不抄数值。 + +落地到界面上的效果: + +| 位置 | 动效 | +| --------------------- | ------------------------------------------------------------------ | +| Element Plus 全站过渡 | 时长收紧到 200 / 140ms,缓动换成 EASE_OUT | +| 弹窗 / 抽屉 / 浮层 | 走弹簧;**退出一律比入场快**(用户已经决定关掉它了) | +| 按钮 | 按下 90ms 直落(跟手)、抬起弹回 | +| 侧栏激活项 | 单个"底片"在菜单项之间滑过去,取代"每项各画一块底色" | +| 页面内容 | 按块错峰浮现(30ms 一档,封顶 180ms) | +| 统计数字 | 0 → 实际值滚动 | +| 灵动岛 | **刻意只有单调缓动,不用弹簧**(任何过冲都会让"开合"看起来在震动) | + +### 降低动态效果 + +两端各有一个 `prefers-reduced-motion` 的全局降级块,把动画压到 1ms —— +**是压短而不是删掉**(`transition: none` 会让依赖 `transitionend` 的逻辑失灵)。 + +唯一的例外是**加载指示旋**:它是放慢而不是停,停掉会让界面看起来像卡死。 + +## 客户端设置页 + +「设置」是一个可展开的分组,六个子页各有自己的路由: + +| 子页 | 内容 | +| ------ | ----------------------------------------------------------------------------- | +| 通用 | 服务器连接、离线缓存、作业录入短语、使用引导 | +| 外观 | 浅色 / 深色 | +| 灵动岛 | 尺寸、字号、主题色、视觉风格、停靠、边距、空闲细缝(详见[灵动岛](island.md)) | +| 提醒 | 通知显示在哪一端 | +| 账号 | 当前班级信息、退出登录 | +| 关于 | 版本 / Electron / Chromium / Node、诊断信息、检查更新、鸣谢 | + +`/settings` 会自动跳到「通用」。设置页的改动会**立刻生效并写进本机配置**,重启后仍保留。 diff --git a/docs/app/calls.md b/docs/app/calls.md new file mode 100644 index 0000000..57daaf6 --- /dev/null +++ b/docs/app/calls.md @@ -0,0 +1,57 @@ +--- +title: 叫人 +description: 老师点名让学生过来:普通与紧急两档、快捷短语、谁能叫谁,以及它在教室里的表现。 +--- + +# 叫人 + +「叫人」是老师点名让某位同学来一趟 —— 它**不是**一条普通通知,因为它有一个明确的收件人和一个明确的动作 +(学生会点「收到」)。 + +## 在哪发 + +| 入口 | 谁能用 | +| --------------- | ------------ | +| 通知发布 → 叫人 | 教师、管理员 | +| 学生管理 → 叫人 | 管理员 | + +学生管理页是管理员专属,但班主任与科任老师同样需要叫人,所以给老师留的入口在「通知发布 → 叫人」。 + +## 两档 + +| 级别 | 落库级别 | 学生端行为 | +| ------------ | -------- | ----------------------------------------------------------------------------------------------------- | +| **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) | +| **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 | + +两档的卡片都是「请 XXX 同学找 XXX 老师」+ 具体事由,徽标是「叫人」,按钮是「收到」 +(复用通知的已读机制)。 + +> **是否立即展开只由 `priority` 决定**,与普通通知共用同一条排队 / 隐藏 / 下课弹出路径。 + +## 怎么说 + +两种输入方式,**自定义消息优先于快捷短语**: + +- **快捷短语**(8 条,点击选中、再点取消):请到办公室找我 / 请到讲台找我 / 请到实验室找我 / + 请到门卫处找我 / 请带上作业本找我 / 请带上试卷找我 / 请到教室门口等我 / 请马上来一趟; +- **自定义消息**:最长 200 字,例如「带上昨天的数学作业到办公室」。 + +## 谁能叫谁 + +| 角色 | 限制 | +| ------ | ---------------------------------------------------------- | +| 学生 | 调用被拒(403) | +| 教师 | 只能叫**自己班级**的学生;跨班、或学生不在本班 → 400 / 403 | +| 管理员 | 任意班级 | + +## 服务端做了什么 + +`POST /api/calls`(教师 / 管理员)→ `urgent: true` 落库成 `URGENT`,否则 `HIGH`, +然后广播两个事件: + +- `notification:new` 到班级房间(通知中心与灵动岛都靠它); +- `call:new` 定向到被叫学生 / 班级设备。 + +如果教室的「通知显示位置」不是"只在 ClassHelper",这条叫人也会推给 ClassIsland, +教室大屏一并播报。 diff --git a/docs/app/classes.md b/docs/app/classes.md new file mode 100644 index 0000000..d9c964a --- /dev/null +++ b/docs/app/classes.md @@ -0,0 +1,98 @@ +--- +title: 班级、学生与教师 +description: 班级账号、学生名单、班主任与协作教师,以及"谁能看什么、谁能改什么"。 +--- + +# 班级、学生与教师 + +## 班级账号:学生端的身份 + +学生**没有个人账号**。一台教室机器用一个**班级账号**登录: + +``` +班级码(例如 G203) + 班级密码 → 这台机器代表"高二(3)班" +``` + +登录之后,这台机器上的「已读 / 完成 / 未交」都是**代全班**操作的;它看到的是本班的数据。 + +班级码是管理员在「班级管理」里设的(新建时自动生成,可以改成好记的)。 +校验时会统一按大写比对(码存大写)。 + +> 班级密码在新建班级时由 `.env` 的 `DEFAULT_CLASS_PASSWORD` 决定初始值, +> 之后由管理员在「班级管理 → 修改班级账号」维护。 +> +> **客户端没有改密码入口**,这是刻意移除的:教室机器上误改班级密码会让别的机器登录不了。 +> 教师 / 管理员本人改密码在 Web 端顶栏头像菜单里。 + +## 学生 + +**学生管理**(仅管理员)→ 新建 / 导入名单。 + +学生记录**只是名单**:成绩、未交名单、叫人、已读记录都按名单记录,所以名单不全这几项就不准。 + +| 项目 | 说明 | +| -------- | ------------------------------------------------------------------------------------ | +| 用户名 | `^[A-Za-z0-9_.-]{3,32}$`;单个新建时自动生成 | +| 密码 | **没有密码概念**。学生的 `passwordHash` 是空串占位,不能登录,也没有任何重置密码接口 | +| 所属班级 | 一个学生属于一个主班级 | + +> 用学生用户名去登录会被拒。就算拿教师 / 管理员账号去试,服务端也会在**密码校验之前**先判角色 —— +> 否则"密码错(401)"和"密码对但学生账号已停用(403)"的差异会把学生账号密码变成可离线验证的预言机。 + +## 教师 + +| 能力 | 在哪 | 谁能做 | +| -------------------- | ------------------------------------------------ | ------------- | +| 新建 / 编辑 / 删除 | 「教师管理」 | **仅管理员** | +| 导入名单(xlsx/csv) | 「教师管理 → 导入名单」 | **仅管理员** | +| 重置密码 | 「教师管理」(可指定新密码,留空用默认初始密码) | **仅管理员** | +| 加进某个班 | 班级详情 → 协作教师 | 管理员 / 教师 | +| 换班主任 | 班级详情顶部「更换班主任」 | **仅管理员** | + +> **教师录入只有管理员能做**:`/api/teachers` 整组接口都是 `requireRole('ADMIN')`,教师调用返回 403。 +> 前端也只在管理员登录时显示这个菜单项。 + +### 删除教师有护栏 + +下面四种情况一律拒绝(409),因为删账号会级联删掉他在各班发过的内容: + +1. 他还是某个班的**班主任**; +2. 名下有**课程**; +3. 有**班级分配**(协作教师); +4. **发布过作业或通知**。 + +(后两项曾经漏检,会静默删掉该教师发过的作业与通知。) + +## 角色与可见范围 + +班级内的角色由「账号角色 + 与该班级的关系」共同决定: + +| 角色 | 怎么判定 | 可见范围 | +| ------------------ | ------------------------------ | -------------------------- | +| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | +| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | +| 科任老师 `SUBJECT` | `ClassTeacher` 里有当前用户 | 被分配的班级 | +| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) | + +### 权限矩阵(摘要) + +| 操作 | 管理员 | 班主任 | 科任老师 | +| ------------------------------------ | :----: | :----------: | :------: | +| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | +| 分配班主任与科任老师 | ✅ | ❌ | ❌ | +| 学生名单管理(增删 / 导入) | ✅ | ❌ | ❌ | +| 教师录入(新建 / 导入 / 改密码) | ✅ | ❌ | ❌ | +| 数据库管理(备份 / 导入导出 / 切换) | ✅ | ❌ | ❌ | +| 课表管理(增删改 / 导入) | ✅ | ✅(仅本班) | ❌ | +| 布置作业 | ✅ | ✅ | ✅ | +| 叫人 | ✅ | ✅ | ✅ | +| 发布通知 | ✅ | ✅ | ✅ | +| 成绩录入 / 修改 / 导入(仅本班) | ✅ | ✅(仅本班) | ❌ | +| 维护班级密码 | ✅ | ❌ | ❌ | + +完整矩阵与实现见[角色与权限](../dev/permissions.md)。 + +## 学期周数 + +每个班有自己的学期周数(`Class.termWeeks`,默认 20,上限 30),**只有管理员**能改。 +它决定"现在是第几教学周",见[课表](schedules.md)。 diff --git a/docs/app/grades.md b/docs/app/grades.md new file mode 100644 index 0000000..bbc4c7a --- /dev/null +++ b/docs/app/grades.md @@ -0,0 +1,56 @@ +--- +title: 成绩 +description: 成绩录入与导入、等级与得分率、学生端看到的全班总览。 +--- + +# 成绩 + +## 三种录入方式 + +Web 管理端「成绩录入」页: + +| 方式 | 适合 | 说明 | +| -------- | --------------------------------- | ---------------------------------------------------------------- | +| 单条录入 | 个别缺漏补录 | 选学生、科目、考试名称、分数、总分 | +| 批量录入 | 一次录一个班的某一科 | 一屏列出全班学生,逐个填分 | +| 导入表格 | 已经有 Excel 的(期中期末最常见) | 支持列名同义词映射、先预览再提交,见[表格与课表导入](imports.md) | + +**科目**下拉是全校统一的 18 科目录;选中一个该班还没有的科目时,前端会自动为该班建同名课程, +不用先去把科目建一遍。 + +## 谁能录 + +| 角色 | 权限 | +| --------------- | -------------------------------------- | +| 管理员 | ✅ 全部班级 | +| **本班班主任** | ✅ 仅本班(录入 / 修改 / 删除 / 导入) | +| 科任老师 | ❌ 403 | +| 学生 / 班级账号 | ❌ 只能看 | + +权限在**服务层**兜底,前端只是按同一份矩阵隐藏按钮。 + +## 学生看到的是什么 + +学生端(教室机器)用的是**班级账号**,所以它看到的不是"我的成绩",而是**全班成绩总览**: + +- 表格:分数 / **得分率** / **等级**; +- 柱状图; +- 等级分布; +- 最近更新。 + +图表是自研的轻量组件(柱状图是纯 HTML/CSS,折线图是 SVG),项目里**不引图表库**。 + +> 学生没有个人账号,所以系统里不存在"某个学生登录后看自己的成绩"这条路径。 +> 成绩记录本身是按学生名单存的(`Grade.userId`),只是入口统一在班级账号上。 + +## 接口 + +| 方法 | 路径 | 权限 | +| -------------- | ---------------------------------------------- | --------------------------- | +| GET | `/grades/my` | 登录(班级会话 → 全班总览) | +| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | +| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | +| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | +| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | + +写入后会广播 `grade:updated`,客户端立刻刷新。 diff --git a/docs/app/homeworks.md b/docs/app/homeworks.md new file mode 100644 index 0000000..7a8ad1f --- /dev/null +++ b/docs/app/homeworks.md @@ -0,0 +1,92 @@ +--- +title: 作业 +description: 作业按所属日期归类、看板与全屏看板、教室机器录入作业、未交名单与附件。 +--- + +# 作业 + +## 按「所属日期」归类,不是按录入时间 + +每份作业有一个**所属日期**(`Homework.assignDate`,形如 `2026-10-06` 的本地日期,不带时区)。 +按天查看、日期高亮、客户端的「只看今天」都看它。 + +> 用录入时间(`createdAt`)当"哪一天的作业"是错的:它是 UTC,晚上 8 点留的作业会被算到第二天。 +> 系统里统一用「本地日期」,新增按天查询的接口也一样走 `?date=YYYY-MM-DD`。 + +所以老师可以给今天、明天、甚至补录上周某一天 —— 客户端按日期查得到。 + +## 两端各自能做什么 + +| 能力 | Web 管理端(老师) | 桌面客户端(教室机器) | +| ------------------ | ---------------------------------------- | ----------------------------------------------- | +| 按天查看 | 工具栏日期选择器;有作业的日期下面带圆点 | 日期选择器 + 前 / 后一天,支持 `?date=` 深链 | +| 发布 / 编辑 / 删除 | ✅(教师 / 管理员) | ✅ **录入**(班级账号也能发,归属到该班班主任) | +| 看板模式 | — | ✅ 默认视图 | +| 全屏看板 | — | ✅ | +| 完成情况 | ✅ 按名单统计完成人数 | ❌ **客户端不显示"已完成 / 待完成"** | +| 未交名单 | ✅ 作业详情抽屉里可看可改 | ✅ | + +> 客户端刻意不显示完成状态:完成与否是**老师端与通知中心**的概念; +> 教室机器上只做"看作业 + 录作业"两件事。服务端的完成数据与统计接口都还在,Web 端照旧可用。 + +## 看板模式 + +客户端作业页默认按**科目**分卡片,每张卡列出该科的所有作业。工具栏可以: + +- **看板 / 列表** 切换视图(列表模式有一列"作业要求"全文); +- **只看今天** 开关(默认开); +- **显示时间** 开关; +- **字号**滑块(11–28px 即时生效)。 + +这些偏好存在客户端配置里,重启后仍生效。 + +卡片里同时渲染**标题 + 作业要求全文**(不截断)。 + +### 全屏看板 + +点卡片或「全屏看板」进入。全屏下的排版不是简单地放大: + +1. 按"可用宽 × 可用高"枚举 1..N 列,取**单卡面积最大、宽高比最接近 1.15** 的方案; +2. 再按「内容高度 / 可用高度」整体 `transform: scale()` 缩到刚好铺满 —— 所以**不用上下翻页**; +3. 标题栏中间绝对居中显示当天日期时间(受「显示时间」开关控制,每分钟刷新)。 + +> 字号仍由工具栏的滑块决定,缩放只负责"放得下"。工具栏上会显示当前缩放比例。 + +## 教室机器录入作业 + +教室里那台机器用**班级账号**登录,可以直接发作业。两个细节: + +- 服务端拿到的是班级账号的会话(`sub` 是班级 id),写 `createdBy` 前会换成该班班主任, + 否则会撞外键。 +- **本机刚录入的那条不会弹到灵动岛上**——自己通知自己没意义。这条判断是在发请求**之前** + 按内容登记指纹做的(服务端是先广播后回响应,等拿到 id 再登记就晚了)。 + +录入时可以点**快捷短语**(默认 `P` / `大本` / `小本` / `卷子` / `背诵` / `默写` / `听写` / `预习` / +`订正` / `读书`),点一下追加到标题或内容。短语可以在客户端「设置 → 通用」里增删,存在本机。 + +## 未交名单 + +在作业列表或详情里点「未交名单」→ 勾选**没交作业**的同学 → 保存。 + +其余同学会被自动标记为已完成(接口是 `PATCH /api/homeworks/:id/submissions`)。 +Web 端作业详情抽屉里同样可看可改。 + +> 名单来自班级的学生名单 —— 名单不全,这项就不准。见[班级、学生与教师](classes.md)。 + +## 附件 + +作业可以带一个附件链接。**只接受 `http(s)://` 或站内相对路径**,其他 scheme(`javascript:`、 +`data:text/html` 等)服务端会直接拒;Web 端渲染前还会再兜一层。 +放行这些 scheme 等于给了一条存储型 XSS。 + +## 实时推送 + +发布 / 修改 / 删除作业会分别广播: + +| 事件 | 客户端反应 | +| ------------------ | ------------------------------------- | +| `homework:new` | 作业列表刷新 + 灵动岛弹「新作业」胶囊 | +| `homework:updated` | 刷新;带 `deleted: true` 表示删除 | +| `homework:status` | 完成状态变化时刷新统计 | + +课表、作业、通知、成绩都是同一个套路:写库 → 按班级房间广播 → 客户端更新内存状态并写回 IndexedDB。 diff --git a/docs/app/imports.md b/docs/app/imports.md new file mode 100644 index 0000000..9eab8cc --- /dev/null +++ b/docs/app/imports.md @@ -0,0 +1,115 @@ +--- +title: 表格与课表导入 +description: 成绩、学生名单、教师名单的表格导入,以及从 ClassIsland 档案导入节次时间与课表。 +--- + +# 表格与课表导入 + +五条导入链路**都在服务端完成解析、校验与去重**,前端只负责收集文件与展示结果。 +所以 Web 端、客户端与直接调接口拿到的结果是同一套规则、同一份统计。 + +## 通用流程 + +表格类导入都是三段式(成绩 / 学生 / 教师都一样): + +1. **下载模板** —— `GET /imports/template?kind=grades&format=xlsx|csv`。 + CSV 带 BOM,Excel 直接双击打开不乱码。 +2. **上传预览** —— 服务端返回列名、前 20 行、总行数与**建议的字段映射**,以及校验问题。 + 列名按同义词自动映射(「学号→学生用户名」「得分→分数」「满分→总分」……),也可以手动改。 + **缺必填列不会静默通过**,会直接给出「缺少必填列「…」」。 +3. **确认导入** —— 按映射与写入模式提交: + + | 模式 | 已存在的记录 | + | -------- | ------------ | + | `upsert` | 更新(默认) | + | `append` | 跳过 | + + 返回 `{ total, inserted, updated, skipped, failed, errors[], warnings[] }`。 + `errors[].row` 是 **Excel 视角的行号(含表头)**,方便老师改完再导。 + +**体积限制**:文件上限 **8MB**,请求体上限 12MB(文件以 base64 放进 JSON,约 4/3 倍)。 +超限返回 413 `IMPORT_TOO_LARGE` 与中文提示 —— 如果你看到一句英文的「request entity too large」, +那说明这个异常没被映射,属于 bug。 + +## 1. 成绩表格导入 + +- 入口:成绩录入 → 「导入表格」 +- 模板列:`学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程` +- 支持 `.xlsx / .xls / .csv / .tsv`(自动识别分隔符) +- **重复判定**:同班级 + 同学生 + 同考试 + 同课程 +- 学生按「用户名」优先、「姓名」兜底匹配 + +## 2. 学生名单导入 + +- 入口:学生管理 → 「导入名单」(仅管理员) +- 模板列:`用户名 / 姓名` —— 学生没有账号属性,**没有密码列** +- 用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$` +- 用户名已经被某个教师 / 管理员账号占用时,**这一行进错误列表**,不会覆盖他人账号 + +## 3. 教师名单导入 + +- 入口:教师管理 → 「导入名单」(仅管理员) +- 模板列:`用户名 / 姓名 / 初始密码 / 角色` +- 角色取 `ADMIN` / `TEACHER`,留空按 `TEACHER` +- 与新建教师同一套校验,导入完可以直接登录 + +## 4. 从 ClassIsland 导入课程表 + +**这是最省事的一条路**:如果教室里已经在用 ClassIsland,直接导入它的档案 JSON, +课表、节次时间、单双周会一起进来,不用排两遍。 + +- 入口:课表管理 → 「导入 ClassIsland 课程表」 +- 输入:同一份档案 JSON(`ClassPlans` / `TimeLayouts` / `Subjects` 三个字典), + 粘贴或选择 `.json` 文件 + +### 真实档案的形态 + +ClassIsland 2.x 的档案在 `Profiles/<档案名>.json`,字段是 PascalCase: + +```jsonc +{ + "TimeLayouts": { + "": { "Name": "…", "Layouts": [{ "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0 }] }, + }, + "ClassPlans": { "": { "Name": "…", "Classes": [/* 与上课时间点逐位对齐 */] } }, + "Subjects": { "": { "Name": "语文" } }, +} +``` + +解析器同时兼容顶层数组、`{ TimeLayouts: [...] }`、`{ items | Items | Layouts | TimeLayout: [...] }` +与单个时间表对象。 + +| 细节 | 说明 | +| ------------ | ----------------------------------------------------------------------------------------------------------------------- | +| 时间字段 | `StartTime` / `EndTime`(`"08:00:00"`);旧版 `StartSecond` / `EndSecond`(ISO 字符串或秒数);`"8:0"` / `"08:00"` 都认 | +| `TimeType` | `0=上课`、`1=课间`、`2=分割线`、`3=行动`,与 ClassIsland 一致 | +| **节次对齐** | `ClassPlan.Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0` 的时间点**(课间、分割线不占位) | +| 单双周 | `TimeRule.WeekCountDiv`(`0=每周`、`1=单周`、`2=双周`)+ `WeekCountDivTotal`(默认 2) | +| 星期换算 | ClassIsland 是 `0=周日 … 6=周六`,导入时换算成 `1=周一 … 7=周日` | +| 科目映射 | `Subjects[].Name`;该班还没有的科目**自动补建课程**(任课老师取该班班主任),预览里会提前列出 | +| 写入模式 | `replace` 清空该班课表后写入(事务内完成)/ `merge` 按「星期 + 开始时间 + 单双周」去重 | + +**解析出 `errors` 时返回 400 且数据库完全不写入**(`IMPORT_INVALID` / `IMPORT_EMPTY`)—— +先保证不写坏,再谈导入。 + +时间重叠、分割线没有时间这类问题只作 `warnings`,不阻断导入。 + +> **3 周及以上的轮换**没法用单双周表达,会给出 warning 并按"每周"处理。 + +## 5. 只导入节次时间(旧入口) + +「导入时间配置」按钮**已经下线** —— 因为同一份档案 JSON 里的 `TimeLayouts` 已经跟着课表一起进来了。 +组件与接口(`/imports/time-layout/*`)仍然保留给旧脚本调用。 + +它的写入模式也是 `replace`(整体替换该班同名配置)与 `merge`(以**开始时间**为槽位标识合并, +同槽位被导入项覆盖,其它旧节次保留,不静默丢数据)。 + +## 相关接口 + +| 路径 | 说明 | +| ------------------------------------------------------ | ------------------------------------------ | +| `/imports/template?kind=&format=` | 下载模板(`csv` 走 JSON,`xlsx` 走二进制) | +| `/imports/table/preview` | 上传解析预览(base64) | +| `/imports/table/commit` | 按映射与模式导入 | +| `/imports/class-plan/preview`、`/imports/class-plan` | ClassIsland 课程表预览 / 导入 | +| `/imports/time-layout/preview`、`/imports/time-layout` | 时间配置预览 / 导入(旧入口) | diff --git a/docs/app/index.md b/docs/app/index.md new file mode 100644 index 0000000..be9aba4 --- /dev/null +++ b/docs/app/index.md @@ -0,0 +1,49 @@ +--- +title: 应用帮助 +description: 班级小助手每一项功能怎么用:课表、作业、通知、叫人、成绩、班级、灵动岛、外观与导入。 +--- + +# 应用帮助 + +这一章按功能讲,重点是**行为上的约定**——什么时段会弹、谁看得见、什么情况下不动。 +很多"看起来像故障"的现象其实是设计行为,下面每一页都会点出来。 + +| 页面 | 讲什么 | +| --------------------------------- | ------------------------------------------------------ | +| [课表](schedules.md) | 周视图与「今天」时间轴、单双周、学期周数、课表来源 | +| [作业](homeworks.md) | 按所属日期归类、看板与全屏看板、教室机器录入、未交名单 | +| [通知与提醒](notifications.md) | 三档优先级、上课时段策略、二次确认、已读与红点 | +| [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 | +| [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 | +| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与协作教师、谁看得见什么 | +| [灵动岛](island.md) | 胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏 | +| [外观、主题与动效](appearance.md) | 深浅色、设计令牌、动效语言、降低动态效果 | +| [表格与课表导入](imports.md) | 成绩 / 名单 / 教师表格导入与 ClassIsland 档案导入 | + +## 贯穿所有功能的三条规则 + +**一、角色决定可见范围。** 同一个页面,管理员、班主任、科任老师看到的数据与能点的按钮都不一样。 +前端会按权限隐藏入口,但**真正拦人的是服务端**——直接调接口一样会被拒。完整矩阵在 +[角色与权限](../dev/permissions.md)。 + +**二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹; +紧急通知与紧急叫人才会立刻打断。规则只有一份,客户端与 ClassIsland 都遵守它。 +细节见[通知与提醒](notifications.md)。 + +**三、实时推送只走班级房间。** 老师一发内容,服务端写库后按班级房间广播,客户端立刻更新, +不靠轮询。教室机器断线重连后会自动补上漏掉的数据,灵动岛也会把上课期间暂存的通知补弹出来。 + +## 两端的差异 + +同一件事在 Web 管理端和桌面客户端上不一定完全一样,这是有意的: + +| 事情 | Web 管理端(老师) | 桌面客户端(教室机器) | +| ---------- | ------------------------------- | --------------------------------------------------------- | +| 作业 | 发布 / 编辑 / 删除 / 看未交名单 | 看作业 + **录入作业** + 勾未交名单,不显示"完成 / 待完成" | +| 成绩 | 录入 / 导入 / 改 / 删,带统计 | 只看全班总览 | +| 课表 | 排课、导入、改 | 只看(周视图 + 今天时间轴) | +| 通知 | 发布 / 删除 / 看已读 | 看、标为已读 | +| 班级与人员 | 管理员可管 | 只能看本班 | + +> 客户端没有"修改密码"入口,是**刻意移除的**:防止教室机器上误改班级密码后, +> 别的教室机器被锁在门外。班级密码由管理员在 Web 端维护。 diff --git a/docs/app/island.md b/docs/app/island.md new file mode 100644 index 0000000..49f1ece --- /dev/null +++ b/docs/app/island.md @@ -0,0 +1,121 @@ +--- +title: 灵动岛 +description: 桌面客户端的通知浮窗:胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏下的行为。 +--- + +# 灵动岛 + +灵动岛是桌面客户端上那个独立于主窗口的浮窗:无边框、透明、置顶、不占任务栏。 +它是「消息到达的第一现场」—— 老师发的内容先在岛上出现,学生点开才进主界面。 + +视觉参照 [WinIsland](https://github.com/WinIslandProject/WinIsland): +连续圆角(超椭圆)而不是 `border-radius`、纯黑 / 毛玻璃 / 主题色三种底、Apple 系统色强调色、 +6 个停靠位、可选的「空闲细缝」。逐项设计令牌见[灵动岛设计令牌](../dev/winisland-design-tokens.md)。 + +## 各形态长什么样 + +| 形态 | 截图 | 说明 | +| ---------------------- | ----------------------------------------------------------------- | ------------------------------------------------- | +| 非上课时段的「新消息」 | ![新消息胶囊](../screenshots/island/island-4-pill.png) | 268×44,直接出现(无入场动画),点击后展开 | +| 点开后的详情卡 | ![点击后展开](../screenshots/island/island-5-clicked.png) | 标题 / 内容 / 时间 + 打开应用 / 标为已读 / 知道了 | +| 下课后自动弹出 | ![下课后自动弹出](../screenshots/island/island-2-after-class.png) | 上课期间暂存的通知,下课后自动弹出 | +| 上课期间的紧急通知 | ![紧急通知立即展开](../screenshots/island/island-3-urgent.png) | 卡片**内部**红色光晕 + 紧急角标,无需点击 | +| 新作业 | ![新作业](../screenshots/island/island-7-homework.png) | 青蓝描边 + 书本图标 | +| 叫人 | ![叫人](../screenshots/island/island-8-call.png) | 琥珀金卡片 + 「叫人」徽标 + 「收到」按钮 | + +## 什么时候弹、弹成什么样 + +| 场景 | 灵动岛行为 | +| --------------------------------- | --------------------------------------------------------------------------------------------- | +| 收到通知(非上课时段) | 直接出现胶囊:`新消息 · 共 N 条` + 发送人 + `点击查看` | +| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)`,右侧带类型小标签 | +| 点击胶囊(只有一条) | 展开为**详情卡**:标题、内容、时间 + 三个按钮 | +| **点击胶囊(多条)** | 展开为**竖排列表**,按重要程度排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前) | +| **点「展开更多」** | 把放得下的都铺出来;铺到工作区下沿还放不下时改成「更多请前往应用内操作」 | +| **多条时的「知道了 / 标为已读」** | 都是**整批**操作 | +| 上课时段的**普通叫人** | **不自动展开**(不打断课堂),但保留胶囊并**允许学生主动点开**;下课自动展开 | +| **紧急叫人** | 上课时段也立即展开,卡片是琥珀金 + 「叫人」徽标,按钮为「收到」 | +| 上课时段的普通通知 | **完全不显示**(窗口隐藏)并进队列;下课自动弹出详情 → 收起为胶囊 | +| 上课时段的紧急通知 | **无论是否上课立刻展开**,45 秒后收起 | +| 点击卡片空白处 / 屏幕任意位置 | 回缩为**胶囊**(不直接消失,仍可再次点开) | +| **收起后胶囊常驻** | 只要还有未处理通知,胶囊就不会消失;彻底消失只在「知道了 / 标为已读」、队列清空或进入上课时段 | + +> **本机刚录入的作业不会上岛** —— 自己通知自己没意义。 + +## 多条通知时的排版 + +这是最容易出问题的地方:卡片要长高,还得刚好放得下。规则是**主进程与渲染进程各算一次同一个数** +(`islandListLayout()`,在 `@classhelper/shared` 里): + +- 默认只显示前 `defaultVisibleRows`(3)条,下面是「展开更多(还有 N 条)」; +- 点开后铺到 `maxCardHeight` 为止,仍放不下的改成「更多请前往应用内操作」; +- `maxCardHeight` 是主进程按工作区 / 任务栏算出的**可用高度**,随状态下发。 + +窗口的包围盒会**在卡片形变之前**先长高(这一步用户看不到),否则底部的按钮会被裁掉。 + +## 外观设置 + +客户端「设置 → 灵动岛」: + +| 设置项 | 区间 / 取值 | 默认 | +| --------------- | -------------------------------------- | ------------------------- | +| 高度 | 36–72px | 44 | +| 宽度 | 220–420px | 268 | +| 圆角 | 8–32px | 20 | +| 不透明度 | 40%–100% | 100% | +| **字号** | 11–20px | 13 | +| 动画速度 | 0.5×–2× | 1× | +| 主题色 | 取色器 | `#0a84ff`(Apple 系统蓝) | +| **视觉风格** | 纯黑 / 毛玻璃 / 主题色渐变 | 纯黑 | +| **显示位置** | 顶部 / 底部 × 左 / 中 / 右(6 个锚点) | 顶部居中 | +| 左右 / 上下边距 | 0–200px / 0–160px | 都是 8px | +| 跟随鼠标屏幕 | 开 / 关 | 关 | +| 动画开关 | 开 / 关 | 开 | +| 始终置顶 | 开 / 关 | 开 | +| **空闲细缝** | 开 / 关 | 关 | + +几个容易困惑的点: + +- **字号是"基础字号"**:岛内所有文本 = 基础字号 × 各自的排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / + 徽标 0.76 / 胶囊标题 0.92 ……),所以调一个滑块就能整体放大,比例不会乱。 +- **左右边距只在「左 / 右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线, + 这时调左右边距不会有任何变化 —— 设置页会把那个滑块置灰并写明原因。 +- **「毛玻璃」不是窗口材质**,是 CSS 半透明深色卡。用系统级背景材质(`setBackgroundMaterial`) + 会在卡片外那一圈垫出一块浅色"白底面板",而且**调用过一次就再也回不去逐像素透明**。 +- **「预览效果」**会本地推一条通知,立刻看到当前尺寸 / 配色 / 动画。预览是专用通道: + 直接展开、失焦不收起、30 秒后自行消失。 + +越界的值在**主进程**统一夹紧,配置填坏了也不会让布局错乱。 + +## 触摸屏(希沃白板等) + +触摸屏上灵动岛走的是**另一条命中链路**。 + +原因:常规的命中判定有两条路径 ——渲染进程转发鼠标移动、主进程读系统光标位置 —— +**两条都以"鼠标指针移动"为前提**。而手指触摸既不产生 `mousemove`、也不移动系统光标, +窗口就会永远停在"穿透"状态,**手指点不开岛**。 + +修法是:客户端检测到 `navigator.maxTouchPoints > 0` 后进入触摸模式, +把窗口**贴合岛体**并**始终接收输入**(Windows 上 Electron 没有窗口区域能力,这是唯一可行的办法)。 + +触摸模式下有三条额外的护栏,都是为了不闪、不裁: + +- **窗口可见期间只缩不放**(需要变小一律等窗口隐藏之后再做); +- 命中矩形按**窗口实际尺寸**算偏移; +- 失焦宽限期放宽到 1200ms(触摸没有光标,"假失焦"会让刚展开的岛被缩回胶囊)。 + +非触摸屏的机器**完全不走这条路**。 + +## 鼠标穿透 + +窗口是按最大形态创建的固定包围盒,比卡片大一圈。默认整块穿透,渲染层做命中测试, +只在指针进入岛体时打开命中 —— 所以这个"大窗口"不会吞掉桌面上的点击。 +另有一层保险:主进程每 120ms 用系统光标位置校正一次命中。 + +## 排查"闪一下" + +- **开合时底座闪一下**:卡片投影是常驻的 CSS `filter`,形态切换只改参数。 + 给元素增删 `filter` 会让 Chromium 重建渲染表面,首帧可能被当成空内容画出去。 +- **收回一瞬间闪一下**:多半是**自动化验证实例的叠影**,不是产品的问题 —— + 冒烟会真的在屏幕上放一个灵动岛,如果它和你自己的客户端叠在一起,你看到的是两个窗口。 + (验证实例的岛是洋红主题色,一眼可辨。) diff --git a/docs/app/notifications.md b/docs/app/notifications.md new file mode 100644 index 0000000..ee0321d --- /dev/null +++ b/docs/app/notifications.md @@ -0,0 +1,91 @@ +--- +title: 通知与提醒 +description: 通知的三档优先级、上课时段策略、紧急通知的二次确认、提醒弹在哪个端、已读与红点。 +--- + +# 通知与提醒 + +通知是这套系统里规则最多的一项 —— 因为它要在"老师想说"和"别打断上课"之间做取舍。 +这一页把规则一次讲清楚。 + +## 三档优先级 + +| 优先级 | 学生端表现 | +| ------ | -------------------------------------------------------------------------------------------- | +| 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 | +| 重要 | 同上,卡片上有优先级标签 | +| 紧急 | **无论是否上课都立即展开**,卡片内部红色呼吸光晕 + 「紧急」角标,无需点击,45 秒后收起 | + +紧急通知的光晕画在**卡片内部**(`inset` 阴影),因为窗口比卡片只大 2~4px,向外的阴影会被裁成硬边。 + +## 上课时段策略 + +「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后: + +| 学生端行为 | 上课时段 | 课间 / 放学 | +| ---------- | ---------------------------------------- | ----------- | +| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 | +| 紧急通知 | **立刻弹** | 立刻弹 | +| 任何点击 | 一律不响应(不会展开、也不会回缩出胶囊) | 正常 | + +ClassIsland 那一端遵守同一套规则,否则会出现"学生在客户端看不到、却在教室大屏上被打断"。 + +## 紧急通知的二次确认 + +上课时段发紧急通知,**服务端会拦一次**(`409 URGENT_DURING_CLASS`): + +``` +POST /api/notifications { classId, title, content, priority: "URGENT" } + → 409 URGENT_DURING_CLASS(响应体里带着正在上的那节课) + +POST /api/notifications { …, priority: "URGENT", confirmDuringClass: true } + → 201 发出去 +``` + +Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二次确认**:全屏遮罩 + 强警示配色, +文案是"现在为上课时间段……" + 正在上的课时段 + 待发布的标题。确认按钮带 **3 秒倒计时** +(圆环进度),倒计时结束前点不动,防误触。 + +**这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。 + +## 提醒弹在哪个端 + +教室机器上的客户端「设置 → 提醒」里选,选择会同步到班级记录: + +| 选择 | ClassHelper 客户端 | ClassIsland | +| ----------------------------------------- | ------------------------------ | ----------- | +| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 | +| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 | +| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 | + +这条规则**任何入口都遵守**,包括 ClassIsland 联动页的「下发提醒」—— +那种情况下会回一句"该班教室已设置为只在 ClassHelper 客户端显示"。 + +管理员与教师也可以用 `PATCH /api/classes/:id/notification-channel` 改,但默认由教室机器自己定。 + +## 已读、红点与删除 + +| 能力 | 说明 | +| -------- | ------------------------------------------------------------------------------ | +| 未读红点 | 客户端侧边栏「通知」项右上角的徽标;数字来自 `GET /notifications/unread-count` | +| 标为已读 | 点开通知自动标为已读;也有「全部已读」 | +| 班级设备 | 教室机器的"已读"是**代全班**操作的(它用的是班级账号) | +| 删除 | 只有教师 / 管理员能删 | + +## 发布入口只有一个实现 + +「通知发布」页、叫人、ClassIsland 联动页的「下发提醒」三个入口, +**共用同一份 ClassIsland 推送实现**。所以不存在"某个页面发的通知不联动"这种事 —— +以前有过,现在是硬约定:新增发通知的入口必须复用同一份代码。 + +## 接口 + +| 方法 | 路径 | 说明 | +| ------ | -------------------------------------------------------- | ------------------------------- | +| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 列表(带已读状态) | +| GET | `/notifications/unread-count` | 未读数 | +| POST | `/notifications` | 发布(广播 `notification:new`) | +| POST | `/notifications/:id/read`、`/notifications/read-all` | 标记已读 | +| DELETE | `/notifications/:id` | 删除 | + +完整清单见 [REST API](../dev/api.md)。 diff --git a/docs/app/schedules.md b/docs/app/schedules.md new file mode 100644 index 0000000..675c8c9 --- /dev/null +++ b/docs/app/schedules.md @@ -0,0 +1,82 @@ +--- +title: 课表 +description: 周课表与「今天」时间轴、单双周、学期周数、课表来源,以及上课时段是怎么判定的。 +--- + +# 课表 + +课表不只是"给学生们看看"的表格 —— **「上课时段」的判定完全基于它**。 +课表排错,提醒就会在该弹的时候不弹。所以这一页值得读完。 + +## 两个视图 + +| 视图 | 在哪 | 内容 | +| -------------- | ----------------------------------------- | -------------------------------------------------------- | +| **周视图** | Web 端「课表管理」、客户端「课表 → 本周」 | 节次 × 星期 的 7 列表格 | +| **今天时间轴** | 客户端「课表 → 今天」 | 大时钟 + 当前状态卡 + 时间轴(已结束 / 正在上 / 下一节) | + +「今天」时间轴是客户端默认打开的那一屏: + +- **大时钟**:时:分(大字)+ 秒(小字),下面一行是日期 / 星期 / 第 N 教学周。 +- **当前状态卡**:「正在上 XXX」+ 时段地点 + **距下课 X 分钟**;没课时显示"今天的课都上完了"或"今天没有课程安排"。 +- **时间轴**:左侧时间栏(开始 / 结束)+ 竖向轨道与节点 + 右侧课卡。已结束的置灰、 + 正在上的用强调色实底 + 进度条 + 距下课倒计时、下一节用橙色标记 + "还有多久开始"。 + +## 单双周 + +一门课可以设成**每周**(默认)、**单周**或**双周**。判定口径是 **第 1 周 = 单周**。 + +从 ClassIsland 导入时,`WeekCountDiv`(`0=每周 / 1=单周 / 2=双周`)会换算成这套 `ALL / ODD / EVEN`。 +**3 周及以上的轮换没法用单双周表达**,导入时会给出 warning 并按"每周"处理 —— +如果学校里真的是三周轮换,目前只能用两份课表 + 手工切换的方式绕过。 + +## 学期周数 + +每个班有自己的**学期周数**(`Class.termWeeks`,默认 20,**上限 30**),只有管理员能改。 + +它决定三件事:课表的周次下拉能选到第几周、`weekEnd` 的默认值、以及"现在是第几教学周"。 +周次换算的起点是服务端 `.env` 里的 `TERM_START_DATE`(当学期第 1 教学周的周一)。 + +> **`TERM_START_DATE` 设错,整套周次都错。** 它不在界面上改,要改 `.env` 后重启服务端。 +> 上限三处必须一致(`MAX_TERM_WEEK`、周次校验、班级设置),曾经出现过"班级能设到 40 周、 +> 而课表只到 30 周"的错位。 + +## 上课时段是怎么判定的 + +服务端有一个接口专门回答"这个班现在是不是在上课": + +``` +GET /api/schedules/current?classId=<班级 id>[&at=] +``` + +返回 `{ inClass, current, next, week, serverTime }`。`at` 参数可以覆盖判定时刻,用来联调与测试。 + +判定结果被用在四处,规则完全一致: + +1. 客户端灵动岛:上课时段普通通知**不显示**(窗口直接隐藏)并进队列,下课自动补弹; +2. ClassIsland 插件:同样暂存,下课后补弹; +3. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行; +4. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。 + +## 课表的"来源"字段 + +每一行课表都记着它是从哪来的: + +| 来源 | 什么时候产生 | 会被自动清理吗 | +| ------------- | ---------------------------------------------- | ---------------------------- | +| `manual` | Web 端手工录入、从 ClassIsland JSON 一次性导入 | **不会**,永远保留 | +| `classisland` | 教室插件上报同步过来的 | 会——本次没再上报的行会被删掉 | + +这条规则解决了"老师在 ClassIsland 里删掉一节,小助手这边却永远留着"的幽灵课问题, +同时保证老师在小助手这边手排的课不会被插件覆盖掉。 + +> 去重键是「星期 + 开始时间 + 单双周」。如果老师手排的某一节恰好和上报的同一时间槽重合, +> 那一行会被上报"收养"(保留原本的 `manual` 来源),下一次清理不会删它。 + +## 排课 / 改课 / 导入 + +- 手工排课在 **课表管理**(管理员 / 本班班主任)。科目下拉是全校统一的 18 科目录, + 选中一个该班还没有的科目会自动建课。 +- 从 ClassIsland 档案导入最省事,课表、节次时间、单双周会一起进来, + 见[表格与课表导入](imports.md)。 +- 课表变更后会广播 `schedule:updated`,客户端与灵动岛立刻刷新。 diff --git a/docs/app/toc.yml b/docs/app/toc.yml new file mode 100644 index 0000000..75a161a --- /dev/null +++ b/docs/app/toc.yml @@ -0,0 +1,20 @@ +- name: 应用帮助 + href: index.md +- name: 课表 + href: schedules.md +- name: 作业 + href: homeworks.md +- name: 通知与提醒 + href: notifications.md +- name: 叫人 + href: calls.md +- name: 成绩 + href: grades.md +- name: 班级、学生与教师 + href: classes.md +- name: 灵动岛 + href: island.md +- name: 外观、主题与动效 + href: appearance.md +- name: 表格与课表导入 + href: imports.md diff --git a/docs/build.mjs b/docs/build.mjs new file mode 100644 index 0000000..bc5439e --- /dev/null +++ b/docs/build.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * 文档站构建脚本 —— `pnpm docs:build`。 + * + * 为什么不用直接跑 `docfx docs/docfx.json`: + * 1) 站点版本号取自根 package.json(单一来源),DocFX 的配置文件是静态 JSON、读不了文件, + * 只能用 CLI 的 `--metadata` 传进去(模板里以 `{{_chVersion}}` 引用); + * 2) GitHub Pages 的项目页挂在 /<仓库名>/ 子路径下,404 页里的相对链接必须相对**站点根** + * 而不是相对被请求的那个不存在的路径,因此 404 页的 `` 由这里注入(DOCS_BASE 控制)。 + * + * 用法: + * node docs/build.mjs # 构建到 docs/_site + * DOCS_BASE=/classhelper/ node docs/build.mjs + * node docs/build.mjs --check # 构建并把 docfx 的警告当成错误(CI / 交付前用) + * node docs/build.mjs --serve # 构建完起本地预览(等价于再跑 docs/serve.mjs) + */ + +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); +const siteDir = path.join(here, '_site'); + +/** 站点根路径。CI 里按仓库名传进来;本地预览用 './'。 */ +const BASE = process.env.DOCS_BASE ?? './'; + +function readVersion() { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + return pkg.version; +} + +/** 找不到 docfx 时给出可照抄的安装命令,而不是一句 spawn ENOENT */ +function ensureDocfx() { + const probe = spawnSync('docfx', ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }); + if (probe.status === 0) return probe.stdout.trim(); + + console.error('[docs] 找不到 docfx。docfx 是 .NET 工具,安装方式:'); + console.error(''); + console.error(' dotnet tool install --global docfx'); + console.error(''); + console.error(' 需要 .NET SDK 8(判据是 `dotnet --list-sdks` 有输出)。'); + console.error(' 想把工具装进仓库内(不污染全局):'); + console.error(' DOTNET_CLI_HOME=./.cache/dotnet-home NUGET_PACKAGES=./.cache/nuget \\'); + console.error(' dotnet tool install --global docfx'); + process.exit(2); +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'inherit', shell: process.platform === 'win32', ...options }); + child.on('error', reject); + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${command} 退出码 ${code}`)))); + }); +} + +/** + * 清空上一轮的产物。docfx 不负责清理,旧文件(改过名 / 删掉的页面)会一直留着, + * 而 GitHub Pages 上传的是整个目录 —— 不清就会把已下线的页面继续挂在线上。 + */ +function cleanSite() { + const sleep = (ms) => { + const until = Date.now() + ms; + while (Date.now() < until) { + /* 同步等待:这是构建脚本,不值得为几毫秒引入异步 */ + } + }; + for (let attempt = 1; attempt <= 5; attempt += 1) { + try { + fs.rmSync(siteDir, { recursive: true, force: true }); + return; + } catch (error) { + if (attempt === 5) { + throw new Error( + `清不掉 ${path.relative(root, siteDir)}(${error.code ?? error.message})。` + + ' 多半是预览服务或编辑器正占用它 —— 关掉再试。', + ); + } + sleep(250 * attempt); + } + } +} + +/** + * 生成 404 页。 + * GitHub Pages 对项目页的未知路径返回站点根的 404.html,而浏览器是**按请求的路径**解析相对链接的, + * 所以页面里的 必须显式指向站点根,否则 /classhelper/get-started/typo 会去解析 + * /classhelper/get-started/ 下的链接。本地预览时 BASE 为 './',正好等价于同目录。 + */ +function writeNotFound() { + const template = fs.readFileSync(path.join(here, '404.html'), 'utf8'); + const html = template.replaceAll('{{BASE}}', BASE); + fs.writeFileSync(path.join(siteDir, '404.html'), html, 'utf8'); + console.log(`[docs] 404.html(base = ${BASE})`); +} + +async function main() { + const version = readVersion(); + const docfxVersion = ensureDocfx(); + console.log(`[docs] docfx ${docfxVersion} · 站点版本 ${version}`); + + // --check:把警告当错误。最常见的警告是"某个 .md 没被任何 toc.yml 引用", + // 那种页面会被构建出来但没有侧边栏,属于必须修掉的疏漏。 + const check = process.argv.includes('--check'); + const args = ['build', 'docfx.json', '--metadata', `_chVersion=${version}`]; + if (check) args.push('--warningsAsErrors'); + + // docfx 只覆盖、不清理:删掉或改名的页面会以旧文件的形式留在 _site 里, + // 而 GitHub Pages 上传的是整个目录 —— 不清理就会把已经下线的页面继续挂在线上。 + // Windows 上目录偶尔会被瞬时占用(编辑器 / 索引 / 预览进程),所以重试几次再放弃。 + cleanSite(); + + await run('docfx', args, { cwd: here }); + + writeNotFound(); + console.log(`[docs] 完成:${path.relative(root, siteDir)}`); + + if (process.argv.includes('--serve')) { + await run(process.execPath, [path.join(here, 'serve.mjs')]); + } +} + +main().catch((error) => { + console.error('[docs] 失败:', error.message); + process.exit(1); +}); diff --git a/docs/dev/api.md b/docs/dev/api.md new file mode 100644 index 0000000..f1e44ba --- /dev/null +++ b/docs/dev/api.md @@ -0,0 +1,107 @@ +--- +title: REST API +description: 班级小助手后端全部接口一览:方法、路径、权限与说明。 +--- + +# REST API + +## 通用约定 + +- 所有接口前缀 `/api`(设备接口也一样); +- 统一响应体:`{ "success": true, "data": {}, "message": "" }`,错误为 `success: false` + `code`; +- 除登录、健康检查与设备接口外,都需要 `Authorization: Bearer `; +- **ClassIsland 设备接口用 `X-ClassIsland-Token`**,不走 JWT(见[ClassIsland 插件](classisland-plugin.md)); +- 请求体校验走 zod(`middleware/validate.ts`),错误由 `middleware/error.ts` 统一映射。 + +## 接口一览 + +| 方法 | 路径 | 权限 | 说明 | +| --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ | +| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) | +| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 | +| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) | +| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) | +| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | +| GET | `/classes` | 登录 | 班级列表(按权限收敛) | +| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 协作教师) | +| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) | +| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 | +| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | +| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 | +| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) | +| GET | `/classes/:id/notification-channel` | 班级可见 | 读取「通知显示到哪个端」 | +| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 | +| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 | +| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 | +| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 | +| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 | +| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) | +| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | +| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | +| POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) | +| GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) | +| GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) | +| GET | `/homeworks/:id` | 班级可见 | 作业详情 | +| POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) | +| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | +| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) | +| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | +| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | +| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | +| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` | +| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | +| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 | +| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) | +| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) | +| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 | +| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 | +| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) | +| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 | +| GET | `/students?classId=&keyword=` | **管理员** | 学生名单 | +| POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) | +| GET | `/teachers?keyword=` | **管理员** | 教师列表 | +| POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 | +| POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) | +| GET | `/dashboard/summary`、`/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | +| GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) | +| POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 | +| POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) | +| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) | +| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 | +| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) | +| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | +| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | +| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | +| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 14 张表行数 / 备份列表 / 定时配置) | +| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) | +| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | +| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | +| GET | `/database/export`、`/database/sqlite-file` | **管理员** | 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite) | +| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite / MySQL 之间互迁) | +| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled / intervalHours / keepCount) | +| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 | +| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间 | +| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) | +| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | +| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | +| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) | +| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) | + +另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。 + +## 几条容易踩的接口约定 + +| 约定 | 说明 | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| **`POST /homeworks` 故意不加 `requireRole`** | 权限判在 `homeworks.service.createHomework` 里(staff 或本班班级账号),因为教室机器也要能录作业 | +| **班级账号的 `sub` 是班级 id** | 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键 | +| **登录限流挂在两个入口上** | `/auth/login` 与 `/auth/class-login` 都挂 `loginRateLimiter()` | +| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 | +| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 | +| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` | + +## 相关 + +- 实时事件见[实时事件(Socket.IO)](realtime.md); +- 权限矩阵见[角色与权限](permissions.md); +- 数据模型见[数据模型](data-model.md)。 diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md new file mode 100644 index 0000000..c27d59b --- /dev/null +++ b/docs/dev/architecture.md @@ -0,0 +1,160 @@ +--- +title: 架构总览 +description: 目录结构、技术栈、模块化设计与几条贯穿全局的工程约定。 +--- + +# 架构总览 + +## 技术栈 + +| 层 | 选型 | 版本(`package.json` 实际声明) | +| ------ | -------------------------------------------------------------------- | ---------------------------------------------------------- | +| 包管理 | pnpm workspace | 11.8.0 | +| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) | +| 后端 | Node.js + Express | ≥ 20.19 / 5.2 | +| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`,切 MySQL 用 `-mariadb`) | +| 实时 | Socket.IO | 4.8 | +| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 | +| 校验 | zod | 4.6 | +| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 | +| 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 | +| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 | +| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 | +| 文档站 | docfx | 2.81 | + +## 目录结构 + +``` +class-helper/ +├── package.json # 根脚本(dev / build / db:* / verify:* / dist:* / docs:* / icons) +├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单) +├── tsconfig.base.json # 共享 TS 基础配置(ESM + NodeNext + strict) +├── eslint.config.mjs # ESLint 扁平配置(TS + Vue) +├── .prettierrc.json # 单引号 / 110 列 / LF / 尾逗号 +├── build/classhelper.{png,ico} # **图标源**(设计导出、手工放入);pnpm icons 从它派生其余全部图标 +├── scripts/ +│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手 +│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons) +│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS) +│ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*) +│ ├── dist-server.mjs # 服务端 + Web 管理端打包(免安装目录 + NSIS 安装程序) +│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟 +│ ├── lib/ # electron-env / node-runtime / smoke-class +│ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入) +│ └── ui-smoke/ # Web 管理端 UI 真实点击回归(Electron 驱动) +├── deploy/ # 生产部署:Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf +│ ├── install.sh # Linux 一键安装器(TUI;native/docker 双形态;自带 --check 体检) +│ ├── classhelper # 运维命令本体(软链 /usr/local/bin/classhelper) +│ ├── verify-linux.sh # Linux 安装/改密/备份/升级/回滚验收(自统计 N/N 项) +│ ├── tools/admin-cli.mjs # 离线账号工具(改密、列账号、SQLite 一致性快照) +│ ├── classhelper.service # systemd 单元模板 +│ └── package.runtime.json # 运行时依赖清单(安装包 / 容器 / Linux 部署共用) +├── docs/ # **文档站**(docfx 项目,发布到 GitHub Pages) +│ ├── docfx.json # docfx 配置 +│ ├── toc.yml # 顶栏导航;各子目录各有一份 toc.yml 作为侧栏 +│ ├── index.md # 首页 +│ ├── get-started/ app/ management/ dev/ # 四个章节 +│ ├── templates/classhelper/ # docfx 模板覆盖(_master.tmpl + public/main.css + 图标) +│ ├── screenshots/ # 回归测试自动留档的截图(island / client / web-mobile / classisland) +│ ├── build.mjs # 构建入口(注入版本号与 404 的 base) +│ ├── serve.mjs # 本地预览(:5181) +│ └── _site/ # 构建产物(gitignore) +├── website/ # 产品官网(纯静态,无构建;node website/serve.mjs 本地预览) +│ └── assets/{styles.css,motion.js,main.js,icon.png,shots/} +└── packages/ + ├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数 + │ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts + ├── server/ # 后端服务 + │ ├── prisma/schema.prisma # 数据模型(14 个 model,只有一份,切库改 provider) + │ ├── prisma/migrations/ # 迁移历史(10 个) + │ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据) + │ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs + │ └── src/ + │ ├── app.ts # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底) + │ ├── index.ts # 启动入口(自检 + HTTP + Socket.IO + 优雅退出) + │ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检 + │ ├── lib/ # access(RBAC) / snapshot(全库 JSON 快照) / db / db-bootstrap / + │ │ # session / web-static / http / jwt / logger / mappers / version … + │ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口 + │ ├── middleware/ # auth / error / validate / security(helmet + 限流 + 耗时日志) + │ ├── realtime/ # socket.ts(房间)+ bus.ts(事件总线) + │ └── modules/ # 15 个功能模块 + registry.ts(模块注册表) + ├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace) + │ ├── manifest.yml + │ ├── src/Plugin.cs + │ ├── src/Services/ # BridgeService / ScheduleMapper / ClassPlanWriter / + │ │ # ClassHelperNotificationProvider + │ ├── src/Views/ # BridgeSettingsPage.axaml(Avalonia 设置页) + │ └── src/Interop/ClassHelperClient.cs # HTTP 客户端 + 逐字段对齐服务端 zod 的 DTO + ├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA) + │ └── src/{api,stores,router,layouts,views,components,composables,styles} + └── desktop-client/ # 桌面客户端(Electron + Vue 3) + ├── electron-builder.yml + ├── scripts/{build-main,dev,smoke,dist-win,check-bundle-secrets}.mjs + └── src/ + ├── main/ # 主进程:窗口 / 单实例 / 配置持久化 / 托盘 / 灵动岛 / 冒烟 + ├── preload/ # contextBridge 安全桥(index.ts + island.ts) + ├── island/ # 灵动岛渲染进程(独立透明置顶窗口) + ├── types/desktop.d.ts # 主进程 ↔ 渲染进程契约 + └── renderer/ # 渲染进程:api / stores / cache / router / views / island +``` + +## 模块化设计 + +后端每个功能是一个独立目录,统一契约: + +```ts +// packages/server/src/modules/registry.ts(当前 15 个模块) +export const apiModules: ApiModule[] = [ + authModule, + classesModule, + coursesModule, + schedulesModule, + homeworksModule, + notificationsModule, + callsModule, + importsModule, + integrationsModule, + gradesModule, + studentsModule, + teachersModule, + dashboardModule, + databaseModule, // 数据库管理(仅管理员) + updateModule, // 更新检查(服务端唯一出网请求) +].filter((module) => module.enabled !== false); +``` + +- **新增功能**:新建 `modules//`(`*.schemas.ts` 校验、`*.service.ts` 业务、`*.module.ts` 路由), + 在 registry 里加一行即可,`app.ts` 不用改; +- **下线功能**:删掉那一行,或把 `enabled` 置为 `false`,接口立即从路由表消失; +- **实时推送解耦**:业务模块只调 `realtime/bus.ts` 的 `emitToClass/emitToUser`,不直接依赖 Socket.IO 实例; +- **共享契约**:所有 DTO 与类型集中在 `@classhelper/shared`(改完要 `pnpm build:shared`),三端共用。 + +## 几条贯穿全局的硬约定 + +这些都是踩过坑之后固化下来的,改代码前值得先看(完整版在 `AGENTS.md`): + +| 约定 | 为什么 | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | +| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` | +| **权限判在服务层** | 路由只做角色过滤,班级维度的判断用 `lib/access.ts` 断言 —— 前端隐藏 ≠ 权限 | +| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` | +| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 | +| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) | +| **跨桥接只传纯数据** | 传 Vue 的 `ref.value` 会在进入 preload 之前就抛 `could not be cloned` | +| **桌面端产物里不得有凭据** | asar 可直接解包;构建有 `check-bundle-secrets` 门禁 | +| **图标只有一个图形源** | 别改 `scripts/icons/` 的派生逻辑,换图标就换 `build/classhelper.{png,ico}` | +| **前端不写死颜色** | 深色主题靠语义令牌,写死的颜色在深色下会露馅 | + +## 本地开发 + +```bash +pnpm install +cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET +pnpm db:generate && pnpm --filter @classhelper/server db:deploy +pnpm build:shared # 必须先构建 shared,否则后续全部 ERR_MODULE_NOT_FOUND +pnpm db:seed # 演示数据(会清空业务表) +pnpm dev # shared watch + 后端 4000 + Web 端 5173 +``` + +常用命令与端口见[安装服务端](../get-started/server.md);验收脚本见[验收与测试](testing.md)。 diff --git a/docs/dev/classisland-plugin.md b/docs/dev/classisland-plugin.md new file mode 100644 index 0000000..2d031f0 --- /dev/null +++ b/docs/dev/classisland-plugin.md @@ -0,0 +1,146 @@ +--- +title: ClassIsland 插件 +description: 联动插件的契约、镜像为什么要带节次时间、上课时段暂存,以及部署与调试。 +--- + +# ClassIsland 插件 + +源码在 `packages/classisland-plugin/`,是一个 **.NET 8 / C#** 的 ClassIsland 插件 +(ClassIsland 插件 API 2.0)。它**独立于 pnpm workspace**,不参与 `pnpm install`。 + +## 三条链路 + +``` +ClassIsland(教室机器) + │ ① 上报:状态 + 课表 + 节次时间(每次上报顺带取回一条待弹提醒) + ▼ +/api/integrations/classisland/report ← X-ClassIsland-Token(设备令牌,非 JWT) + │ + │ ② 下发:老师在 Web 端发提醒 → 落库 → 插件下次上报时取回 → ClassIsland 全屏弹出 → ack + ▼ +/api/integrations/classisland/pending、/ack + + │ ③ 镜像(可选):服务端把本班课表下发,插件在 ClassIsland 里新建一份档案课表 + ▼ +/api/integrations/classisland/class-plan +``` + +## 为什么走 HTTP 而不是 IPC + +ClassIsland 的跨进程 IPC 数据质量不足以还原课表。插件内部**直接读** +`ILessonsService`(课程状态)与 `IProfileService`(档案课表)最准。 + +与后端之间用「**上报即拉取**」:每次上报的返回值里顺带带一条待弹出的提醒 —— +插件因此**不需要 WebSocket 依赖**,故障面更小,而且**上报间隔本身就是心跳**。 + +提醒是**轮询取回**的(默认最多 10 秒延迟,设置页「提醒轮询间隔」可调到 5 秒)。 + +## 设备令牌 + +- 令牌是**设备级**的,不是用户级; +- 插件在本机保存明文,服务端只存 **sha256** 与一个前缀提示(`chci_xxxxxxxx`)用于人眼识别; +- 同一班级下按 `deviceKey`(机器码)唯一 —— 插件重装后带同一 `deviceKey` 会**自动重绑**到原记录, + 不会在设备列表里堆重复项; +- 关闭设备的「启用」开关后,该设备的上报与下发一律 403。 + +## 提醒的排队规则 + +| 提醒类型 | 上课时段 | 下课 / 放学 | +| ------------------------------------------------------ | ---------------------------- | ----------- | +| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹** | 自动补弹 | +| 主动通知:紧急提醒、**叫人**(`kind=call`) | **立刻弹**(老师正在等学生) | 立刻弹 | + +插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件); +客户端侧的实现在 `renderer/island/bridge.ts`。**改这条规则要两端一起改。** + +暂存期间**不回执**,所以服务端会一直保留着待提醒;`ClassIslandPush.expiresAt` 到期后不再补发 +(避免学生几天后突然弹出旧提醒)。 + +## 镜像:为什么必须带 `startTime` / `endTime` + +ClassIsland 的 `ClassPlan.Classes[i]` **强制与时间表里第 i 个 `TimeType == 0`(上课)的时间点对齐**。 + +所以镜像下发时,每一条课目都要带上自己的 `startTime` / `endTime`。 + +> **少一个时间点,整份课表就会错位** —— 而且表现为"课表看着有内容、每节课都对不上", +> 极难排查。这是插件契约里最容易踩的坑。 + +另外镜像进 ClassIsland 的课表还要**带课间**:每两节之间补一段(上一节下课 → 下一节上课), +**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算), +服务端合成节次时间表用的是同一口径。 + +## 上报课表:单向还是双向 + +**双向对齐**。插件每次全量上报时,服务端只清理「`source='classisland'` 且本次未再上报」的行: + +- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步; +- 老师在小助手这边手排的课 → **一律不删**。 + +merge 的去重键是「星期 + 开始时间 + 单双周」。导入时**不要覆盖 `weekStart` / `weekEnd`** —— +插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。 + +## 单双周的映射 + +ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` ⇄ 本项目的 `ALL / ODD / EVEN`, +走 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` —— +**服务端与插件共用同一份语义**,两边不会各算各的。 + +## 代理:本地地址强制直连 + +`ClassHelperClient` 里有一个 `LocalAwareProxy`:对本地 / 内网地址**强制直连**, +不走系统代理。 + +原因:教室机器上如果开了系统代理,填好令牌后表现为"立刻 HTTP 404"(其实是代理返回的错误页)。 +`pnpm verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。 + +## 构建、打包与验收 + +```bash +pnpm build:classisland-plugin # 编译(.NET 8;缓存与临时目录自动指到仓库内 .cache/) +pnpm dist:classisland-plugin # 打包 .cipx 并归集到 releases/classisland-plugin/<版本>/ +pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需要服务端) +``` + +`verify:classisland-plugin` 会逐项校验:清单一致性、注册完整性、 +**C# DTO ↔ 服务端 zod 与路由的字段对齐**,以及已修复坑的护栏。 + +### 那个预期内的编译警告 + +全量编译会有 **1 个警告 `AVLN3001`**:设置页只有 DI 构造函数、没有公开无参构造。 +本插件走的是编译期 XAML + ClassIsland 的 DI 实例化,**加个无参构造反而会把 DI 弄坏**。 +别为了消警告去改它。 + +## 部署到教室机器 + +```powershell +# 1) 完全退出 ClassIsland(它会锁住 DLL) +Get-Process ClassIsland.Desktop | Stop-Process -Force +# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌) +Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll ` + D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force +# 3) 重新启动 +Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland +``` + +改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。 + +### 读日志要用 GBK + +.NET 的控制台输出是**系统 ANSI(GBK)**编码。用 UTF-8 读会变成乱码, +**中文关键字一条都搜不到**(只能搜到 ASCII 的类名): + +```powershell +[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936)) +``` + +日志文件被运行中的进程占用时,先 `Copy-Item` 一份再读。 + +## 真机验收链路 + +`pnpm verify:classisland`(需要后端在跑)会真的走一遍:设备令牌 → 上报 → 提醒下发与回执 → +镜像契约 → 幽灵行清理。 + +> 这个脚本开头会先**抽干本班的历史积压提醒**。因为待提醒接口是「本班全部未确认提醒, +> 按时间升序取前 20 条」,而其他冒烟会真的往演示班发通知 —— 攒够 20 条之后, +> 本脚本刚发的提醒会被挤出窗口,表现为"待提醒里找不到刚发的那条",跟代码对不对毫无关系。 +> **别把那个 drain 删掉。** diff --git a/docs/dev/data-model.md b/docs/dev/data-model.md new file mode 100644 index 0000000..fb48f50 --- /dev/null +++ b/docs/dev/data-model.md @@ -0,0 +1,87 @@ +--- +title: 数据模型 +description: 14 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。 +--- + +# 数据模型 + +模型定义只有一份:`packages/server/prisma/schema.prisma`。 + +## 跨库通用约定 + +- 默认是 **SQLite**(Prisma 7 的 libSQL driver adapter,数据文件 `packages/server/prisma/dev.db`); +- 字段**只用** `String / Int / Float / Boolean / DateTime`,**不用 Prisma enum、不用 `@db.*` 原生类型**; + 角色与优先级存字符串,在 API 层用 zod 校验 —— 这样 SQLite 与 MySQL 行为完全一致; +- 因此切库只需改 `provider` 与连接串(`pnpm db:switch:mysql` 就地改写它,没有第二份 schema); +- 主键统一用 cuid 字符串,便于多端离线生成与合并。 + +## 14 张表 + +| 表 | 职责 | 值得注意的字段 | +| ------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- | +| `User` | 教师 / 管理员 / 学生统一存储 | `role`、`classId`;学生 `passwordHash` 是空串占位,不能登录 | +| `Class` | 班级 | `code` + `passwordHash`(班级账号)、`termWeeks`、`notificationChannel` | +| `ClassTeacher` | 教师-班级 分配关系(协作教师) | `@@unique([classId, teacherId])` | +| `Enrollment` | 学生-班级 归属记录 | 与 `User.classId` 同步,为将来多班预留 | +| `Course` | 课程(班级维度) | `@@unique([classId, name])` | +| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) | +| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 | +| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, userId])` | +| `Notification` | 通知 | `priority` | +| `NotificationRead` | 已读记录 | `@@unique([notificationId, userId])` | +| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` | +| `Grade` | 成绩 | `score` / `totalScore` / `examName` | +| `IntegrationDevice` | ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`tokenHint`、两个开关、最后一次上报的快照字段 | +| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` | + +### 三个容易忽略的设计 + +**`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。 +设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。 +`expiresAt` 防止学生几天后突然弹出旧提醒。 + +**`Schedule.source`** —— 决定插件全量上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。 +**`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。 + +**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。 +保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。 + +## 迁移历史(10 个) + +``` +init → add_time_layout → add_class_account → add_schedule_week_parity → +add_class_term_weeks → add_classisland_integration → add_class_notification_channel → +add_homework_assign_date → add_push_kind → add_schedule_source +``` + +### 启动时自动迁移 + +`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名: + +| 情况 | 行为 | +| -------- | --------------------------------------------------- | +| 全新安装 | 依次执行全部迁移并创建初始管理员 | +| 覆盖安装 | 只补跑账本里没有的迁移(已存在的表 / 索引自动跳过) | +| 已是最新 | **不做任何写操作** | + +日志会写明「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。 +用户因此**不需要手动执行 `prisma migrate deploy`**。 + +## 备份格式就是这份模型的拓扑序导出 + +「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:14 张表按拓扑序导出, +恢复时**临时关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User` +**互相引用**,任何排序都会被其中一侧卡死。见[数据库管理](../management/database.md)。 + +## 开发时的模型改动 + +1. 改 `schema.prisma`; +2. 在 `prisma/migrations/` 下加一个迁移目录(本机用 + `pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** —— + 它在 Windows 上不可靠); +3. 改 schema 时先看**哪些外键指向 `User` 且是 Cascade**,再回来核对 + `deleteTeacher` 的护栏清单(它必须覆盖 `Class.teacherId` / `Course.teacherId` / + `ClassTeacher.teacherId` / `Homework.createdBy` / `Notification.createdBy` 五项)。 + +模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准; +本文里的数字标注的是当前版本(1.1.2)的状态。 diff --git a/docs/dev/index.md b/docs/dev/index.md new file mode 100644 index 0000000..c4863e8 --- /dev/null +++ b/docs/dev/index.md @@ -0,0 +1,55 @@ +--- +title: 开发文档 +description: 班级小助手的架构、接口、数据模型、权限、动效层,以及验收与发版流程。 +--- + +# 开发文档 + +班级小助手是一个 **pnpm monorepo**,一份代码产出四个交付物: + +| 交付物 | 位置 | 形态 | +| -------------------- | ----------------------------- | --------------------------------------------------------------- | +| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` | +| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) | +| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生端 / 教室机器,含灵动岛浮窗) | +| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 | +| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) | + +核心链路: + +``` +教师在 Web 端发布内容 + │ REST API(JWT 鉴权) + ▼ + 后端服务(Express + Prisma) + │ 写库 + 按班级房间广播 + ▼ + Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI(含灵动岛) + +ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权) + ▲ │ + └──── 老师下发的提醒 ◄──────┘ +``` + +## 这一章有什么 + +| 页面 | 内容 | +| -------------------------------------------- | ------------------------------------------------ | +| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 | +| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) | +| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 | +| [数据模型](data-model.md) | 14 张表、迁移历史、跨库约定 | +| [角色与权限](permissions.md) | 权限矩阵与实现位置 | +| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 | +| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 | +| [灵动岛设计令牌](winisland-design-tokens.md) | 岛逐项的几何 / 配色 / 排版 token | +| [验收与测试](testing.md) | 五套验收脚本怎么跑、哪些用例是脆弱的 | +| [版本迭代与发布](release.md) | 改版本号、出安装包、发 Release 的完整流程 | + +## 仓库里另外两份文档 + +- **[完整参考](../reference.md)** —— 产品说明书 + 验收对照表(原 README 全文), + 功能行为的**权威来源**;仓库首页那份 README 是精简后的门面页,细节都在这里。 +- **[`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md)** —— + 写给 AI 编码代理的工程约定与本机踩坑记录,**改代码前必读**。 + 里面每一条硬约定都对应一次真实的翻车,比代码注释更值得先看一遍。 diff --git a/docs/dev/motion.md b/docs/dev/motion.md new file mode 100644 index 0000000..20c13a9 --- /dev/null +++ b/docs/dev/motion.md @@ -0,0 +1,140 @@ +--- +title: 动效层与主题 +description: beUI 动效令牌、CSS linear() 弹簧、运行时注入,以及深色主题的实现边界。 +--- + +# 动效层与主题 + +## 令牌的单一来源 + +两端的动画与过渡统一走一套自研的动效语言,**移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`**。 +beUI 的组件本身是 React(`motion/react` + Tailwind),用不到这里,**但它的动效语言与框架无关**。 + +令牌全部在 `packages/shared/src/motion.ts`: + +| 常量 | 取值 | 用在哪 | +| --------------- | --------------------- | ------------------------------ | +| `EASE_OUT` | `[0.16, 1, 0.3, 1]` | 绝大多数进出场 | +| `EASE_IN_OUT` | `[0.77, 0, 0.175, 1]` | 对称的来回动(抽屉、内容互换) | +| `EASE_DRAWER` | `[0.32, 0.72, 0, 1]` | 侧向抽屉 | +| `SPRING_PRESS` | 500 / 30 / 0.6 | 按钮、可点表面 | +| `SPRING_SWAP` | 460 / 30 / 0.55 | 控件内的内容互换 | +| `SPRING_PANEL` | 420 / 40 / 0.5 | 弹窗、抽屉、气泡进场 | +| `SPRING_LAYOUT` | 360 / 32 / 0.6 | 共享布局滑动(指示器) | +| `SPRING_MOUSE` | 200 / 15 / 0.3 | 光标跟随类装饰动效 | +| `SPRING_GLIDE` | 700 / 50 / 0.5 | 拖拽滑块与填充(临界阻尼) | + +弹簧语义与 `motion/react` 一致:`加速度 = (stiffness × 剩余距离 − damping × 当前速度) / mass`。 +这六组里 PRESS / SWAP / MOUSE 是欠阻尼(有过冲),PANEL / LAYOUT / GLIDE 是过阻尼。 + +### 为什么这个文件必须"纯常量 + 纯函数" + +`packages/shared` 会被后端(Express)一起消费,它的 tsconfig 是 `lib: ["ES2023"]`(**不含 DOM**)。 +所以这里不碰 `document`、不碰 `window`、也不依赖 vue。 + +DOM 注入与 Vue 组合式函数在两端各自的 `composables/motion.ts` 里 +(`useReducedMotion` / `useHoverCapable` / `useCountUp` / `useStaggerIn`)—— +与 `ScoreBarChart.vue` 一样,两端各一份是这个仓库的既有做法。 + +## 弹簧怎么变成 CSS + +不必给每个组件挂一个 rAF 循环:**用数值积分求出落定时间,再生成 CSS `linear()` 缓动字面量**, +弹簧就能纯 CSS 驱动。 + +几条实现约束: + +| 约束 | 原因 | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| 积分步长 1ms、`linear()` 取 60 个采样点 | 60 段肉眼已看不出折线,再密只是徒增样式表体积 | +| **落定时间不能用包络公式 `6/(ζ·ω0)`** | 它只对欠阻尼成立;过阻尼要用较慢的实极点 `ω0(ζ−√(ζ²−1))`,否则曲线会被截断在 0.87/0.95 上、元素永远差一截 | +| **`--ch-spring-X` 与 `--ch-spring-X-dur` 必须成对用** | `linear()` 是把 [0,1] 进度重映射,时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了 | +| `linear()` 的进度值可以 >1(过冲) | 只能用在不越界无害的属性上。**别用在 `opacity`** | + +### 运行时注入 + +两端的 `main.ts` 在 `app.mount()` **之前**把令牌写进 `:root` 的自定义属性 +(`--ch-ease-*` / `--ch-spring-*` / `--ch-dur-*`)。CSS 里只引用变量,**不抄数值 —— 抄了必漂**。 + +> **灵动岛是另一个渲染进程**,不共享主窗口的 `:root`:令牌要在 `src/island/main.ts` 里**再注入一次**。 +> 漏了的话 `var(--ch-ease-out)` 解析为空、**整条 `transition` 简写作废**,比不改还差(完全没有过渡)。 + +## 落地到界面上的效果 + +| 位置 | 动效 | +| --------------------- | ---------------------------------------------------------------------------------------------- | +| Element Plus 全站过渡 | 时长 300/200ms → 200/140ms,缓动换成 `EASE_OUT`(改写 `--el-transition-*` 变量,不动组件源码) | +| 弹窗 / 抽屉 / 浮层 | 面板走 `SPRING_PANEL` / `SPRING_SWAP`;**退出一律比入场快** | +| 按钮 | 按下 90ms 直落(跟手)、抬起走 `SPRING_PRESS` 弹回 | +| 侧栏激活项 | 单个"底片"在菜单项之间滑过去(`SPRING_LAYOUT`) | +| 页面内容 | `.page > *` 按块错峰浮现(30ms 一档,封顶 180ms) | +| 统计数字 | 0 → 实际值滚动(`useCountUp`) | +| 主题切换(两端) | View Transition API:从点击位置扩散的圆整页换肤 | +| 灵动岛 | **刻意只有单调缓动,不用弹簧** | + +### 灵动岛为什么不用弹簧 + +岛的形变由**主进程**缓动窗口尺寸,而冒烟断言「展开过程不震动」「收回过程不抖动」 +是**逐帧采样岛体几何**并要求单调的 —— 任何带过冲的弹簧都会直接判红。 + +所以岛用 `--ch-ease-out` 这类单调曲线,壳的物理仍归它自己的 `src/island/spring.ts`(WinIsland 移植)。 +**不要拿 beUI 的 `SPRING_*` 去换**。 + +另外岛的 `:hover` 规则必须关在 `@media (hover: hover) and (pointer: fine)` 里 —— +教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。 + +## 四条硬约束 + +| 约束 | 后果 | +| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **路由层不要包 ``**,尤其不要 `mode="out-in"` | ① 点菜单后一小段时间页面上还是上一页;② 退场判定依赖 `animationend`,而**窗口被遮挡时 Chromium 冻结 CSS 动画**,事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页 | +| **Vue 的 `` 一律显式给 `:duration`** | 同上,靠 `animationend` 判断的写法在窗口不可见时会永远停住 | +| **`position: fixed` 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画** | `getBoundingClientRect()` 包含 transform,而 `clientHeight` / `offsetHeight` 不含 —— 下移 6px 就可能溢出屏幕 | +| **弹窗面板的动画挂在 `.el-dialog` 上,不要挂在 `.el-overlay-dialog`** | Element Plus 的 `@opened` 按根元素(只有 200ms 淡入)的动画结束触发,而外层容器的弹簧要跑 463ms —— 在 `@opened` 里量尺寸会量到"还带着 `scale(0.97)` 的缩小版" | + +页面切换的动效因此改成 `.page > *` 错峰入场 —— 那是随组件挂载**同步**触发的,不依赖动画事件。 + +## 降低动态效果 + +两端各有一个 `@media (prefers-reduced-motion: reduce)` 全局块,把动画压到 **1ms**。 + +**是压短而不是 `transition: none`** —— 后者会让依赖 `transitionend` 的逻辑失灵。 +唯一的例外是**加载指示旋**:它改为放慢而不是停,停掉会让界面看起来像卡死。 + +## 主题 + +**两端机制完全一致**:在 `` 上挂 `.dark` 类,由各自的样式表覆盖**语义色令牌**, +Element Plus 的 `theme-chalk/dark/css-vars.css` 提供 `--el-*` 的深色默认。 +两端的 `main.ts` 都在挂载前落主题,深色用户首帧才不会看到"先白后黑"。 + +### 语义色令牌 + +| 令牌 | 用途 | +| ------------------------------------------------------ | -------- | +| `--ch-surface` / `-alt` / `-sunken` / `-hover` | 表面 | +| `--ch-text` / `-secondary` / `-muted` / `-placeholder` | 文字层级 | + +组件里**一律引用这些,不要写死颜色**。自查命令: + +```bash +grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer +``` + +除了**明确固定深色**的区域(全屏作业看板、登录页渐变、启动过渡页、灵动岛自身、 +以及 accent 渐变底上的白字)之外,都应该是变量。 + +### 两条纪律 + +**`html.dark` 只覆盖颜色,绝不覆盖圆角与动效令牌。** 两套主题的几何必须完全一致, +否则切换时会看到布局跳一下。 + +**改令牌时只改值,不要对样式表跑批量替换。** "把 `#303133` 全量替换成 `var(--ch-text)`" +会把令牌**定义本身**也换成 `--ch-text: var(--ch-text)` —— 自定义属性一旦自引用就整条失效、 +`getPropertyValue` 返回空串,全站文字颜色静默回落成继承色(浅色下看着"还行",深色下才露馅)。 + +Web 端还要一并同步 `` 与 `color-scheme` —— 后者管**原生控件** +(滚动条、日期选择器弹层、自动填充底色),只改 CSS 变量的话它们仍是浅色。 + +### 刻意固定深色的区域 + +登录页的深色渐变底、桌面端的启动过渡页与全屏作业看板、灵动岛自身、Web 管理端的侧边栏 +(本来就是深色)、以及深色底上的白色文字。**改它们会被当成回归。** diff --git a/docs/dev/permissions.md b/docs/dev/permissions.md new file mode 100644 index 0000000..404ce45 --- /dev/null +++ b/docs/dev/permissions.md @@ -0,0 +1,85 @@ +--- +title: 角色与权限 +description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条纪律。 +--- + +# 角色与权限 + +## 矩阵在哪 + +**唯一一份**在 `packages/shared/src/permissions.ts`: + +- 后端 `lib/access.ts` 复用它做断言; +- 两端前端用它隐藏入口; +- 验收脚本按它逐条断言。 + +所以不存在"前端一套后端一套"。改权限就改这一个文件(改完记得 `pnpm build:shared`)。 + +## 角色判定 + +班级内的角色由「账号角色 + 与该班级的关系」共同决定: + +| 角色 | 判定方式 | 可见范围 | +| ------------------ | ------------------------------ | -------------------------- | +| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | +| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | +| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 | +| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) | +| `NONE` | 与这个班没有任何关系 | 什么都看不到 | + +判定函数是 `resolveClassRole()`。 + +## 权限矩阵 + +| 操作 | 管理员 | 班主任 | 科任老师 | +| -------------------------------------------------- | :----: | :----------: | :------: | +| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | +| 分配班主任与科任老师 | ✅ | ❌ | ❌ | +| 学生名单管理(增删 / 导入) | ✅ | ❌ | ❌ | +| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | +| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | +| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | +| 布置作业 | ✅ | ✅ | ✅ | +| 叫人 | ✅ | ✅ | ✅ | +| 发布通知 | ✅ | ✅ | ✅ | +| 成绩录入 / 修改 / 导入(仅本班) | ✅ | ✅(仅本班) | ❌ | +| 班级密码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | + +## 实现要点 + +**路由级只做角色过滤**:`requireRole('ADMIN', 'TEACHER')`。 + +**班级维度的判断在服务层**:`src/lib/access.ts` 提供 + +``` +assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster +assertCanManageSchedule / assertCanPublishContent / assertCanManageGrades +``` + +**列表数据用 `resolveClassScope()` 收敛**(`classId in [...]`),单条接口直接断言; +越权一律 403。 + +**鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次用户与班级关系, +所以改了班级 / 角色会**立即生效**,不用等令牌过期。班级会话则回查 `Class` 表。 + +## 前端隐藏 ≠ 权限 + +前端的隐藏只是"别让人看见入口": + +- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 数据库管理」; +- 课表页的增删改按钮只对「管理员或本班班主任」显示; +- 成绩页的录入 / 删除按钮同理。 + +但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘), +而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。 + +> 新增视图时别忘了两处:路由的 `meta.roles` + 服务层对应 service 里的 `assertCanXxx`。 +> 只做其中一个都等于没做。 + +## 学生账号的两个反向约束 + +1. **学生不能登录**。`/auth/login` 遇到 `role=STUDENT` 直接 403; + 且**角色判定在密码校验之前** —— 否则"密码错(401) / 密码对但学生账号已停用(403)"的差异, + 会把学生账号密码变成可离线验证的预言机。 +2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404), + 新建学生也不设密码(`passwordHash` 是空串占位)。 diff --git a/docs/dev/realtime.md b/docs/dev/realtime.md new file mode 100644 index 0000000..62beda1 --- /dev/null +++ b/docs/dev/realtime.md @@ -0,0 +1,81 @@ +--- +title: 实时事件(Socket.IO) +description: 事件清单、房间规则,以及新增一个实时事件要同时改哪三处。 +--- + +# 实时事件(Socket.IO) + +## 连接 + +```ts +io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权 +``` + +客户端断线由 Socket.IO 自动重连。教室机器断线期间的变更,重连后靠各页重新拉取补齐 +(客户端在检测到服务器恢复时会触发 `onServerRecovered`);**灵动岛的上课时段暂存通知**由服务端保留、 +下课后补发,不依赖重连。 + +## 事件清单 + +事件名定义在 `@classhelper/shared` 的 `SOCKET_EVENTS` —— **不要手写字符串**。 + +| 事件 | 方向 | 载荷 | +| ------------------------------------- | --------------- | ------------------------------------------------ | +| `connected` | 服务端 → 客户端 | `{ userId, role, rooms }` | +| `notification:new` | 服务端 → 客户端 | `NotificationDto` | +| `homework:new` | 服务端 → 客户端 | `HomeworkDto` | +| `homework:updated` | 服务端 → 客户端 | `HomeworkDto & { deleted?: boolean }` | +| `homework:status` | 服务端 → 客户端 | `HomeworkStatusDto & { classId }` | +| `grade:updated` | 服务端 → 客户端 | `GradeDto` | +| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` | +| `class:updated` | 服务端 → 客户端 | `{ classId, action }` | +| `call:new` | 服务端 → 客户端 | 叫人消息(定向到 `user:{studentId}` / 班级设备) | +| `classisland:state` | 服务端 → 客户端 | `ClassIslandStateEvent`(教室现在上什么课) | +| `classisland:notification` | 服务端 → 客户端 | `ClassIslandNotificationEvent`(提醒已下发) | +| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) | + +## 房间规则 + +房间名定义在 `SOCKET_ROOMS`: + +| 房间 | 谁在里面 | +| --------------------- | --------------------------------- | +| `class:{classId}` | 这个班的所有在线端 | +| `user:{userId}` | 某个用户自己的连接 | +| `teacher:{teacherId}` | 某个教师 | +| `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) | +| `students` | 全部学生端连接 | +| `teachers` | 全部教师端连接 | + +服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 —— +**业务模块不直接依赖 Socket.IO 实例**,只依赖事件总线。 + +## 新增一个实时事件要改三处 + +``` +① 服务端 shared/src/constants.ts 的 SOCKET_EVENTS 加常量 + → business service 调 bus.emitToXxx(...) + → realtime/socket.ts 房间映射(如果需要新房间) +② Web 端 web-admin/src/stores/realtime.ts 订阅 +③ 客户端 desktop-client/src/renderer/stores/realtime.ts 订阅 + → 灵动岛也依赖它(renderer/island/bridge.ts) +``` + +**只改服务端不会报错**,只是前端静静地不更新 —— 所以这三处必须一起改。 +事件名一律取自共享常量,不要手写字符串(拼错一个字就是一个静默失效)。 + +## 事件到达之后发生什么 + +以通知为例: + +``` +服务端写库 → bus.emitToClass(classId, notification:new) + ├─► Web 端 stores/realtime.ts → 通知列表刷新 + └─► 客户端 stores/realtime.ts → 更新内存状态 + 写回 IndexedDB + └─► island/bridge.ts → pushNotificationToIsland() + └─► 主进程 IslandController 决定:隐藏 / 胶囊 / 详情 + └─► 灵动岛渲染进程 IslandApp.vue +``` + +上课时段的取舍(隐藏还是立即展开)由主进程的 `IslandController` 按当前上课状态决定, +见[通知与提醒](../app/notifications.md)与[灵动岛](../app/island.md)。 diff --git a/docs/dev/release.md b/docs/dev/release.md new file mode 100644 index 0000000..0e5a49d --- /dev/null +++ b/docs/dev/release.md @@ -0,0 +1,184 @@ +--- +title: 版本迭代与发布 +description: 改版本号、跑验收、打安装包、发 GitHub Release、发布文档站——一次完整发布的命令清单。 +--- + +# 版本迭代与发布 + +## 一条总原则 + +> **版本号按「发布」迭代,不按「提交」迭代。** + +日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时, +才走一次完整的版本迭代。理由很实际:安装包、`.cipx`、Docker 镜像 tag、文档站的版本徽标、 +更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。 + +| 算一次发布 | 不算 | +| --------------------------------------- | ---------------------------- | +| 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 | +| 要给别人一份新安装包 | 只在自己机器上跑 | +| 插件单独发补丁(只动插件第 4 段版本号) | — | + +## 版本号写在哪(10 处) + +同一个版本号出现在 10 个文件里,**手工改必然漏一处**,而漏掉的那处不会报错: + +| 文件 | 内容 | +| ---------------------------------------------------------------- | ------------------------------- | +| `package.json` | 单一来源(服务端运行时读它) | +| `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 | +| `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 | +| `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 | +| `packages/classisland-plugin/manifest.yml` | 插件清单(四段:`x.y.z.0`) | +| `packages/classisland-plugin/src/Services/BridgeService.cs` | 插件上报的 `PluginVersion` 常量 | + +所以**改版本号的唯一入口是脚本**: + +```bash +pnpm version:check # 逐处列出并比对,不一致 exit 1 +pnpm version:bump patch # 1.1.2 → 1.1.3 +pnpm version:bump minor # 1.1.2 → 1.2.0 +pnpm version:bump major # 1.1.2 → 2.0.0 +pnpm version:check # 再确认一次 +``` + +脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。 + +> 插件版本是 **四段** `x.y.z.0`:前三段跟产品版本走,第 4 段留给"只改插件、不动产品版本"的补丁发布。 +> `--bump` / `--set` 会把第 4 段归零;要单独发插件补丁就手工改那两处(清单 + `BridgeService.cs`)。 + +## 一次完整的发布 + +下面这一串命令就是全部流程,逐条照抄即可。 + +### 1. 改版本号 + +```bash +pnpm version:check +pnpm version:bump patch +``` + +### 2. 本地体检 + +```bash +pnpm lint +pnpm typecheck +pnpm check:icons +pnpm docs:check # 文档站构建(警告即失败) +``` + +### 3. 跑验收 + +```bash +pnpm dev:server # 另开一个终端跑着 + +pnpm verify:e2e +pnpm verify:classisland +pnpm verify:classisland-plugin + +pnpm build:desktop && pnpm verify:desktop +pnpm build && pnpm verify:web +``` + +哪一项红了先看[验收与测试](testing.md)的「失败时先别改代码」—— +里面列了几个已知脆弱的用例,它们红不代表代码坏了。 + +### 4. 打安装包 + +```bash +pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin +``` + +产物落在 `releases/<组件>/<版本>/`:`安装包/`、`免安装/`、`构建中间/`。 +打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过): + +```bash +pnpm verify:packaged --exe "releases/client/<版本>/免安装/班级小助手.exe" +``` + +> Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 —— +> 它由 GitHub Actions 出,见第 7 步。 + +### 5. 生成跨组件哈希清单 + +`releases/SHA256SUMS-<版本>.txt` 是**人读的**(表头 + 体积 + 说明),由发布流程维护, +本地只写 Windows 与插件那几行: + +```bash +cd releases/client/<版本>/安装包 && sha256sum *.exe > ../../../SHA256SUMS-<版本>.txt # 按实际文件名整理 +``` + +(Linux 那一行由 CI 在第 7 步里**合并**进去,不要手工写。) + +### 6. 提交并推上去 + +```bash +git add -A +git commit -m "chore(release): 1.1.3" +git push origin master +``` + +提交信息用 Conventional Commits + 模块 scope,描述写中文,可多 scope,例如 +`fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`。 + +### 7. 开 Release + +```bash +gh release create v1.1.3 --title "v1.1.3" --generate-notes + +gh release upload v1.1.3 \ + releases/client/<版本>/安装包/*.exe \ + releases/classisland-plugin/<版本>/安装包/*.cipx \ + releases/server/<版本>/安装包/*.exe \ + releases/SHA256SUMS-<版本>.txt --clobber +``` + +`gh release create` 会**创建 tag 并推送**,这一步会自动触发 +[`release-linux-server.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/release-linux-server.yml) +构建 Linux 包(ubuntu runner)。它跑完之后会: + +1. 把 `classhelper-server-linux-x64-<版本>.tar.gz` 与 `.sha256` 挂到 Release 上; +2. **把 Linux 那一行合并进** `SHA256SUMS-<版本>.txt`(保留 Windows / 插件那几行)。 + +> **顺序很重要**:Linux workflow 是在最后一步才去合并 SHA256SUMS 的, +> 所以第 5 步那份清单必须**先上传**。万一手速反了(workflow 先跑完、你后上传), +> 重新跑一次 `gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true` 就能把那一行补回来。 + +想手动触发(比如只需要重出 Linux 包): + +```bash +gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true +``` + +### 8. 文档站 + +**不用做任何事。** 推送 master 时只要 `docs/**` 或 docs 的 workflow 变过, +[`docs.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/docs.yml) +会自动用 docfx 构建并部署到 GitHub Pages。 + +文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了。 + +### 9. 检查 + +- [ ] Release 页面上四个组件都在,`SHA256SUMS-<版本>.txt` 里 Windows / 插件 / Linux 三种行齐全; +- [ ] 文档站能打开,顶栏版本号是新版; +- [ ] 装一台机器试一遍(或至少跑 `pnpm verify:packaged`)。 + +## 回滚 + +发布出去之后发现严重问题: + +| 情形 | 做法 | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 只是文档 / Release 说明写错 | 直接改 Release 说明或重发文档站(`gh workflow run docs.yml`) | +| 二进制有问题 | 删掉 Release 与 tag(`gh release delete v1.1.3 --cleanup-tag`),修完重新发;**不要**拿同一个版本号发第二次内容不同的包 | +| 用户已经装了 | 发一个**新版本号**(`pnpm version:bump patch`)修复,不要原地改 | + +> 已经发出去的版本号**不要复用**:客户端把它记进「忽略此版本」,服务端与 Web 端各有 30 分钟缓存, +> 同号换内容会让"检查更新"永久失灵。 + +## 相关 + +- 文档站本身怎么写的 → [架构总览的目录结构](architecture.md) +- 验收脚本与脆弱用例 → [验收与测试](testing.md) +- 各形态升级的**用户侧**步骤 → [更新与升级](../management/upgrade.md) diff --git a/docs/dev/testing.md b/docs/dev/testing.md new file mode 100644 index 0000000..9675cae --- /dev/null +++ b/docs/dev/testing.md @@ -0,0 +1,99 @@ +--- +title: 验收与测试 +description: 五套验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。 +--- + +# 验收与测试 + +这个仓库的验收方式是**跑真东西**:真的起后端、真的开 Electron 窗口、真的点击菜单、 +真的发一条通知。不是单元测试,是端到端回归。 + +## 五套脚本 + +| 命令 | 覆盖 | 需要什么 | +| -------------------------------- | -------------------------------------------------------------------------------- | --------------------------- | +| `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** | +| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 | +| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 | +| `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 | +| `pnpm verify:classisland` | 设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理 | 后端已启动 | +| `pnpm verify:classisland-plugin` | 插件静态契约(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) | 不需要 .NET,也不需要后端 | + +条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`),**文档里不写死容易过期的总数** —— +每次跑完看脚本输出。 + +另外三套是"门禁"而不是验收:`pnpm typecheck`、`pnpm lint`、`pnpm check:icons`。 + +### 一次完整的交付前检查 + +```bash +pnpm lint # ESLint,0 error 是底线 +pnpm typecheck # 全仓库类型检查(含 vue-tsc) + +pnpm dev:server # 另开一个终端 +pnpm verify:e2e +pnpm verify:classisland +pnpm verify:classisland-plugin + +pnpm build:desktop && pnpm verify:desktop +pnpm build && pnpm verify:web +``` + +改动涉及打包/交付形态时,再加 `pnpm dist:all` 与 +`pnpm verify:packaged --exe "<客户端路径>"`(**开发产物通过不等于用户机器上的副本通过**)。 + +## 失败时先别改代码 + +下面这些用例**不是回归**,遇到时先看条件。完整清单在 `AGENTS.md` 的「已知脆弱用例」表。 + +| 用例 | 触发条件 | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `verify:desktop`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) | +| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 | +| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 | +| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 | +| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 | +| 岛交互 / 岛动画 / 岛截图留档 | **AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** | + +## 大面积报「登录失败 / 401」时 + +先核对本机 `teacher1` 的密码 —— 本机实测可能被改成了 `123456`,不是种子默认的 `teacher123` +(原因是「修改密码」弹窗留空会落到默认初始密码)。**处理方式是用环境变量传实际凭据,不要去改数据。** + +两个演示账号的口令**不一定一起漂**,所以不能简单地全局替换。 + +| 脚本 | 覆盖凭据的方式 | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` | +| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) | +| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) | +| `verify:e2e` | **不支持环境变量**,需用临时副本替换(且副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败) | + +## 冒烟会真的动你的屏幕 + +`verify:web` / `verify:desktop` / `verify:packaged` 会**真的弹窗口**、真的发通知、真的移动鼠标: + +- 跑的时候别动键鼠; +- 验证实例的灵动岛是**洋红主题色**(`#e91e8c`),一眼可以与你自己客户端那个区分开。 + 两条反馈里的"屏幕上出现了第二个灵动岛""点它反应不对"都源于这个叠影; +- 冒烟会往演示班发几条「张老师 · _自检_」通知,用完即删;中途被 kill 会留下残条。 + +## 截图留档 + +回归测试顺手把截图存到 `docs/screenshots/`(island / client / web-mobile / classisland 四组), +文档站里用的就是这些图。要重新采集: + +```bash +pnpm dev:server +ISLAND_SHOTS_DIR=F:/ClassHelper/.cache/island-shots \ +ELECTRON_CLIENT_SHOTS_DIR=F:/ClassHelper/.cache/client-shots \ +ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \ +ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \ + node packages/desktop-client/scripts/smoke.mjs +``` + +官网的实机图则用 `website/tools/capture-shots.mjs`(采集步骤见 `README.md` §7)。 + +> 采集脚本会把窗口真的弹在屏幕上。客户端留档会**临时停掉 CSS 动画**再截图 —— +> 窗口被遮挡时 Chromium 冻结动画,而页面入场动画的起始帧是 `opacity: 0`, +> 不停掉的话截出来是**整片空白**(DOM 里文本齐全,只有图是空的)。 diff --git a/docs/dev/toc.yml b/docs/dev/toc.yml new file mode 100644 index 0000000..65d17ef --- /dev/null +++ b/docs/dev/toc.yml @@ -0,0 +1,22 @@ +- name: 开发文档 + href: index.md +- name: 架构总览 + href: architecture.md +- name: REST API + href: api.md +- name: 实时事件(Socket.IO) + href: realtime.md +- name: 数据模型 + href: data-model.md +- name: 角色与权限 + href: permissions.md +- name: 动效层与主题 + href: motion.md +- name: ClassIsland 插件 + href: classisland-plugin.md +- name: 灵动岛设计令牌 + href: winisland-design-tokens.md +- name: 验收与测试 + href: testing.md +- name: 版本迭代与发布 + href: release.md diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 0000000..f7baae5 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "build": { + "content": [ + { + "files": ["**/*.{md,yml}"], + "exclude": ["_site/**", "obj/**", "templates/**", "node_modules/**", "404.html"] + } + ], + "resource": [ + { + "files": ["screenshots/**", "images/**"], + "exclude": ["**/*.md"] + } + ], + "output": "_site", + "template": ["default", "modern", "templates/classhelper"], + "globalMetadata": { + "_appName": "班级小助手", + "_appTitle": "班级小助手文档", + "_appLogoPath": "logo.png", + "_appLogoUrl": "", + "_appFaviconPath": "favicon.png", + "_enableSearch": true, + "_disableContribution": false, + "_gitContribute": { + "repo": "https://github.com/LoveChengke/classhelper", + "branch": "master" + }, + "_appFooter": "
班级小助手(Class Helper)· 文档·GitHub 仓库·下载与更新日志·构建于 docfx
" + }, + "sitemap": { + "baseUrl": "https://lovechengke.github.io/ClassHelper/", + "changefreq": "weekly", + "priority": 0.5 + } + } +} diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..1fe23c7 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,155 @@ +--- +title: 常见问题 +description: 装不上、连不上、改了不生效——最常被问到的那些问题。 +--- + +# 常见问题 + +## 安装与启动 + +### 客户端装不上 / 便捷版双击没反应 / 应用一闪就没了 + +**现象**:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志 +(退出码 `0x80000003`)。 + +**原因**:交付物被打了 **Low 完整性标签**。带 Low 标签的 exe 会被 Windows 降级运行: +写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的子进程(完整性更低)也读不到这些文件而秒崩。 + +**处置**:从 [Releases](https://github.com/LoveChengke/classhelper/releases) 下载的官方包不受影响 +(打包脚本收尾会自动修正标签)。自己构建时如果碰到,用: + +```cmd +icacls <产物目录> /setintegritylevel Medium /T /C +``` + +判据是 `icacls | findstr Mandatory` 里出现 `Low`。 +另外**别把安装测试装进仓库目录里** —— 在那里又会继承 Low。 + +### 双击客户端"点了没反应" + +**关窗只是隐藏到托盘,进程还在。** 再双击 EXE 会被单实例锁秒退(看起来像没反应), +安装包也会因为"应用正在运行"装不上。先**从托盘右键退出**。 + +### 安装包装不上,说应用正在运行 + +同上。托盘 → 退出班级小助手。 + +## 连接与登录 + +### 后端明明在跑,`Invoke-WebRequest` 却返回别人的 404 + +**系统代理拦住了 `127.0.0.1`**。自测请加 `-NoProxy`(`curl.exe` 用 `--noproxy '*'`)。 +项目内的 Node 脚本直连,不受影响。 + +### 客户端连不上服务器 + +1. 服务器地址要填**局域网 IP**(`http://192.168.1.10:4000`),不要写 `localhost`(那是客户端自己); +2. 服务器防火墙放行 **4000**; +3. 服务器上如果开了系统代理,内网地址可能被代理接管 —— 浏览器与客户端**不会**自动绕过 + (只有 ClassIsland 插件对本地 / 内网做了强制直连); +4. 在服务器本机 `curl.exe --noproxy '*' http://127.0.0.1:4000/healthz` 是不是 200? + +### 页面能打开,但接口全 401 + +令牌过期,或者 `.env` 里的 `JWT_SECRET` 被换过(换过之后所有人的令牌都失效)。重新登录即可。 + +### 提示「无法连接 GitHub」/ 检查更新查不到 + +**这是设计内的结果**,不是错误。机房与教室机器常常只有内网,请求会超时; +界面按"暂时查不到更新"呈现。失败结果同样进缓存,不会每点一次都白等一个超时。 + +### 反向代理后面所有请求都被限流 + +反向代理没设 `TRUST_PROXY=1`,导致所有请求都被算作代理那一个 IP。 + +## 功能行为 + +### 上课时段发的通知学生看不到 + +**这是设计行为。** 上课时段普通通知只进队列、不打断课堂,**下课自动补弹**。 +紧急通知与叫人则立刻弹。规则见[通知与提醒](app/notifications.md)。 + +### 老师说发了通知,教室机器上什么都没弹 + +按顺序排查: + +1. 客户端顶栏的实时通道是不是"已连接"? +2. 现在是上课时段吗?(同上,是设计行为) +3. 教室客户端的「设置 → 提醒」是不是设成了"只在 ClassIsland 上弹"?那样客户端只进通知中心。 +4. 反向代理放行 `/socket.io/` 的 WebSocket 升级了吗? + +### 屏幕上出现了第二个灵动岛,点它反应不对 + +那是**自动化验证实例**的岛 —— `pnpm verify:desktop` / `verify:packaged` 会真的在屏幕上放一个, +而且它归脚本控制,会自己展开 / 收起 / 消失。验证实例的岛是**洋红主题色**,一眼可辨。 + +### 灵动岛上的文字往右跳一下再缩回 + +那是早期的布局问题(胶囊内容跟着卡片重排),已经修掉了。如果你的版本还有, +说明用的是旧产物 —— 参考[更新与升级](management/upgrade.md)。 + +### 灵动岛调"左右边距"没反应 + +**左右边距只在「左 / 右停靠」时生效。** 默认是「顶部居中」,水平锚点就是屏幕中线, +这时调它不会有任何变化 —— 设置页会把那个滑块置灰并写明原因。 + +### 触摸屏上点不开灵动岛 + +触摸屏(希沃白板等)会自动走「触摸模式」,正常情况不用手动开。 +如果确实点不开,到「设置 → 关于」看一眼诊断信息,并把客户端版本反馈出来。 + +### 教室机器上刚录的作业又要弹一次? + +**本机录入的作业不会上岛**(自己通知自己没意义)。如果弹了,说明这条不是这台机器录的。 + +### 学生登录不了 + +**学生没有个人账号。** 学生端统一用**班级码 + 班级密码**登录(见[装学生端](get-started/client.md))。 +用学生用户名去登录一定会失败,这是设计。 + +### 客户端里找不到"修改密码" + +**是刻意移除的。** 防止教室机器上误改班级密码后,别的教室机器被锁在门外。 +班级密码由管理员在 Web 端「班级管理」维护;教师 / 管理员本人密码在 Web 端顶栏头像菜单里改。 + +## 开发与运维 + +### `pnpm db:seed` 报 `Cannot find module '.../@classhelper/shared/dist/index.js'` + +`packages/shared` 还没构建(它产出 `dist`,三端与 seed 都依赖)。先 `pnpm build:shared`。 + +### 根目录跑 `pnpm db:deploy` 说不存在 + +根 `package.json` 没有这个脚本。正确写法: + +```bash +pnpm --filter @classhelper/server db:deploy +``` + +### 后端改了代码不生效 + +`tsx src/index.ts` **不热载**。用 `pnpm dev:server`(watch),或重启服务端。 +**切换数据库之后必须重启**才会连上新库。 + +### 上传表格报「服务器内部错误:request entity too large」 + +表格导入的文件上限是 **8MB**(请求体 12MB),超限应返回 **413 `IMPORT_TOO_LARGE`** 与中文提示。 +如果你看到的是英文兜底错误,说明这个异常没被映射 —— 那是 bug,不是你的文件太大。 + +### 切了 MySQL 之后重启又连回旧库 + +服务端安装形态里,安装目录的 `.env` **必须是指向 `/etc/classhelper/config.env` 的软链**。 +两处不是同一个文件时,一键切换会"看起来成功、重启后又连回旧库"。 +Linux 上跑 `sudo classhelper doctor` 有专门一条断言盯着这个软链。 + +### SQLite 报 `database is locked` + +同一个数据库文件只允许一个服务实例。确认没有重复启动;需要并发就换 MySQL。 + +## 还没解决? + +- 开发环境的坑(命令、代理与 TLS、完整性标签、ClassIsland 路径与日志编码……) + 都记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里; +- 功能行为的权威说明在[完整参考](reference.md); +- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个, + 附上版本号(Web 端顶栏 / 客户端「设置 → 关于」里都有)和日志。 diff --git a/docs/get-started/classisland.md b/docs/get-started/classisland.md new file mode 100644 index 0000000..b86b832 --- /dev/null +++ b/docs/get-started/classisland.md @@ -0,0 +1,120 @@ +--- +title: 接入 ClassIsland +description: 把教室里的 ClassIsland 接上班级小助手:下课表上报、大屏提醒下发、课表镜像三个方向。 +--- + +# 接入 ClassIsland + +课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。 +它跟班级小助手管的事情不重叠,接起来之后两边各拿各的好处: + +| 方向 | 做什么 | 老师在哪看 | +| ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- | +| ClassIsland → 班级小助手 | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 | +| 班级小助手 → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 | +| (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 | + +> 没接 ClassIsland 也能用班级小助手,只是教室里少一个大屏播报的出口。**这一步是可选的。** + +## 前置:ClassIsland 装好了 + +本插件按 ClassIsland 插件 API 2.0 编写,ClassIsland 2.0.x 与 2.1.0.x 都能加载。 +先确认教室机器上 ClassIsland 本身能正常显示课表。 + +## 第一步:在 Web 端签发设备令牌 + +**ClassIsland 联动**页(管理员 / 本班班主任)→「接入新设备」→ 选班级 → 生成令牌。 + +令牌形如 `chci_xxxxxxxx…`,**明文只显示这一次**:服务端只存它的 sha256 与一个前缀提示 +(`chci_80fa1c…`),用来在设备列表里人眼区分。丢了就重置一个。 + +## 第二步:在教室机器上装插件并填令牌 + +### 装插件 + +从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取 +`ClassHelper.ClassIslandPlugin.cipx`: + +- **推荐**:在 ClassIsland 里直接安装这个 `.cipx`(插件页有「从文件安装」)。 +- 或者手工解压:把包里的 `ClassHelper.ClassIslandPlugin/` 目录整个丢进 + `\Plugins\`,然后重启 ClassIsland。 + +> **ClassIsland 运行时会锁住 DLL**。要覆盖升级插件,必须先完全退出 ClassIsland 再覆盖, +> 否则写入会被拒。插件配置在 `Config\Plugins\<插件 id>\Settings.json`, +> 里面存着服务器地址与设备令牌 —— **升级时别覆盖这个文件**。 +> 另外改配置要在 ClassIsland 退出时改,运行中改会被内存里的副本回写覆盖。 + +### 填令牌 + +ClassIsland 的**设置 → 班级小助手联动**里填: + +| 设置项 | 说明 | +| ---------------------------- | --------------------------------------------------------------- | +| 后端地址 | `http://<服务器IP>:4000` | +| 设备令牌 | 上一步生成的 `chci_…` | +| 上报课表到小助手 | 打开后才会把 ClassIsland 的课表同步过来 | +| 接收小助手提醒 | 关掉就收不到老师发的提醒 | +| 提醒轮询间隔 | 默认 10 秒(可调到 5 秒)。上报本身就是心跳,轮询只是顺带取提醒 | +| 把班级课表镜像回 ClassIsland | 见下面「镜像」一节 | + +填完点**「立即上报」**。 + +## 第三步:验证 + +回到 Web 端的「ClassIsland 联动」页,应该出现设备卡片和「在线」标记,实时状态卡上会显示这台机器 +现在上什么课。到这一步链路就通了。 + +如果一直离线:先看插件的日志,再看服务端能不能从教室机器访问到(**别让系统代理拦住内网地址** —— +插件对本地 / 内网做了强制直连,但填错地址一样连不上)。 + +## 提醒弹在哪个端:由教室客户端自己选 + +教室机器的**客户端**「设置 → 提醒」里选,选择会同步到班级记录: + +| 选择 | ClassHelper 客户端 | ClassIsland | +| ----------------------------------------- | ------------------------------ | ----------- | +| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 | +| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 | +| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 | + +规则只有一条,**任何入口都遵守**:通知发布页、叫人、联动页的「下发提醒」三个入口共用同一份推送实现, +不存在"某个页面发的通知不联动"。 + +## 两个方向各需要哪些开关 + +| 要同步的内容 | 需要打开 | +| -------------------------------- | ------------------------------------------------------------------------------- | +| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 | +| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到班级小助手」 | +| 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 | + +## 上课时段:两边同一套规则 + +老师在**上课时段**发通知时,教室里的两个端遵守同一条规则,否则会出现"学生在客户端看不到、 +却在 ClassIsland 上被打断"的怪事: + +| 提醒类型 | 上课时段 | 下课 / 放学 | +| ------------------------------------------------------ | ---------------------------- | ----------- | +| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹**(两边都先收着) | 自动补弹 | +| 主动通知:**紧急提醒**、**叫人**(`kind=call`) | **立刻弹** | 立刻弹 | + +ClassIsland 侧的暂存由插件实现:它订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执, +所以服务端会一直保留着待提醒,教室机器断网也不丢。 + +## 上报课表是"双向对齐"的 + +插件每次全量上报时,服务端只清理**「来源是 ClassIsland 且这次没再上报」**的课表行。于是: + +- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步; +- 老师在小助手这边手排的课 → 一律不删。 + +## 镜像:把小助手的课表写回 ClassIsland + +开启后,插件会新建一份名为 `班级小助手-<班级名>` 的档案课表,**不动老师原有的课表**。 + +镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。 +这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。 + +细节与实现见[ClassIsland 插件](../dev/classisland-plugin.md)。 + +下一步:[发布第一条内容](first-content.md)。 diff --git a/docs/get-started/client.md b/docs/get-started/client.md new file mode 100644 index 0000000..bfb7e41 --- /dev/null +++ b/docs/get-started/client.md @@ -0,0 +1,89 @@ +--- +title: 装学生端 +description: 在教室机器上安装班级小助手桌面客户端,用班级码登录,并第一次跑通实时通知。 +--- + +# 装学生端 + +桌面客户端装在**教室的那台机器**上(教室大屏、讲台电脑)。它是学生看课表作业的地方, +也是「消息到达的第一现场」—— 灵动岛就长在这一端。 + +## 1. 下载与安装 + +从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取: + +| 产物 | 用途 | +| ------------------------------------ | ------------------------------------------ | +| `班级小助手-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 | +| `班级小助手-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 | + +要自己打: + +```bash +pnpm dist:win # 安装包 + 便携版;只要免安装目录用 pnpm dist:dir +``` + +> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了 +> Low 完整性标签。打包脚本收尾已自动修正;从 Release 下载的包不受影响。 +> 另外:关窗是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘退出。 + +## 2. 登录 + +打开客户端 → 填三项: + +| 字段 | 填什么 | +| ---------- | ------------------------------------------------------------- | +| 服务器地址 | `http://<服务器IP>:4000`(局域网用内网 IP,别用 `localhost`) | +| 班级码 | 管理员在「班级管理」里给这个班设的码,例如 `G203` | +| 班级密码 | 建班级时设的密码 | + +旁边的「测试连接」可以先确认网络通。**已登录过但服务器暂时不可达时**,登录页会给一个「离线进入」—— +进去后看到的是上次缓存的课表 / 作业 / 通知(顶部会标「离线缓存」),网络恢复后自动刷新。 + +> 客户端**没有改密码入口**,这是刻意的:教室机器上误改班级密码会把别的机器锁在门外。 +> 班级密码由管理员在 Web 端「班级管理 → 修改班级账号」维护。 + +## 3. 首次启动的六步向导 + +第一次启动会在登录页上自动弹一个向导:欢迎 → 连接服务器 → 登录班级 → 登录之后(主界面与离线缓存)→ +灵动岛提醒 → 完成。走完或跳过都会记为已完成,之后不再自动弹。 + +想重看:登录页的**「查看使用引导」**,或进去之后 **设置 → 通用 → 使用引导**。 + +## 4. 认一认主界面 + +左侧是四个一级项(**课表 / 作业 / 通知 / 成绩**)加上一个可展开的**设置**分组, +顶部汉堡按钮把整栏收成 64px 的图标导轨(折叠状态会记住)。「通知」项上有未读红点。 + +| 页面 | 一句话 | +| ---- | -------------------------------------------------------------- | +| 课表 | 周视图 + 「今天」时间轴(大时钟 / 正在上什么 / 下一节) | +| 作业 | 看板模式(按科目卡片、可全屏)/ 列表模式;教室机器还能录入作业 | +| 通知 | 未读红点、优先级标签、点开自动标为已读 | +| 成绩 | 班级账号看到的是全班总览(表格 + 柱状图 + 等级分布) | +| 设置 | 通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,六个子页 | + +细节见[应用帮助](../app/index.md)。 + +## 5. 在教室机器上把它调成"能看"的样子 + +教室大屏的观看距离和角度跟办公桌不一样,装完建议顺手做三件事: + +1. **开全屏作业看板** —— 作业页「全屏看板」,按屏幕尺寸自动排版,整个屏幕刚好放得下、不用滚动。 +2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。 + 讲台机器上通常把字号调大一点、位置放到顶部居中。 + 触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。 +3. **选提醒弹在哪端** —— 设置 → 提醒:`ClassHelper 与 ClassIsland 都弹` / 只在客户端 / 只在 ClassIsland。 + 这个选择会同步到班级记录,服务端据此决定要不要推 ClassIsland。 + +## 6. 确认实时链路是通的 + +在 Web 管理端给这个班发一条普通通知,教室机器上应该几秒内出现灵动岛胶囊。如果没有: + +- 客户端顶栏的实时通道状态是不是"已连接"? +- 现在是上课时段吗?上课时段的普通通知**只进队列、下课才弹**,这是设计行为,不是故障。 +- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗? + +排查清单见[常见问题](../faq.md)。 + +下一步(可选):[接入 ClassIsland](classisland.md)。 diff --git a/docs/get-started/first-content.md b/docs/get-started/first-content.md new file mode 100644 index 0000000..3fc65d7 --- /dev/null +++ b/docs/get-started/first-content.md @@ -0,0 +1,78 @@ +--- +title: 发布第一条内容 +description: 发通知、布置作业、叫人、录成绩——把班级小助手真正用起来的第一轮操作。 +--- + +# 发布第一条内容 + +前面几步做完,系统就完整了。这一页走一遍四件最常用的事,每件都点出**它会不会弹、弹在哪个端**。 + +## 发一条通知 + +**通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。 + +| 优先级 | 学生端表现 | +| ------ | -------------------------------------------------------------------------- | +| 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 | +| 重要 | 同上,卡片上带优先级标签 | +| 紧急 | **无论是否上课都立即展开**(卡片内部红色呼吸光晕 + 紧急角标),45 秒后收起 | + +**上课时段发紧急通知会被拦一次。** 服务端返回 `409 URGENT_DURING_CLASS`,前端弹一个全屏二次确认, +上面写着"现在为上课时间段……"、正在上的那节课,还有待发布的标题;确认按钮带 **3 秒倒计时**, +倒计时结束前点不动。确认之后才真的发出去。 + +这个拦截是**服务端做的**,绕过界面直接调接口一样会被拦 —— 它是产品行为,不是前端提醒。 + +## 布置一份作业 + +**作业发布** → 选班级 / 科目 → 填标题与要求 → 选**所属日期** → 发布。 + +> 作业按**所属日期**归类,不是按录入时间。晚上 8 点留的作业算今天。 +> 老师可以给今天、明天甚至上周补录一份作业,客户端按日期查得到。 + +附件支持站内相对路径或 `http(s)://` 链接(其他 scheme 服务端会拒,避免被当成脚本执行)。 + +学生端那边默认是**看板模式**:按科目分卡片、每条作业完整显示要求、整个看板缩放到刚好铺满屏幕; +老师也可以在教室机器上直接**录入作业**、勾选**未交名单**(教室机器用的是班级账号)。 + +## 叫一位同学 + +**通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。 + +| 级别 | 学生端 | +| -------- | ------------------------------------------------------------------ | +| 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 | +| 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 | + +文案是「请 XXX 同学找 XXX 老师」,卡片上是「收到」按钮(复用通知的已读机制)。 + +> 学生管理页是管理员专属,但班主任与科任老师同样需要叫人,所以给老师留的入口在「通知发布 → 叫人」。 + +## 录一次成绩 + +**成绩录入** → 选班级与考试名称 → 单条录入 / 批量录入 / 导入表格。 + +- **科目**下拉同样是全校统一目录,选一个该班还没有的科目会自动建课。 +- 导入表格支持 `.xlsx / .xls / .csv / .tsv`,列名按同义词自动映射(「学号→学生用户名」「得分→分数」), + 也可以手动改。**文件上限 8MB**,超了会返回明确的 413 而不是一句英文的内部错误。 +- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。 + +学生端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。 + +## 在教室大屏上播一条提醒(需要 ClassIsland) + +**ClassIsland 联动 → 下发提醒** → 选班级、填标题内容、设置显示时长与语音朗读 → 下发。 + +它会**落库**再推给教室。教室机器断网也不丢:插件重连后会拉取还没确认的那条, +立刻弹出来,弹完回执,不会重复弹。 + +如果教室客户端把「通知显示位置」设成了"只在 ClassHelper 客户端显示",这一步会回一句 +「该班教室已设置为只在 ClassHelper 客户端显示」—— 那是设计行为,不是报错。 + +--- + +到这里你已经把整套系统跑通了。接下来: + +- 每一项功能的完整行为 → [应用帮助](../app/index.md) +- 服务器怎么维护 → [管理运维](../management/index.md) +- 遇到问题 → [常见问题](../faq.md) diff --git a/docs/get-started/index.md b/docs/get-started/index.md new file mode 100644 index 0000000..4fea832 --- /dev/null +++ b/docs/get-started/index.md @@ -0,0 +1,40 @@ +--- +title: 快速上手 +description: 从零把班级小助手跑起来:装服务端、认管理端、建班级排课、装学生端、接 ClassIsland、发第一条内容。 +--- + +# 快速上手 + +班级小助手不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**: + +| 组成部分 | 装在哪 | 谁用 | 必须先装? | +| ------------------------ | --------------------------------------- | -------------------------- | ---------------- | +| **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 | +| **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 | +| **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 | +| **ClassIsland 联动插件** | 教室那台装了 ClassIsland 的机器 | 教室大屏播报 | 可选 | + +按顺序走下面这几页就行,全程大约半小时(不含下载时间): + +1. [系统要求](requirements.md) —— 先确认机器和网络够用 +2. [安装服务端](server.md) —— Windows 安装包 / Linux 一键脚本 / Docker / 源码跑 +3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面 +4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提 +5. [装学生端](client.md) —— 教室机器用班级码登录 +6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选) +7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩 + +## 只想先看看它长什么样? + +不用装任何东西也能看出来它在干什么: + +- **看界面**:本页文档里各处嵌的都是实机截图,比如[灵动岛](../app/island.md)、[课表](../app/schedules.md)。 +- **本地跑一套演示数据**:装好 Node 20.19+ 与 pnpm 之后, + `pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本机拉起一套带演示班级的后端与 + Web 管理端(详见[安装服务端](server.md#从源码跑开发模式))。 + +## 装完之后呢 + +- 日常怎么用 → [应用帮助](../app/index.md) +- 服务器怎么维护(备份、换库、升级) → [管理运维](../management/index.md) +- 出问题了 → [常见问题](../faq.md) diff --git a/docs/get-started/requirements.md b/docs/get-started/requirements.md new file mode 100644 index 0000000..6f40134 --- /dev/null +++ b/docs/get-started/requirements.md @@ -0,0 +1,76 @@ +--- +title: 系统要求 +description: 班级小助手服务端、Web 管理端、桌面客户端的运行环境与网络要求。 +--- + +# 系统要求 + +## 服务端 + +服务端的安装包**自带 Node 运行时**,目标机器不需要另外装 Node.js,也不需要数据库软件 +(默认用内嵌的 SQLite)。 + +| 项目 | 要求 | +| -------- | --------------------------------------------------------------------------- | +| 操作系统 | Windows 10 / 11 / Server 2016+(x64);Linux(glibc 2.28+,见下);Docker | +| 内存 | 512 MB 起步,2 GB 以上更稳(Node + SQLite 常驻约 120–200 MB) | +| 磁盘 | 程序约 340 MB(免安装目录)/ 安装后约 400 MB;数据按班级量增长,通常几十 MB | +| CPU | 任意 x64;不需要独显 | + +**Linux 的 glibc 要求**:内置的 Node 官方 linux-x64 构建要求 glibc ≥ 2.28。 +CentOS 7 是 2.17,一键安装器会检测并自动换成 `-glibc-217` 变体(尽力而为,未做长期验证)。 +判据: + +```bash +ldd --version | head -1 +``` + +**外网**:服务端只在「检查更新」时访问 GitHub,且失败是设计内的结果(返回"查不到",不报错)。 +内网机器可以完全不连通外网。 + +## Web 管理端 + +不需要安装,用浏览器打开服务端地址即可。 + +| 项目 | 要求 | +| -------- | --------------------------------------------------------------------------- | +| 浏览器 | Chrome / Edge 100+、Firefox 100+、Safari 15+(要支持 CSS 容器查询) | +| 屏幕 | 桌面 1280×720 以上;手机(≤768px)有一套独立的抽屉式布局,同样可用 | +| 安装 PWA | 手机 / 桌面 Chrome 里「添加到主屏幕」可当 App 用(需要 HTTPS 或 localhost) | + +## 桌面客户端(学生端 / 教室机器) + +| 项目 | 要求 | +| -------- | ---------------------------------------------------------------------------- | +| 操作系统 | Windows 10 / 11(x64) | +| 内存 | 4 GB 以上(Electron 常驻约 250–400 MB) | +| 磁盘 | 安装后约 330 MB | +| 显卡 | 有 D3D11 即可;希沃一体机、老显卡、虚拟机都做过兼容(没有裁剪 GPU 回退组件) | +| 触摸屏 | 支持,会自动进入[触摸模式](../app/island.md)(希沃白板等) | + +> 客户端**只有 Windows 版**。Linux / macOS 上目前只能用 Web 管理端。 + +## 网络 + +三端的通信都指向服务端的 **HTTP 端口(默认 4000)**,其中: + +- REST 接口:`/api/*` +- 实时推送(Socket.IO):`/socket.io/` +- Web 管理端静态资源:`/`(由服务端托管) + +| 场景 | 要做什么 | +| ------------------------- | --------------------------------------------------------------------------------------- | +| 只在服务器本机用 | 什么都不用做,`http://127.0.0.1:4000` | +| 教室机器 / 老师电脑也要用 | 客户端与管理端填服务器的**局域网 IP**(如 `http://192.168.1.10:4000`),防火墙放行 4000 | +| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) | + +> **别用代理**。本机自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`; +> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。插件里对本地 / 内网地址做了强制直连, +> 但浏览器与客户端本身不会绕过。 + +## 卸载 / 重置 + +- 服务端:开始菜单 → 卸载。卸载会保留数据库(`data` 目录改名为 `data.backup`)。 +- 客户端:从托盘退出后卸载;重置配置删 `%APPDATA%\class-helper`(或客户端「关于」页显示的数据目录)。 + +下一步:[安装服务端](server.md)。 diff --git a/docs/get-started/server.md b/docs/get-started/server.md new file mode 100644 index 0000000..2e77fb0 --- /dev/null +++ b/docs/get-started/server.md @@ -0,0 +1,139 @@ +--- +title: 安装服务端 +description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把班级小助手服务端跑起来。 +--- + +# 安装服务端 + +服务端是整个系统的中心:它存数据、发实时推送,**并且顺带托管 Web 管理端**(所以老师那一端不用单独装东西)。 + +四种装法按场景选,任选一种即可: + +| 方式 | 适合 | 一句话 | +| --------------------------------- | -------------------------------------- | ------------------------------- | +| [Windows 安装包](#windows-安装包) | 学校机房、教师办公电脑、单机即完整系统 | 双击 exe,装完自动启动 | +| [Linux 一键安装](#linux-一键安装) | 学校已有 Linux 服务器 | 一条命令,带 systemd 与运维命令 | +| [Docker](#docker) | 云服务器、想跟别的服务共用一台机器 | `docker compose up -d` | +| [源码跑](#从源码跑开发模式) | 想改代码、想先看看它长什么样 | `pnpm dev` | + +## Windows 安装包 + +### 生成安装包 + +安装包不入库(体积大),从 [Releases](https://github.com/LoveChengke/classhelper/releases) 直接下载 +`班级小助手服务端-<版本>-x64-setup.exe` 即可。要自己打: + +```bash +pnpm dist:server # 首次会执行一次 npm install(约 2 分钟) +pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过 npm install +``` + +产物: + +| 路径 | 说明 | +| --------------------------------------------------------------------- | ------------------------------------------- | +| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) | +| `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 | + +包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env` +与启停脚本。目标机器**不需要预先安装 Node.js**。 + +### 安装与启动 + +双击安装程序即可,**不需要管理员权限**(当前用户级安装)。 + +- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` +- 静默安装:`"班级小助手服务端-<版本>-x64-setup.exe" /S` +- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 +- 自动加入当前用户开机自启 + +装完服务会自动启动并完成首次初始化:数据库为空则执行随包迁移建表 → 创建管理员账号 → +`http://127.0.0.1:4000/` 就是 Web 管理端。 + +常用脚本(都在安装目录里): + +```cmd +start.cmd :: 启动并等待就绪(已在运行则直接返回) +stop.cmd :: 按 PID 精确停止 +restart.cmd :: 重启 +status.cmd :: 查看状态与 HTTP 探针结果 +``` + +机房主机这种无人值守的机器,建议以**管理员身份**跑一次 `install-service.cmd`, +把服务注册成开机启动的计划任务(比注册表 Run 更稳): + +```cmd +install-service.cmd :: schtasks /Create /SC ONSTART /RL HIGHEST +uninstall-service.cmd :: 删除计划任务 +``` + +安装目录结构与配置见[生产部署指南](../management/production.md); +安装程序里的默认管理员口令**第一次登录后必须改掉**。 + +## Linux 一键安装 + +从 Release 下载 `classhelper-server-linux-x64-<版本>.tar.gz`(以及旁边的 `.sha256`),连同 +`install.sh` 一起拷到服务器: + +```bash +scp releases/server/<版本>/linux-x64/classhelper-server-linux-x64-*.tar.gz \ + install.sh verify-linux.sh root@<主机>:/tmp/ + +ssh root@<主机> 'bash /tmp/install.sh --check' # 先体检,不改任何东西 +ssh root@<主机> "printf '%s\n' '<管理员密码>' | bash /tmp/install.sh --yes --admin-password-stdin \ + --package /tmp/classhelper-server-linux-x64-*.tar.gz" +``` + +安装器会装好内置 Node、把程序放到 `/opt/classhelper`、写好 systemd 单元、 +把 `classhelper` 运维命令软链到 `/usr/local/bin/`。之后: + +```bash +classhelper status # 服务状态 +classhelper doctor # 一整套自检(配置软链、权限、依赖完整性…) +classhelper backup # 立即备份 +classhelper upgrade # 升级到最新 Release(自动校验 sha256) +``` + +完整的目录布局、配置项、升级回滚、反向代理与故障排查见 [Linux 部署与运维](../management/linux-deploy.md)。 + +## Docker + +```bash +cd deploy +cp .env.example .env # 至少改 JWT_SECRET +docker compose up -d # 默认 SQLite 单容器,数据卷挂在本机 +``` + +要 MySQL 就用 `docker-compose.yml`(带一个 MySQL 服务),只想要单容器 SQLite 用 +`docker-compose.sqlite.yml`。细节见[生产部署指南](../management/production.md)。 + +## 从源码跑(开发模式) + +想改代码或只想先看看,用这条路。需要 **Node ≥ 20.19** 与 **pnpm 11**。 + +```bash +pnpm install +cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET +pnpm db:generate +pnpm --filter @classhelper/server db:deploy # 应用数据库迁移 +pnpm build:shared # 必须先构建 shared,否则后面会 ERR_MODULE_NOT_FOUND +pnpm db:seed # 写演示数据(会清空业务表) +pnpm dev # 后端 4000 + Web 端 5173 一起起 +``` + +演示账号见 [README 的「默认账号与种子数据」](https://github.com/LoveChengke/classhelper#readme); +**演示口令只用于本地开发,别带到真实环境**。 + +| 端口 | 用途 | +| ---- | -------------------------------------------------------- | +| 4000 | 后端 REST + Socket.IO + (构建后)Web 管理端静态托管 | +| 5173 | Web 管理端开发服务器(`/api`、`/socket.io` 代理到 4000) | +| 5174 | 桌面客户端渲染进程开发服务器 | + +> 改动 `packages/shared` 之后必须 `pnpm build:shared`(或让它跑着 watch)—— +> 三端都从它的 `dist` 导入。 + +## 装完之后 + +打开 `http://<服务器地址>:4000/`,用管理员账号登录。下一页: +[认识 Web 管理端](web-admin.md)。 diff --git a/docs/get-started/setup-class.md b/docs/get-started/setup-class.md new file mode 100644 index 0000000..b73972a --- /dev/null +++ b/docs/get-started/setup-class.md @@ -0,0 +1,71 @@ +--- +title: 建班级、排课表 +description: 建第一个班级、配班级账号、加学生与老师、排课表——后面所有功能的前提。 +--- + +# 建班级、排课表 + +后面所有功能(发通知、布置作业、录成绩、接 ClassIsland)都挂在**班级**上,所以先把班级建出来。 + +## 1. 建班级(仅管理员) + +Web 管理端 → **班级管理** → 「新建班级」: + +| 字段 | 说明 | +| -------- | ----------------------------------------------------------------------- | +| 班级名称 | 例如「高二(3)班」 | +| 年级 | 用于分组与统计 | +| 班主任 | 从教师账号里选,默认是自己;之后随时可在班级详情顶部「更换班主任」改 | +| 学期周数 | 默认 20,**上限 30**。课表的周次选择与周次范围都以它为准 | +| 班级码 | 保存时自动生成(字母数字 4–16 位,存大写),可以改成好记的,例如 `G203` | +| 班级密码 | 新建班级时的初始密码就是它 —— 学生在教室机器上要用这个登录 | + +> **班级码 + 班级密码 = 学生端的登录凭据。** 学生端没有个人账号,一台教室机器用班级账号登录后, +> 那台机器上的「已读 / 完成」是**代全班**操作的。 +> +> 学期周数只有管理员能改:它决定"现在是第几教学周",乱改会让课表的周次过滤和上课时段判定错位。 + +## 2. 加学生(仅管理员) + +**学生管理** → 「新建」或「导入名单」: + +- **单个新建**:填姓名即可,登录名会自动生成。 +- **导入名单**:下载模板(列只有 `用户名 / 姓名`,学生没有账号属性,**没有密码列**),填好上传。 + 用户名要匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复用户名在 `append` 模式下跳过、`upsert` 模式下更新姓名。 + 用户名已经属于某个教师 / 管理员账号时,这一行会进错误列表,不会覆盖别人的账号。 + +学生的用途是:**成绩、未交名单、叫人、已读记录都按名单记录**。所以名单不全,这几项就不准。 + +## 3. 加科任老师 + +教师的**录入**只有管理员能做(**教师管理**页,支持单个录入与导入名单)。班级层面的**分配**在班级详情里: +把老师加进「协作教师」,他就能看到这个班、给这个班发作业 / 通知 / 叫人。 + +角色与可见范围见[班级、学生与教师](../app/classes.md)与[角色与权限](../dev/permissions.md)。 + +## 4. 排课表 + +**课表管理** → 选中班级 → 「新增课表」: + +- **科目**下拉直接列出全校统一的 18 科目录(语文 / 数学 / 英语 / 政治 / 历史 / 物理 / 化学 / 地理 / + 生物 / 信息技术 / 体育 / 美术 / 音乐 / 班会 / 早读 / 晚读 / 通用技术 / 听力)。 + 选中一个该班还没有的科目时,前端会**自动为该班建同名课程**,不用先去把科目的建一遍。 +- 填星期、开始 / 结束时间、周次范围(第几周到第几周)、单双周(每周 / 单周 / 双周)。 +- 一节课**必须同时有开始时间和结束时间** —— 「上课时段」的判定就是拿这两个时间比对的。 + +排完在**课表管理**里能看到周视图(7 列),客户端那边则多一个「今天」时间轴视图。 + +### 从 ClassIsland 导入更省事 + +如果教室里已经在用 ClassIsland,直接导入它的档案 JSON 就行:课表、节次时间、单双周都会一起进来, +不用排两遍。见[表格与课表导入](../app/imports.md)。 + +## 5. 确认「上课时段」判定是对的 + +班级小助手有一整套「上课时段策略」:普通通知在上课时段只进队列、下课自动补弹,紧急通知与叫人则立刻弹。 +这套判定完全基于课表,所以**课表排错,提醒就会在该弹的时候不弹**。 + +排完课之后建议立刻验一下:在**通知发布**页选这个班,随便发一条普通通知, +看客户端是不是在课间弹出来、上课时是不是安静地收着。规则详见[通知与提醒](../app/notifications.md)。 + +下一步:[装学生端](client.md)。 diff --git a/docs/get-started/toc.yml b/docs/get-started/toc.yml new file mode 100644 index 0000000..28b0306 --- /dev/null +++ b/docs/get-started/toc.yml @@ -0,0 +1,16 @@ +- name: 快速上手 + href: index.md +- name: 系统要求 + href: requirements.md +- name: 安装服务端 + href: server.md +- name: 认识 Web 管理端 + href: web-admin.md +- name: 建班级、排课表 + href: setup-class.md +- name: 装学生端 + href: client.md +- name: 接入 ClassIsland + href: classisland.md +- name: 发布第一条内容 + href: first-content.md diff --git a/docs/get-started/web-admin.md b/docs/get-started/web-admin.md new file mode 100644 index 0000000..53816b8 --- /dev/null +++ b/docs/get-started/web-admin.md @@ -0,0 +1,58 @@ +--- +title: 认识 Web 管理端 +description: 登录班级小助手 Web 管理端,认一认 12 个页面、新手引导与手机端布局。 +--- + +# 认识 Web 管理端 + +Web 管理端不用装:服务端起来之后,浏览器打开 `http://<服务器地址>:4000/` 就是它。 + +## 登录 + +用**教师 / 管理员账号**登录(用户名 + 密码)。学生没有个人账号,学生那一端走 +[班级码 + 班级密码](client.md),见[班级、学生与教师](../app/classes.md)。 + +- 新装的服务端只有安装时创建的那一个管理员账号。**第一次登录后请立刻在顶栏头像菜单 → 修改密码里改掉它。** +- 登录页**不含任何演示内容**(没有"点击填充示例账号"之类的按钮),也不会提示种子口令。 +- 连不上服务器时页面会直接失败,不会静默重试 —— 先确认服务端在跑、端口对、没走代理。 + +## 界面 + +布局沿用 1Panel 那一套:左边固定深色侧边栏,右边浅灰底 + 白色圆角卡片。 + +| 页面 | 干什么 | 谁能进 | +| ---------------- | ---------------------------------------------- | ---------------------- | +| 仪表盘 | 全校 / 本班的汇总数字与最近动态 | 所有登录用户 | +| 课表管理 | 排课、导入 ClassIsland 课表、节次时间 | 管理员、本班班主任 | +| 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 | +| 通知发布 | 发通知、发叫人 | 教师、管理员 | +| 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 | +| ClassIsland 联动 | 接入教室设备、下发提醒、看实时状态、开镜像 | 管理员、教师 | +| 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** | +| 学生管理 | 学生名单增删、导入 | **仅管理员** | +| 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** | +| 数据库管理 | 备份 / 恢复、导出导入、一键切换 SQLite ⇄ MySQL | **仅管理员** | +| 检查更新 | 顶栏头像菜单里,查 GitHub 上有没有新版本 | 所有登录用户 | + +> **前端隐藏 ≠ 权限**。上面这张表只是"入口藏不藏",每一条写入请求服务端都会再判一次 +> (见[角色与权限](../dev/permissions.md))。直接输网址绕过菜单,一样会被挡回仪表盘。 + +## 第一次登录的引导 + +首次登录进入主布局后会自动弹一次聚焦式引导,依次指出:侧边菜单 → 实时通道状态 → 账号菜单 → 完成。 +走完或跳过(右上角 × 或 Esc)都记作"已看过",之后不再自动弹。 + +想重看:**右上角头像菜单 →「使用引导」**。 + +## 手机 / 平板 + +| 屏幕宽度 | 布局 | +| --------- | ------------------------------------------------------ | +| > 992px | 常驻 210px 深色侧边栏 + 顶栏显示连接状态与实时事件计数 | +| 769–992px | 保留侧边栏,内容区内边距压缩 | +| ≤ 768px | 侧边栏收进抽屉,顶栏汉堡按钮唤出;表格在卡片内横向滚动 | + +手机上(与电脑同一局域网)用 `http://<主机IP>:4000` 打开,可以「添加到主屏幕」当 App 用(PWA)。 +做成 HTTPS 之后 PWA 才能完全离线化,见[生产部署指南](../management/production.md)。 + +下一步:[建班级、排课表](setup-class.md)。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..87572f5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,71 @@ +--- +title: 首页 +description: 班级小助手(Class Helper)的官方文档:安装部署、功能说明、运维手册与开发参考。 +--- + +# 班级小助手文档 + +班级小助手(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,四个交付物**: +装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(学生端 / 教室大屏), +以及装在 [ClassIsland](https://classisland.tech) 上的联动插件。 + +

+老师发一条通知,教室那台机器上的灵动岛和 ClassIsland 会同时弹出来;学生打开客户端就能看到当天的课表和作业。 +

+ + + +## 它长什么样 + +
+ 桌面客户端 · 课表「今天」时间轴 + 灵动岛 · 点开通知后的详情卡 + Web 管理端 · 手机上的通知发布页 +
+ +> 这些图都是脚本把程序真的跑起来截的,不是示意图。采集方式见 [验收与测试](dev/testing.md)。 + +## 目录 + +| 章节 | 里面有什么 | 谁看 | +| -------------------------------- | -------------------------------------------------------- | -------------------- | +| [快速上手](get-started/index.md) | 系统要求、安装、第一次配置、发布第一条内容 | 第一次部署的人 | +| [应用帮助](app/index.md) | 逐个功能怎么用,以及行为上的约定(什么时段弹、谁看得见) | 老师、班主任、管理员 | +| [管理运维](management/index.md) | 部署形态、Linux 运维命令、备份、换库、升级 | 管服务器的人 | +| [开发文档](dev/index.md) | 架构、REST API、实时事件、数据模型、权限、发布流程 | 改代码的人 | +| [常见问题](faq.md) | 装不上、连不上、改了不生效…… | 所有人 | + +## 这套系统的三件事 + +**一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新; +落到 ClassIsland 上的那部分走设备令牌的独立通道,走 HTTP 上报,不依赖教室机器的网络环境有多好。 + +**二、上课时段不打扰。** 「上课时段」由课表实时判定,普通通知只进队列、下课自动补弹, +紧急通知和叫人则立刻弹出来 —— 服务端强制拦截,绕开界面直接调接口一样会被拦。 + +**三、离线可用。** 客户端先写 IndexedDB,断网时回退缓存并在顶部提示; +恢复连接后自动重新拉取,教室网络抖一下不会让大屏变白。 + +## 关于本文档 + +- 文档站的源码就在仓库里(`docs/`),用 [docfx](https://github.com/dotnet/docfx) 生成,发布在 GitHub Pages 上。 + 每页右下角有「在 GitHub 上编辑这一页」,发现写错或写漏可以直接改。 +- 版本号取自仓库根 `package.json`,显示在顶栏,与 [Releases](https://github.com/LoveChengke/classhelper/releases) 对齐。 +- 想在自己机器上预览:`pnpm docs:build --serve`。 diff --git a/docs/management/database.md b/docs/management/database.md new file mode 100644 index 0000000..65feb0f --- /dev/null +++ b/docs/management/database.md @@ -0,0 +1,78 @@ +--- +title: 数据库管理 +description: 备份与恢复、导出导入、定时备份,以及 Web 管理端里的「数据库管理」模块。 +--- + +# 数据库管理 + +Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可见**,后端同样 `requireRole('ADMIN')`), +把过去要手工做的数据库运维都收进了界面。 + +## 它能做什么 + +| 能力 | 说明 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、14 张表的行数、连接串(MySQL 密码脱敏) | +| 连接测试 | 对任意目标库试连(切换前的第一步) | +| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** | +| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 | +| 定时备份 | enabled + 间隔小时数 + 保留份数,配置存在 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(**进程不运行不补跑**) | +| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) | + +## 关键设计:只有一种快照格式 + +**备份、导入导出、跨库迁移共用同一种「JSON 快照」**: + +- 14 张表按**拓扑序**导出; +- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User` + 是**互相引用**的,任何排序都会被其中一侧卡死; +- 分批 `createMany` 写入。 + +因此**任意一份备份都能恢复回任意一种受支持的数据库**(SQLite 备份可以恢复进 MySQL,反之亦然)。 + +MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`,不需要额外安装。 + +## 三条边界 + +1. **主库只支持 SQLite 与 MySQL**。Redis 这类内存键值库 Prisma 不支持建表 / 迁移, + 接口层直接 422 拒绝;PostgreSQL 暂未纳入。 +2. **切换完成后必须重启服务端**(安装版跑 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连上新库。 + 切换失败会自动回滚 `schema.prisma`,`.env` 只在**数据全部迁移成功后**才改写。 +3. **目标库必须为空**(有表就拒绝)。 + +> 安装包体积因此从约 34MB 涨到约 62MB:切换需要随包带上 prisma CLI 与 TypeScript 编译器 +> (Prisma 7 的生成器产出 `.ts` 源码,切换后要在目标机上编译进 `dist`)。 + +## 命令行 / SSH 场景的备份 + +Web 界面适合日常;服务器上更适合用命令。Linux 安装形态有一套现成的: + +```bash +sudo classhelper backup # 备份配置 + 数据库 +sudo classhelper backup --note "升级前" --keep 7 +sudo classhelper backup list +sudo classhelper backup restore 20261005-033000 # 用某个备份覆盖当前配置与数据(会先停服务) +sudo classhelper backup schedule daily # 每天 03:30 自动备份(systemd timer,保留 7 份) +sudo classhelper backup schedule off +``` + +备份落在 `/var/backups/classhelper/<时间戳>[-说明]/`:`config.env` + `classhelper.db` + `meta.json`, +目录权限 700、文件 600。 + +- SQLite 用 `VACUUM INTO` 做**一致性快照** —— 服务在跑也安全,比直接 `cp` 可靠得多; +- MySQL 用 `mysqldump --single-transaction`,密码经环境变量传给子进程,**不进 argv**; +- `restore` 前会先自动备份现状;恢复的对象是**配置与数据**,不是程序。 + +## 其它形态怎么备份 + +| 形态 | 备份 | 恢复 | +| ----------------- | ------------------------------------------------------ | ---------------------------- | +| Windows 安装包 | 停服务后复制 `data\` 目录(里面有 `classhelper.db`) | 替换后启动 | +| Linux | `classhelper backup`(见上) | `classhelper backup restore` | +| Docker | 备份挂载出来的数据卷 | 还原数据卷 | +| MySQL(任意形态) | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql … < backup.sql` | + +**建议**:每日自动备份 + 保留 7 份,并且**定期真的恢复一次**验证备份可用。 + +> 变更 `JWT_SECRET` 会让所有用户重新登录,无必要时不要换。 +> Linux 上换用 `sudo classhelper key rotate`。 diff --git a/docs/management/index.md b/docs/management/index.md new file mode 100644 index 0000000..00518c4 --- /dev/null +++ b/docs/management/index.md @@ -0,0 +1,76 @@ +--- +title: 管理运维 +description: 部署形态怎么选、备份与恢复、换数据库、升级与回滚,以及上线前要逐项确认的清单。 +--- + +# 管理运维 + +这一章写给管服务器的人。如果你只是要用它,看[应用帮助](../app/index.md)就够了。 + +## 先选部署形态 + +| 形态 | 适合 | 产物 | 详细 | +| ------------------------ | ---------------------------------------- | ----------------------------------- | ----------------------------------- | +| **Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | NSIS 安装程序,内置 Node 运行时 | [生产部署指南](production.md) | +| **Linux 原生 + systemd** | 学校已有 Linux 服务器 | 一键安装器 + `classhelper` 运维命令 | [Linux 部署与运维](linux-deploy.md) | +| **Docker** | 云服务器、跟别的服务共用一台机器 | `deploy/docker-compose*.yml` | [生产部署指南](production.md) | +| **手动部署** | 已有 Node 环境、需要自定义 | 免安装目录 | [生产部署指南](production.md) | + +一句话的取舍: + +- **只有一台 Windows 机器** → 安装包形态,装完就能用,卸载也不留东西。 +- **有 Linux 服务器** → 一键安装器,之后所有维护都在 `classhelper` 这一条命令里。 +- **要对外提供服务** → Docker 或 Linux + Nginx 反代 + HTTPS(公网部署**必须**有 TLS)。 + +## 数据库:默认 SQLite,可以换 MySQL + +默认用内嵌的 SQLite,**单校规模完全够用**,而且备份就是复制一个文件。 +多校区 / 高并发 / 需要多实例再换 MySQL: + +- 换法见[切换到 MySQL](mysql.md)(Web 端一键切换 / 命令行手工切换两条路); +- 备份、导出导入与跨库迁移共用同一种 JSON 快照格式,**任意备份能恢复回任意一种受支持的库**; +- 见[数据库管理](database.md)。 + +> 主库**只支持 SQLite 与 MySQL**。Redis 是内存键值库,Prisma 不支持它建表 / 迁移,接口层直接拒绝。 + +## 升级 + +不同形态的升级方式不一样,共同点是:**升级数据不会丢,但升级前一定先备份**。 + +| 形态 | 升级方式 | +| ----------- | -------------------------------------------------------------- | +| Windows | 停服务 → 备份 `data` → 跑新版本安装程序(覆盖安装保留 `.env`) | +| Linux | `sudo classhelper upgrade`(自动下载、校验、快照、失败回滚) | +| Docker | `docker compose pull && docker compose up -d` | +| 手动 / 源码 | 替换程序目录,启动时自动补跑数据库迁移 | + +完整流程、回滚与注意事项见[更新与升级](upgrade.md)。 + +## 上线前逐项确认 + +- [ ] 改掉 `admin` 与教师账号的默认密码; +- [ ] `JWT_SECRET` 是随机值(≥ 48 字节)并妥善保管 —— **换它会让所有人重新登录**; +- [ ] 公网访问走 HTTPS 反向代理(`deploy/nginx.conf` 可直接用); +- [ ] `CORS_ORIGIN` 收敛到具体域名,不要留 `*`; +- [ ] `TRUST_PROXY=1` 并确认限流按真实 IP 生效(`/api` 响应头有 `RateLimit`); +- [ ] 反向代理**放行 `/socket.io/` 的 WebSocket 升级**(否则实时推送静默失效); +- [ ] 数据库账号用最小权限,只授权业务库; +- [ ] 配好定时备份,并且**验证过能恢复**; +- [ ] 关注 `/readyz` 与日志里的 5xx。 + +## 监控与日志 + +| 端点 | 用途 | +| ------------- | ---------------------------------------- | +| `/healthz` | 存活探针:进程活着即 200 | +| `/readyz` | 就绪探针:数据库可连才 200,否则 503 | +| `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 | + +日志输出到 stdout/stderr;安装包形态由启停脚本重定向到 `logs/server.log`(UTF-8)。 +访问日志含方法、路径、状态码与耗时,5xx 记 error、4xx 记 warn。 + +## 故障排查 + +常见现象与排查方向列在[生产部署指南](production.md)的最后一节, +以及[常见问题](../faq.md)。Linux 特有的问题(`systemctl` 启动即退出、`EACCES`、 +SQLite `database is locked`)在 [Linux 部署与运维](linux-deploy.md) 的故障排查一节。 diff --git a/docs/management/linux-deploy.md b/docs/management/linux-deploy.md index 4ade99c..e8ce0df 100644 --- a/docs/management/linux-deploy.md +++ b/docs/management/linux-deploy.md @@ -61,7 +61,7 @@ classhelper-server-linux-x64-<版本>.tar.gz.sha256 ← 旁边那份校验值 **为什么 Linux 包要单独出一个**:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 这些依赖是平台相关的, Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此 Linux 包必须在 Linux 上构建。 -仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml) +仓库里的 [`.github/workflows/release-linux-server.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/release-linux-server.yml) 就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上): 1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.2`); @@ -389,7 +389,7 @@ sudo classhelper uninstall --purge # 全删(含备份与数据),不可 ## 6. 环境变量文件 安装器会写 `/etc/profile.d/classhelper.sh`(内容见 -[`deploy/profile.d/classhelper.sh`](../deploy/profile.d/classhelper.sh)): +[`deploy/profile.d/classhelper.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/profile.d/classhelper.sh)): ```sh export CLASSHELPER_HOME=/opt/classhelper diff --git a/docs/management/production.md b/docs/management/production.md index 7a347c0..956cdf9 100644 --- a/docs/management/production.md +++ b/docs/management/production.md @@ -6,7 +6,7 @@ | --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | | **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | | **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | -| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](../deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 | +| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 | | **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) | --- diff --git a/docs/management/toc.yml b/docs/management/toc.yml new file mode 100644 index 0000000..f51f14e --- /dev/null +++ b/docs/management/toc.yml @@ -0,0 +1,12 @@ +- name: 管理运维 + href: index.md +- name: 生产部署指南 + href: production.md +- name: Linux 部署与运维 + href: linux-deploy.md +- name: 数据库管理 + href: database.md +- name: 切换到 MySQL + href: mysql.md +- name: 更新与升级 + href: upgrade.md diff --git a/docs/management/upgrade.md b/docs/management/upgrade.md new file mode 100644 index 0000000..965d30e --- /dev/null +++ b/docs/management/upgrade.md @@ -0,0 +1,132 @@ +--- +title: 更新与升级 +description: 检查更新与各形态的升级步骤、Linux 的升级回滚链路,以及升级前必须做的准备。 +--- + +# 更新与升级 + +## 先知道版本是怎么回事 + +三端**共用一套版本口径**: + +- 版本号的单一来源是仓库根 `package.json`。服务端在运行时读它(打包后读的是运行时清单), + 不在代码里写死; +- 比较逻辑放在 `@classhelper/shared` 的 `compareVersions` / `isNewerVersion`,三端共用 —— + 避免出现"客户端说有新版、服务端说没有"; +- 数字相同、只有后缀不同时,**带后缀的算更新**(对应 `v0.1.0-fix` 这种"同版本号的修复发布")。 + +## 检查更新 + +| 端 | 入口 | 检查路径 | +| ------------------- | --------------------------------------------------------------- | ------------------------------------------------ | +| 服务端 / Web 管理端 | 顶栏用户菜单 →「检查更新」 | 浏览器 → 服务端 `GET /api/update/check` → GitHub | +| 桌面客户端 | 启动后自动一次(延迟 5 秒)+「设置 → 关于」里的「检查更新」按钮 | 主进程 → GitHub(不经后端) | + +两端的路径不同是**被约束逼出来的**: + +- **Web 端不能直连 GitHub**。服务端下发的 CSP 里 `connect-src` 只放行 `'self'` 与 WebSocket, + 浏览器发往 `api.github.com` 的请求会直接被拦掉。走服务端转发还有个好处:所有管理员共享同一份缓存 + (GitHub 匿名限流是 60 次/小时/IP)。 +- **客户端在主进程做**。渲染进程的 CSP 与导航防线是安全模型里最要紧的一层, + 不让它去连外部域名、解析外部 JSON。 + +### "查不到"是设计内的结果 + +机房服务器与教室机器常常只有内网,请求会超时或失败。这时接口返回 `ok: false` + 一句人话 +(「无法连接 GitHub,本机可能没有外网」),界面按"暂时查不到更新"呈现并给重试入口, +**而不是弹红字报错**。 + +失败结果同样进缓存 —— 否则断网时每点一次按钮都要白等一个超时。 + +| 细节 | 说明 | +| ---------- | ----------------------------------------------------------------------------------------------------- | +| 缓存 | 两端各缓存 30 分钟;`?force=1` 绕过服务端缓存,但**仅管理员生效**(否则任意登录用户都能刷爆匿名限流) | +| 权限 | 任何已登录用户都能查(信息来自公开仓库);`force` 除外 | +| 只提示一次 | 客户端启动检查发现新版才推提示;点「忽略此版本」记进配置,同一版本不再提示 | + +更新**只提示,不自动下载、不自动安装**。安装包仍由人从 [Releases](https://github.com/LoveChengke/classhelper/releases) +页面取,可用 `SHA256SUMS-<版本>.txt` 核对。 + +## 升级步骤 + +### Windows 安装包 + +1. `stop.cmd` 停止服务; +2. **备份 `data` 目录**; +3. 运行新版本安装程序 —— 覆盖安装会**保留原有 `.env`**,JWT 密钥与端口设置不变, + 用户不需要重新登录; +4. `start.cmd` 启动。数据库结构如有变更,会在首次启动时自动补跑迁移。 + +如果升级后需要重新登录,说明 `.env` 被覆盖了(JWT 密钥变了),检查安装目录权限。 + +### Linux + +```bash +sudo classhelper upgrade --check # 只检查有没有新版本 +sudo classhelper upgrade # 升到最新版 +sudo classhelper upgrade --version 1.2.0 # 升到指定版本 +sudo classhelper upgrade --url ./classhelper-server-linux-x64-1.0.1.tar.gz --sha256 <哈希> # 内网离线 +sudo classhelper upgrade --url <包> --force # 强制重装同一版本 + +sudo classhelper rollback --list # 看有哪些程序快照 +sudo classhelper rollback # 回滚到最近一份(会问一次确认) +``` + +`upgrade` 的完整链路(**任一步失败都不改动程序文件**): + +``` +解析目标版本 → 下载 → 校验 sha256 → 备份数据与配置 → 快照当前程序文件 → +停服 → 替换程序文件(改名 + 就位,data/ 与配置原地不动)→ 起服 → 轮询 /healthz + ↘ 失败:自动用刚才的快照回滚并重新起服,再告诉你失败原因 +``` + +两点必须知道: + +1. **升级不会回滚数据库结构**。如果新版执行过迁移,回滚程序前要确认旧版还能读新库, + 必要时用 `classhelper backup restore` 连数据一起回退。 +2. 替换程序文件用的是"改名 + 就位"而不是删除重写:升级时**正在运行的就是 `bin/classhelper` 自己**, + 必须先改名再放新文件,运行中的进程继续读旧 inode,否则 bash 会读到半个脚本。 + +### Docker + +```bash +docker compose pull +docker compose up -d +``` + +数据库迁移在启动时自动补跑。 + +### 手动 / 源码 + +拉新代码或替换免安装目录 → 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations` +对比已执行的迁移,只补跑缺的那些: + +- 全新安装:依次执行全部迁移并创建初始管理员; +- 覆盖安装:只补跑账本里没有的迁移(已存在的表 / 索引自动跳过); +- 已是最新:**不做任何写操作**。 + +日志里会明确写出「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。 + +### 客户端(学生端) + +从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。 + +> 如果你是在**自己维护的机器**上只替换了 `releases/` 里的产物、而没有同步到安装目录, +> 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。 +> 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。 + +### ClassIsland 插件 + +**必须先完全退出 ClassIsland**(它会锁住 DLL),再覆盖插件,然后重启。 +插件配置 `Config\Plugins\<插件 id>\Settings.json` 里存着服务器地址与设备令牌,**别覆盖它**。 + +## 升级前的准备清单 + +- [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`); +- [ ] 记下当前版本号,必要时能回滚到它; +- [ ] 挑一个没课的时间 —— 升级期间实时推送会中断; +- [ ] 升级后确认 `/readyz` 是 200,并且客户端顶栏的实时通道显示"已连接"。 + +## 给维护者 + +打版本、出安装包、发 Release 的完整流程见[版本迭代与发布](../dev/release.md)。 diff --git a/docs/serve.mjs b/docs/serve.mjs new file mode 100644 index 0000000..2954fd1 --- /dev/null +++ b/docs/serve.mjs @@ -0,0 +1,69 @@ +/** + * 文档站本地预览用的最小静态服务器(构建产物 docs/_site)。 + * 正式发布走 GitHub Pages(.github/workflows/docs.yml),不需要这个文件。 + * + * node docs/build.mjs --serve → 构建后直接预览(推荐) + * node docs/serve.mjs → http://127.0.0.1:5181(要求已经构建过) + * PORT=8080 node docs/serve.mjs + */ + +import { createServer } from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { extname, join, normalize, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(fileURLToPath(new URL('_site/', import.meta.url))); +const PORT = Number(process.env.PORT ?? 5181); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.map': 'application/json', +}; + +const server = createServer(async (request, response) => { + const url = new URL(request.url ?? '/', 'http://localhost'); + const requested = decodeURIComponent(url.pathname); + const target = normalize(join(ROOT, requested === '/' ? 'index.html' : requested)); + + // 目录穿越防护:解析结果必须仍在 ROOT 里 + if (target !== ROOT && !target.startsWith(ROOT + sep)) { + response.writeHead(403).end('Forbidden'); + return; + } + + try { + const info = await stat(target); + const file = info.isDirectory() ? join(target, 'index.html') : target; + const body = await readFile(file); + response.writeHead(200, { + 'content-type': MIME[extname(file)] ?? 'application/octet-stream', + 'cache-control': 'no-store', + }); + response.end(body); + } catch { + // 与 GitHub Pages 行为对齐:未知路径给站点根的 404.html + const fallback = join(ROOT, '404.html'); + if (existsSync(fallback)) { + response.writeHead(404, { 'content-type': MIME['.html'] }).end(await readFile(fallback)); + return; + } + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('404'); + } +}); + +server.listen(PORT, '127.0.0.1', () => { + if (!existsSync(ROOT)) { + console.warn('还没有构建产物,先跑 `node docs/build.mjs`'); + } + console.log(`文档站预览:http://127.0.0.1:${PORT}`); +}); diff --git a/docs/templates/classhelper/favicon.png b/docs/templates/classhelper/favicon.png new file mode 100644 index 0000000..2c8a7ec Binary files /dev/null and b/docs/templates/classhelper/favicon.png differ diff --git a/docs/templates/classhelper/layout/_master.tmpl b/docs/templates/classhelper/layout/_master.tmpl new file mode 100644 index 0000000..7f78a89 --- /dev/null +++ b/docs/templates/classhelper/layout/_master.tmpl @@ -0,0 +1,171 @@ +{{!班级小助手文档站的模板覆盖(只改必要的地方,其余保持 docfx modern 模板的行为)。}} +{{! + 覆盖的是 docfx modern 模板的 layout/_master.tmpl,改动只有四处: + 1) lang 固定 zh-CN;loc: 开头的那些 meta 是给 docfx.min.js 用的 UI 文案(导航、目录、上一页/下一页、 + 主题菜单、复制按钮…),modern 模板只提供英文,这里全部换成中文; + 2) 顶栏右侧补一个 GitHub 图标链接与一个版本徽标(版本号由 docs/build.mjs 通过 --metadata 传入, + 单一来源是根 package.json,别在这里写死); + 3) 补 与 color-scheme,让手机浏览器地址栏跟着深浅色走; + 4) 页脚换成项目自己的(_appFooter 在 docfx.json 里)。 + + 下面三行 include 决定**模板目录里哪些文件会被拷进产物**:public/(本模板的 main.css)与两个图标。 + 别删 —— 删了图标与样式就进不了 _site。 +}} +{{!include(/^public/.*/)}} +{{!include(logo.png)}} +{{!include(favicon.png)}} + + + + + {{#redirect_url}} + + {{/redirect_url}} + {{^redirect_url}} + {{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}} + + + + + + {{#_description}}{{/_description}} + {{#description}}{{/description}} + + + + + + {{#_noindex}}{{/_noindex}} + {{#_enableSearch}}{{/_enableSearch}} + {{#_disableNewTab}}{{/_disableNewTab}} + {{#_disableTocFilter}}{{/_disableTocFilter}} + {{#docurl}}{{/docurl}} + + + + + + + + + + + + + + + + + + {{#_googleAnalyticsTagId}} + + + {{/_googleAnalyticsTagId}} + {{/redirect_url}} + + + {{^redirect_url}} + +
+ {{^_disableNavbar}} + + {{/_disableNavbar}} +
+ +
+ {{^_disableToc}} +
+
+
+
本页内容
+ +
+
+ +
+
+
+ {{/_disableToc}} + +
+
+ {{^_disableToc}} + + {{/_disableToc}} {{^_disableBreadcrumb}} + + {{/_disableBreadcrumb}} +
+ +
{{!body}}
+ + {{^_disableContribution}} +
+ {{#sourceurl}} + 在 GitHub 上编辑这一页 + {{/sourceurl}}{{^sourceurl}}{{#docurl}} + 在 GitHub 上编辑这一页 + {{/docurl}}{{/sourceurl}} +
+ {{/_disableContribution}} {{^_disableNextArticle}} + + {{/_disableNextArticle}} +
+ + {{^_disableAffix}} +
+ +
+ {{/_disableAffix}} +
+ + {{#_enableSearch}} +
+ {{/_enableSearch}} + + + + {{/redirect_url}} + diff --git a/docs/templates/classhelper/logo.png b/docs/templates/classhelper/logo.png new file mode 100644 index 0000000..0eae00c Binary files /dev/null and b/docs/templates/classhelper/logo.png differ diff --git a/docs/templates/classhelper/public/main.css b/docs/templates/classhelper/public/main.css new file mode 100644 index 0000000..e662a60 --- /dev/null +++ b/docs/templates/classhelper/public/main.css @@ -0,0 +1,630 @@ +/* + * 班级小助手文档站 —— docfx modern 模板的样式覆盖。 + * + * docfx 按 `template` 数组的顺序合并模板,这个文件排在最后,因此覆盖掉 modern 模板自带的 + * `public/main.css`(它本身是空的,被留作官方覆盖点)。docfx.min.css 在本文件之前加载, + * 所以同特异度的选择器这里写的会赢;Bootstrap 那类 0-2-0 的选择器要在下面用同等或更高的特异度压。 + * + * 两条原则: + * 1) 只调版式与配色,不动 DOM 结构(结构改动都在 templates/classhelper/layout/_master.tmpl 里); + * 2) 深色主题只覆盖**颜色**,几何与浅色完全一致 —— 切换主题时不该看到布局跳一下。 + */ + +/* ── 设计令牌 ─────────────────────────────────────────────────────────── */ +:root { + --ch-font-ui: + 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', -apple-system, + BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', sans-serif; + --ch-font-mono: + 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Sarasa Mono SC', ui-monospace, + SFMono-Regular, Menlo, monospace; + --ch-accent: #0f6cbd; + --ch-accent-soft: rgba(15, 108, 189, 0.08); + --ch-radius: 8px; + --ch-navbar-h: 60px; + + --bs-body-bg: #ffffff; + --bs-body-color: #24292f; + --bs-secondary-color: #59636e; + --bs-tertiary-color: #7d8590; + --bs-border-color: #d8dee4; + --bs-link-color: var(--ch-accent); + --bs-link-hover-color: #115ea3; + --bs-link-color-rgb: 15, 108, 189; + --bs-code-color: #b1370f; + --bs-body-font-family: var(--ch-font-ui); + --bs-body-font-size: 16px; + --bs-body-line-height: 1.8; +} + +[data-bs-theme='dark'] { + --ch-accent: #6cb8ff; + --ch-accent-soft: rgba(108, 184, 255, 0.12); + + --bs-body-bg: #1b1f24; + --bs-body-color: #e6edf3; + --bs-secondary-color: #9198a1; + --bs-tertiary-color: #6e7681; + --bs-border-color: #30363d; + --bs-link-color: var(--ch-accent); + --bs-link-hover-color: #96ccff; + --bs-link-color-rgb: 108, 184, 255; + --bs-code-color: #ffa198; +} + +/* ── 基础 ─────────────────────────────────────────────────────────────── */ +body { + font-family: var(--ch-font-ui); + font-size: 16px; + line-height: 1.8; + letter-spacing: 0.002em; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body > main.container-xxl { + padding-top: 1.6rem; +} + +:focus-visible { + outline: 2px solid var(--ch-accent); + outline-offset: 2px; + border-radius: 3px; +} + +/* ── 顶栏 ─────────────────────────────────────────────────────────────── */ +header.bg-body { + backdrop-filter: saturate(180%) blur(12px); +} + +.navbar { + min-height: var(--ch-navbar-h); +} + +.navbar-brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 17px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.navbar-brand #logo { + width: 28px; + height: 28px; + object-fit: contain; +} + +/* 版本徽标:版本号由 docs/build.mjs 从根 package.json 注入,见 _master.tmpl 顶部注释 */ +.ch-version { + padding: 1px 7px; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + font-family: var(--ch-font-mono); + font-size: 11px; + font-weight: 500; + line-height: 1.6; + color: var(--bs-secondary-color); +} + +.ch-nav-github { + display: inline-flex; + align-items: center; + margin-left: auto; + padding: 6px 8px; + border-radius: 6px; + color: var(--bs-secondary-color); + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.ch-nav-github:hover { + color: var(--bs-body-color); + background: var(--ch-accent-soft); +} + +/* 顶栏一级导航(由 docfx.min.js 按 toc.yml 生成) */ +.navbar-collapse { + min-width: 0; +} + +#navbar { + min-width: 0; + /* 章节有 7 个,窄屏上放不下就会换行成"首/页"这种两行标题 —— 改成横向滚动, + 标题一律不折行(滚动条本身隐藏,触控板/滚轮照样能滚)。 */ + overflow-x: auto; + scrollbar-width: none; +} + +#navbar::-webkit-scrollbar { + display: none; +} + +#navbar .navbar-nav { + flex-wrap: nowrap; + gap: 2px; +} + +#navbar .nav-link { + padding: 6px 12px; + border-radius: 6px; + font-size: 15px; + white-space: nowrap; + color: var(--bs-body-color); + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +#navbar .nav-link:hover { + color: var(--ch-accent); + background: var(--ch-accent-soft); +} + +#navbar .nav-link.active, +#navbar .nav-item.active > .nav-link { + color: var(--ch-accent); + font-weight: 600; +} + +/* 搜索框:默认是 Bootstrap 的灰底,这里做成产品里那种浅色输入框 */ +form.search { + position: relative; + display: flex; + align-items: center; +} + +form.search > i.bi-search { + position: absolute; + left: 10px; + font-size: 13px; + color: var(--bs-tertiary-color); + pointer-events: none; +} + +form.search .form-control { + min-width: 190px; + padding-left: 30px; + border-radius: 6px; + background: var(--bs-tertiary-bg); +} + +/* ── 左栏(章节导航)与右栏(本页内容) ───────────────────────────────── */ +.toc-offcanvas { + flex: 0 0 264px; + max-width: 264px; + padding-right: 8px; + border-right: 1px solid var(--bs-border-color); +} + +.toc-offcanvas .offcanvas-header { + display: none; +} + +#toc .nav, +#affix .nav { + display: flex; + flex-direction: column; + gap: 1px; +} + +#toc .nav-link, +#affix .nav-link { + padding: 5px 10px; + border-left: 2px solid transparent; + border-radius: 0 6px 6px 0; + font-size: 14.5px; + line-height: 1.6; + color: var(--bs-secondary-color); + transition: + color 0.15s ease, + background-color 0.15s ease, + border-color 0.15s ease; +} + +#toc .nav-link:hover, +#affix .nav-link:hover { + color: var(--bs-body-color); + background: var(--ch-accent-soft); +} + +#toc .nav-link.active, +#affix .nav-link.active { + border-left-color: var(--ch-accent); + color: var(--ch-accent); + font-weight: 600; + background: var(--ch-accent-soft); +} + +#toc .nav .nav, +#affix .nav .nav { + margin-left: 10px; + padding-left: 6px; + border-left: 1px solid var(--bs-border-color); +} + +#affix > .nav::before { + content: '本页内容'; + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--bs-tertiary-color); +} + +.affix .toc-filter { + margin-bottom: 10px; +} + +/* ── 正文 ─────────────────────────────────────────────────────────────── */ +article { + max-width: 46rem; + color: var(--bs-body-color); +} + +article > h1 { + margin: 0 0 1.2rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--bs-border-color); + font-size: 2rem; + font-weight: 650; + line-height: 1.35; + letter-spacing: -0.01em; +} + +article h2 { + margin: 2.6rem 0 0.9rem; + font-size: 1.4rem; + font-weight: 650; + line-height: 1.4; +} + +article h3 { + margin: 1.9rem 0 0.6rem; + font-size: 1.13rem; + font-weight: 650; + line-height: 1.5; +} + +article h4 { + margin: 1.5rem 0 0.5rem; + font-size: 1rem; + font-weight: 650; +} + +article h1, +article h2, +article h3, +article h4 { + scroll-margin-top: calc(var(--ch-navbar-h) + 16px); +} + +article p, +article li { + line-height: 1.85; +} + +article a { + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +/* 文档里大量出现「代码 + 中文」混排,行内代码要能跟正文对齐 */ +article code { + padding: 0.15em 0.4em; + border-radius: 5px; + background: var(--bs-tertiary-bg); + font-family: var(--ch-font-mono); + font-size: 0.875em; + word-break: break-word; +} + +article pre { + margin: 1.1rem 0; + padding: 14px 16px; + border: 1px solid var(--bs-border-color); + border-radius: var(--ch-radius); + background: var(--bs-tertiary-bg); + line-height: 1.65; + overflow-x: auto; +} + +article pre code { + padding: 0; + background: none; + font-size: 13.5px; + color: inherit; + word-break: normal; +} + +article blockquote { + margin: 1.2rem 0; + padding: 2px 0 2px 16px; + border-left: 3px solid var(--ch-accent); + color: var(--bs-secondary-color); +} + +article blockquote p:last-child { + margin-bottom: 0; +} + +article table { + display: table; + width: 100%; + margin: 1.2rem 0; + border: 1px solid var(--bs-border-color); + border-radius: var(--ch-radius); + border-collapse: separate; + border-spacing: 0; + overflow: hidden; + font-size: 14.5px; +} + +article table thead th { + background: var(--bs-tertiary-bg); + font-weight: 600; + white-space: nowrap; +} + +article table th, +article table td { + padding: 9px 13px; + border: 0; + border-bottom: 1px solid var(--bs-border-color); + border-right: 1px solid var(--bs-border-color); + vertical-align: top; + line-height: 1.7; +} + +article table tr > *:last-child { + border-right: 0; +} + +article table tbody tr:last-child > td { + border-bottom: 0; +} + +article table img { + max-width: 100%; +} + +article img { + max-width: 100%; + border-radius: var(--ch-radius); +} + +article hr { + margin: 2.4rem 0; + border-top: 1px solid var(--bs-border-color); + opacity: 1; +} + +/* 标题旁自动生成的锚点:默认常显会显得很吵,只在悬停/聚焦时露出来 */ +article a.anchor, +article h1 > a.anchor, +article h2 > a.anchor, +article h3 > a.anchor { + float: none; + margin-left: 8px; + color: var(--bs-tertiary-color); + opacity: 0; + text-decoration: none; + transition: opacity 0.15s ease; +} + +article h1:hover > a.anchor, +article h2:hover > a.anchor, +article h3:hover > a.anchor, +article a.anchor:focus-visible { + opacity: 1; +} + +/* ── 页脚区(上一页/下一页、编辑入口、站点页脚) ─────────────────────── */ +.contribution { + margin-top: 2.4rem; +} + +.contribution .edit-link { + display: inline-block; + padding: 5px 12px; + border: 1px solid var(--bs-border-color); + border-radius: 6px; + font-size: 13.5px; + color: var(--bs-secondary-color); + text-decoration: none; + transition: + color 0.15s ease, + border-color 0.15s ease; +} + +.contribution .edit-link:hover { + border-color: var(--ch-accent); + color: var(--ch-accent); +} + +.next-article { + margin-top: 1.6rem; + padding-top: 1.2rem; +} + +.next-article a { + display: inline-flex; + gap: 6px; + align-items: baseline; + max-width: 48%; + font-size: 14.5px; +} + +footer.border-top { + margin-top: 2rem; + padding: 20px 0; + font-size: 13.5px; +} + +.ch-footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: center; +} + +.ch-footer a { + color: var(--bs-secondary-color); + text-decoration: none; +} + +.ch-footer a:hover { + color: var(--ch-accent); +} + +.ch-footer-sep { + color: var(--bs-tertiary-color); +} + +/* ── 首页的入口卡片 ─────────────────────────────────────────────────────── + 首页(docs/index.md)里手写的一小块 HTML。只在首页用到,所以没做成通用组件。 */ +.ch-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; + margin: 1.6rem 0 2rem; +} + +.ch-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px 18px; + border: 1px solid var(--bs-border-color); + border-radius: 10px; + text-decoration: none; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; +} + +.ch-card:hover { + border-color: var(--ch-accent); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.07); + transform: translateY(-2px); +} + +.ch-card-title { + font-size: 15.5px; + font-weight: 600; + color: var(--bs-body-color); +} + +.ch-card-desc { + font-size: 13.5px; + line-height: 1.65; + color: var(--bs-secondary-color); +} + +.ch-tagline { + margin: 0 0 1.6rem; + font-size: 17px; + color: var(--bs-secondary-color); +} + +/* 首页的截图墙 */ +.ch-shots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin: 1.4rem 0 2rem; +} + +.ch-shots img { + width: 100%; + border: 1px solid var(--bs-border-color); +} + +@media (prefers-reduced-motion: reduce) { + .ch-card { + transition: border-color 0.15s ease; + } + + .ch-card:hover { + transform: none; + } +} + +/* ── 搜索结果页 ───────────────────────────────────────────────────────── */ +.search-results .search-list-item { + padding: 12px 0; + border-bottom: 1px solid var(--bs-border-color); +} + +/* ── 小屏 ─────────────────────────────────────────────────────────────── */ +@media only screen and (max-width: 991px) { + .toc-offcanvas { + flex: 0 0 240px; + max-width: 240px; + } + + article > h1 { + font-size: 1.7rem; + } +} + +/* 中屏(约 1000–1200px):顶栏放不下 7 个章节 + 搜索框,把每项收窄一档, + 配合上面 #navbar 的横向滚动,标题不会被折成两行。 */ +@media only screen and (max-width: 1200px) { + #navbar .nav-link { + padding: 6px 8px; + font-size: 14px; + } + + form.search .form-control { + min-width: 150px; + } +} + +@media only screen and (max-width: 767px) { + body { + font-size: 15.5px; + } + + .navbar-brand { + font-size: 16px; + } + + .ch-version { + display: none; + } + + article table { + display: block; + overflow-x: auto; + white-space: nowrap; + } + + article > h1 { + font-size: 1.55rem; + } + + .next-article a { + max-width: 100%; + } +} + +/* ── 打印 ─────────────────────────────────────────────────────────────── */ +@media print { + .toc-offcanvas, + .affix, + header.bg-body, + .contribution, + .next-article { + display: none !important; + } + + article { + max-width: none; + } +} diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..f4a42b6 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,14 @@ +- name: 首页 + href: index.md +- name: 快速上手 + href: get-started/index.md +- name: 应用帮助 + href: app/index.md +- name: 管理运维 + href: management/index.md +- name: 开发文档 + href: dev/index.md +- name: 常见问题 + href: faq.md +- name: 完整参考 + href: reference.md diff --git a/eslint.config.mjs b/eslint.config.mjs index d330fef..ad37648 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,8 @@ export default tseslint.config( '**/.cache/**', '**/.pnpm-store/**', '**/.probe/**', + // 文档站(docfx)的构建产物:里面是 docfx 模板自带的压缩 JS 与生成的 HTML + '**/docs/_site/**', '**/*.d.ts', ], }, diff --git a/package.json b/package.json index e0f639a..44d72ab 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,11 @@ "verify:classisland": "pnpm --filter @classhelper/server verify:classisland", "verify:classisland-plugin": "node packages/classisland-plugin/scripts/verify.mjs", "icons": "node scripts/generate-icons.mjs", + "version:check": "node scripts/version.mjs", + "version:bump": "node scripts/version.mjs --bump", + "docs:build": "node docs/build.mjs", + "docs:serve": "node docs/build.mjs --serve", + "docs:check": "node docs/build.mjs --check", "dist:win": "pnpm --filter @classhelper/desktop-client dist:win", "dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir", "dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs", diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 124f4ab..7d31668 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -1269,7 +1269,7 @@ class IslandController { * 真机实测与不加模糊的像素完全一致(卡片区域离散度都是 12.7)。 * * 结论:`glass` 风格改为**纯 CSS 半透明深色卡片**(取 WinIsland 拿不到 host backdrop 时的 - * 官方降级色 `rgba(32,32,36,0.804)`,见 `docs/winisland-design-tokens.md`), + * 官方降级色 `rgba(32,32,36,0.804)`,见 `docs/dev/winisland-design-tokens.md`), * 桌面透过率由卡片自身 alpha 决定,窗口始终保持 `transparent: true` 的逐像素透明。 * 回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」。 */ diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index eaf2236..7365d67 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -298,7 +298,7 @@ const sizeMb = (target) => Math.round(dirSize(target) / 1024 / 1024); * * 为什么值得单独立一步:安装包的 node_modules 实测 380MB,而其中绝大部分是 * Prisma 为「它支持的所有数据库方言」准备的查询编译器副本 —— 本项目的 schema - * 只在 sqlite / mysql 之间切换(见 docs/mysql.md、`lib/db.ts` 的 DatabaseProvider), + * 只在 sqlite / mysql 之间切换(见 docs/management/mysql.md、`lib/db.ts` 的 DatabaseProvider), * 其余方言(postgresql / cockroachdb / sqlserver)的 wasm 一个字节都用不上。 * * 原则:只删数据文件与文档,**不碰任何会被 require 的入口**;每一条都写明依据。 @@ -654,7 +654,7 @@ function writeReadme() { 六、安全建议 - 修改 admin / teacher 的默认密码 - - 如需通过公网访问,请在前面加 Nginx/Caddy 反向代理并启用 HTTPS(见 docs/production.md) + - 如需通过公网访问,请在前面加 Nginx/Caddy 反向代理并启用 HTTPS(见 docs/management/production.md) - 定期备份 data 目录;更换 JWT_SECRET 会导致所有用户需要重新登录 `; fs.writeFileSync(path.join(staging, 'README.txt'), content.replace(/\r?\n/g, '\r\n'), 'utf8'); diff --git a/scripts/icons/render.cjs b/scripts/icons/render.cjs index f902868..85a741a 100644 --- a/scripts/icons/render.cjs +++ b/scripts/icons/render.cjs @@ -28,6 +28,8 @@ * packages/desktop-client/public/tray.png 系统托盘图标(32) * packages/desktop-client/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128) * website/assets/icon.png 官网(favicon / apple-touch / 页面内 brand) + * docs/templates/classhelper/logo.png 文档站顶栏的品牌标(128) + * docs/templates/classhelper/favicon.png 文档站标签页图标(64) * * ## 为什么还是 Electron * @@ -232,6 +234,13 @@ app.whenReady().then(async () => { // ── 官网(纯静态站,图标同样是页面里的 brand 标记) ────────────────────── writeIfChanged(path.join(ROOT, 'website', 'assets', 'icon.png'), pngBySize.get(512)); + // ── 文档站(docfx) ───────────────────────────────────────────────────── + // 必须落在**模板目录**里:docfx 只拷贝各模板 `{{!include}}` 声明的文件, + // 直接放 docs/ 根下不会进 _site(见 docs/templates/classhelper/layout/_master.tmpl 顶部注释)。 + const docsTemplate = path.join(ROOT, 'docs', 'templates', 'classhelper'); + writeIfChanged(path.join(docsTemplate, 'logo.png'), pngBySize.get(128)); + writeIfChanged(path.join(docsTemplate, 'favicon.png'), pngBySize.get(64)); + console.log('[icons] 全部图标生成完成'); app.exit(0); } catch (error) { diff --git a/scripts/use-database.mjs b/scripts/use-database.mjs index c0312e5..a33fe9a 100644 --- a/scripts/use-database.mjs +++ b/scripts/use-database.mjs @@ -4,7 +4,7 @@ * * 只做一件事:把 packages/server/prisma/schema.prisma 里的 datasource provider * 改成目标值(其余字段、模型定义完全不动),并打印后续必须执行的步骤。 - * 详细说明见 docs/mysql.md。 + * 详细说明见 docs/management/mysql.md。 */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -49,7 +49,7 @@ if (target === 'mysql') { 4) 初始化数据 pnpm db:seed -详见 docs/mysql.md`); +详见 docs/management/mysql.md`); } else { console.log(` 后续步骤: diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 0000000..ef4580f --- /dev/null +++ b/scripts/version.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * 版本号的读写与一致性校验 —— `pnpm version:check` / `pnpm version:bump <级别>`。 + * + * ## 为什么要这个脚本 + * + * 这个仓库里**同一个版本号写在 10 个地方**(5 份 package.json、运行时常量、两个 compose 文件、 + * 插件清单,以及插件 C# 源码里的一个 const)。手工改必然漏一处,而漏掉的那处**不会报错**, + * 只会在某个不常走的分支上表现为"版本号对不上": + * + * - `deploy/docker-compose*.yml` 镜像 tag 落后 → 服务器上跑的还是旧镜像; + * - `deploy/package.runtime.json` 容器 / 安装包里那份依赖清单的版本(人看,但会误导排查); + * - `manifest.yml` + `BridgeService.cs` ClassIsland 上显示的插件版本 + * (清单里的 version 与实际上报的 PluginVersion 不一致时, + * Web 端「ClassIsland 联动」页显示的版本会对不上插件市场)。 + * + * 因此:**改版本号的唯一入口是这个脚本**。它按表逐个改写,任何一处没匹配到(或匹配到多处) + * 就直接失败退出,不做"尽力而为"的部分改写。 + * + * ## 用法 + * + * node scripts/version.mjs # 检查(默认):列出各处版本,不一致则 exit 1 + * node scripts/version.mjs --bump patch # 1.1.2 → 1.1.3 + * node scripts/version.mjs --bump minor # 1.1.2 → 1.2.0 + * node scripts/version.mjs --bump major # 1.1.2 → 2.0.0 + * node scripts/version.mjs --set 1.2.0 # 显式指定(插件同时设为 1.2.0.0) + * node scripts/version.mjs --bump minor --dry # 只看会改哪些文件,不落盘 + * + * 版本口径与完整发布流程见 docs/dev/release.md。 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * `pattern` 一律写成**三个捕获组**:`(前缀)(版本号)(后缀)`。 + * 前缀与后缀原样保留,中间的版本号被替换 —— 这样既能精确替换,也能取出来比对。 + */ +const PRODUCT_TARGETS = [ + { file: 'package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ }, + { file: 'packages/shared/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ }, + { file: 'packages/server/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ }, + { file: 'packages/web-admin/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ }, + { file: 'packages/desktop-client/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ }, + { file: 'deploy/package.runtime.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ }, + { + file: 'deploy/docker-compose.yml', + pattern: /(classhelper-server:\$\{APP_VERSION:-)(\d+\.\d+\.\d+)(\})/, + }, + { + file: 'deploy/docker-compose.sqlite.yml', + pattern: /(classhelper-server:\$\{APP_VERSION:-)(\d+\.\d+\.\d+)(\})/, + }, +]; + +/** + * 插件版本(`x.y.z.0`)。第 4 段留给"只改插件、不动产品版本"的补丁发布; + * `--bump` / `--set` 一律把它归零,要单独发插件补丁时手工改这两处即可。 + */ +const PLUGIN_TARGETS = [ + { + file: 'packages/classisland-plugin/manifest.yml', + pattern: /^(version:[ \t]*)(\d+\.\d+\.\d+\.\d+)([ \t]*)$/m, + }, + { + file: 'packages/classisland-plugin/src/Services/BridgeService.cs', + pattern: /(private const string PluginVersion = ")(\d+\.\d+\.\d+\.\d+)(";)/, + }, +]; + +const ALL_TARGETS = [...PRODUCT_TARGETS, ...PLUGIN_TARGETS]; + +const PRODUCT_RE = /^\d+\.\d+\.\d+$/; +const PLUGIN_RE = /^\d+\.\d+\.\d+\.\d+$/; + +function readTarget(target) { + const source = fs.readFileSync(path.join(root, target.file), 'utf8'); + const flags = target.pattern.flags.includes('g') ? target.pattern.flags : `${target.pattern.flags}g`; + const hits = [...source.matchAll(new RegExp(target.pattern.source, flags))]; + return { source, hits, global: new RegExp(target.pattern.source, flags) }; +} + +/** 取出某个文件当前声明的版本号;没匹配到或匹配到多处都返回 null。 */ +function readVersion(target) { + const { hits } = readTarget(target); + return hits.length === 1 ? hits[0][2] : null; +} + +/** 改写一处。缺失或重复命中都算失败 —— 静默漏改比直接报错危险得多。 */ +function writeVersion(target, next, { dry }) { + const { source, hits, global } = readTarget(target); + if (hits.length !== 1) { + throw new Error( + `${target.file} 里匹配到 ${hits.length} 处版本号(应为 1 处)。` + + ' 模板被改过?请同步 scripts/version.mjs 里的 pattern 表。', + ); + } + if (!dry) { + const replaced = source.replace(global, (_m, prefix, _old, suffix) => `${prefix}${next}${suffix}`); + fs.writeFileSync(path.join(root, target.file), replaced, 'utf8'); + } + return `${target.file} ${hits[0][2]} → ${next}`; +} + +function bumpProduct(current, level) { + const [major, minor, patch] = current.split('.').map(Number); + if (level === 'major') return `${major + 1}.0.0`; + if (level === 'minor') return `${major}.${minor + 1}.0`; + if (level === 'patch') return `${major}.${minor}.${patch + 1}`; + throw new Error(`未知的级别 ${level}(可选:major / minor / patch,或用 --set x.y.z)`); +} + +/** 检查模式:逐处列出,并给出不一致清单。 */ +function check(product) { + console.log(`产品版本(单一来源:根 package.json):${product}\n`); + const width = Math.max(...ALL_TARGETS.map((target) => target.file.length)); + const problems = []; + + for (const target of ALL_TARGETS) { + const version = readVersion(target); + if (!version) { + console.log(`?? ${target.file.padEnd(width)} (未匹配到版本号)`); + problems.push(`${target.file}:匹配不到版本号`); + continue; + } + // 插件版本是四段,只校验前三段;第 4 段允许不为 0(插件专属补丁号) + const expected = PLUGIN_RE.test(version) ? `${product}.0` : product; + const ok = PLUGIN_RE.test(version) + ? version.split('.').slice(0, 3).join('.') === product + : version === expected; + console.log(`${ok ? ' ' : '!!'} ${target.file.padEnd(width)} ${version}`); + if (!ok) problems.push(`${target.file}:${version} ≠ ${expected}`); + } + + if (problems.length) { + console.error('\n版本号不一致:'); + for (const problem of problems) console.error(` - ${problem}`); + console.error('\n对齐方式:pnpm version:check 之后用 pnpm version:bump patch 统一改写。'); + process.exit(1); + } + console.log('\n全部一致 ✅(插件第 4 段是插件专属补丁号,允许不为 0)'); +} + +function apply(next, { dry, product }) { + const pluginNext = `${next}.0`; + const lines = []; + for (const target of PRODUCT_TARGETS) lines.push(writeVersion(target, next, { dry })); + for (const target of PLUGIN_TARGETS) lines.push(writeVersion(target, pluginNext, { dry })); + + console.log( + `${dry ? '[dry] 将改写' : '已改写'} ${product} → ${next}(插件 ${product}.0 → ${pluginNext}):\n`, + ); + for (const line of lines) console.log(` ${line}`); + console.log('\n接着按 docs/dev/release.md 走发布流程;GitHub Release 的 tag(v' + next + ')也要对齐。'); +} + +function main() { + const args = process.argv.slice(2); + const dry = args.includes('--dry'); + const bumpIndex = args.indexOf('--bump'); + const setIndex = args.indexOf('--set'); + + const product = readVersion(PRODUCT_TARGETS[0]); // 根 package.json = 单一来源 + if (!product || !PRODUCT_RE.test(product)) { + throw new Error('读不到根 package.json 的版本号(或它不是 x.y.z 形式)'); + } + + let next = null; + if (bumpIndex >= 0) { + const level = args[bumpIndex + 1]; + if (!level) throw new Error('--bump 后面要跟 patch / minor / major'); + next = bumpProduct(product, level); + } else if (setIndex >= 0) { + const value = args[setIndex + 1]; + if (!value) throw new Error('--set 后面要跟 x.y.z'); + if (!PRODUCT_RE.test(value)) throw new Error(`--set 的取值 ${value} 不是 x.y.z 形式`); + next = value; + } + + if (!next) check(product); + else apply(next, { dry, product }); +} + +try { + main(); +} catch (error) { + console.error(`[version] ${error.message}`); + process.exit(1); +}