feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)

学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8711 -1846

No files matched your search

+88 -34
View File
@@ -105,23 +105,27 @@ website/ 产品官网(**纯静态、无构建步骤**,
docs/dev/testing.md 的「截图留档」;客户端那两张要配合冒烟的 docs/dev/testing.md 的「截图留档」;客户端那两张要配合冒烟的
ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。 ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
⚠ download.html 的版本清单是**当场**读 GitHub Releases 的 ⚠ download.html 的版本清单是**当场**读 GitHub Releases 的
(assets/download.js)—— 渲染出的卡片与文件行都是拉到数据之后才建的, (assets/download.js):版本分段控件 → 一行状态 → 「装什么」分段控件
建完会派发一次 ch:content,由 main.js 补挂按压反馈(新增动态内容照这个来)。 (客户端 / 服务端 / 插件,**默认停在客户端**)→ 卡片。卡片是拉到数据之后
才建的,建完会派发一次 ch:content,由 main.js 补挂按压反馈
(新增动态内容照这个来)。两个分段控件共用同一套指示器实现。
卡片排版照 ClassIsland 官网那页:图标 + 名称 + 系统要求 + 主按钮 + 变体下拉
(<details>,点别处或 Esc 收起);插件那张只有一个文件,不摆点不开的箭头。
读不到时退回 <meta name="ch-version"> 并按发布命名约定拼直链 —— 读不到时退回 <meta name="ch-version"> 并按发布命名约定拼直链 ——
那个 meta 是 version.mjs 的落点之一,发版必须一起改。 那个 meta 是 version.mjs 的落点之一,发版必须一起改。
命名约定与 README「开始使用」表里那三个名字要对齐 命名约定与 README「开始使用」表里那三个名字要对齐
(ClassHelper-<版本>-x64-client-setup.exe / -client-portable.exe / (ClassHelper-<版本>-x64-client-setup.exe / -client-portable.exe /
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。 -x64-server-setup.exe),它们是离线兜底链接的唯一依据。
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
packages/server/ packages/server/
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
prisma/migrations/ 10 个迁移(init / time_layout / class_account / schedule_week_parity / prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
class_term_weeks / classisland_integration / class_notification_channel / 2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
homework_assign_date / push_kind / schedule_source) add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据) prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串 prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-05 实测 190 项) scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
scripts/verify-classisland.mjs 联动链路验收(实测 42 项) scripts/verify-classisland.mjs 联动链路验收(实测 44 项)
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用) scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底) src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出) src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
@@ -130,8 +134,10 @@ packages/server/
password schemas session term version(读 package.json) web-static password schemas session term version(读 package.json) web-static
src/middleware/ auth validate error security src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线) src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 15 个功能模块 + registry.ts + module.types.ts src/modules/ 17 个功能模块 + registry.ts + module.types.ts
packages/web-admin/src/ api stores router layouts views(12) components(5) composables styles config.ts (含 archives 毕业归档、term 学期周次)
packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts
(含 ArchivesView 毕业归档、TermWeeksView 学期周次)
packages/desktop-client/ packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**) src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
@@ -140,7 +146,7 @@ packages/desktop-client/
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约 src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install) packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.2.0 manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.2.0.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0) ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs src/Models/PluginSettings.cs
@@ -148,7 +154,7 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上) ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页 src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 68 项) scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 74 项)
``` ```
--- ---
@@ -205,9 +211,15 @@ pnpm build:classisland-plugin
> 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`); > 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`);
> `.env` 的 `DEFAULT_CLASS_PASSWORD` 只影响**新建**班级时的初始班级密码。 > `.env` 的 `DEFAULT_CLASS_PASSWORD` 只影响**新建**班级时的初始班级密码。
> **学生没有个人账号**(2026-10-01 清理):`student01…15` 只是名单记录(成绩/未交/叫人/已读按名单), >
> `User.passwordHash` 为空串、不能登录,也没有任何密码接口;ClassHelper 班级端统一用班级码 + 班级密码。 > **学生不是账号**(2026-10-06 起由表结构保证):`Student` 表里根本没有 `passwordHash` / `role`,
> 班级码 `G203` 是「高二(3)班」,不是 `G103`。 > 学号(`202601`…`202615`)只是名单记录与查询键。拿学号去 `/auth/login` 只会得到 401。
> 教室机器统一用**班级码 + 班级密码**登录(会话主体是班级,角色 `CLASS_DEVICE`)。
>
> 演示数据布局:在读班级 `2026级1班`(码 `G101`)/ `2026级2班`(码 `G102`)/ `2025级3班`(码 `G203`),
> 外加一个**已归档**的 `2023级1班`(3 名毕业生,用于验证「毕业归档」页)。
> 任课关系刻意让**语文**由另一位老师任教,这样班主任在语文上没有编辑权 ——
> 正好覆盖「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
--- ---
@@ -233,12 +245,12 @@ pnpm build:classisland-plugin
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 | | `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
| `pnpm db:studio` | Prisma Studio | | `pnpm db:studio` | Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/management/mysql.md`) | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/management/mysql.md`) |
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-05 实测 190 项全过) | | `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-06 实测 205 项全过) |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) | | `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | | `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) | | `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) | | `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) |
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) | | `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) |
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 | | `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | | `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) |
@@ -434,22 +446,28 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
## 5. 架构硬约定(改代码前必须知道) ## 5. 架构硬约定(改代码前必须知道)
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、 1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 15 个:`auth / classes / courses / schedules / `*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 17 个:`auth / classes / courses / schedules /
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard / homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
database / update`。 term / dashboard / database / update`。
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。 新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用 2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。 `middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用 3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`, `@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/
权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` / `canManageGrades` 等 CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublishNotification` /
(人类可读矩阵是 `PERMISSION_MATRIX`,README 直接引用它),**不要在页面里另写一套规则**。 `canManageRoster` / `canManageGrades` / `canManageSubjectContent` 等
4. **ClassHelper 班级端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」 (人类可读矩阵是 `PERMISSION_MATRIX`),**不要在页面里另写一套规则**。
代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。 4. **学生不是账号 —— 这是表结构保证的,别在代码里"兼容"学生账号。**
5. **实时推送只走房间。** 服务端 `src/realtime/`(`SOCKET_ROOMS`:`class:{classId}`、`user:{id}`、 `Student` 表没有 `passwordHash` / `role`,学生不能登录、没有任何密码接口;
`teacher:{id}`、`role:{role}`、`students`、`teachers`;`bus.ts` 事件总线),客户端 学号(`Student.studentNo`)是学生的唯一标识与查询键(名单 / 成绩 / 作业提交 / 导入导出全用它)。
`renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、 曾经那套"`User.role='STUDENT'` + 空串密码占位 + 登录前判 role 再 403"的写法在 2026-10-06
客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量 `SOCKET_EVENTS`,不要手写字符串。 整体删除,**不要再加回来**:任何 `role === 'STUDENT'` 的分支都是回归。
5. **任课关系的唯一来源是 `Course`(班级 + 科目 + 教师)。** 没有第二张任课表
(历史上那张无科目的 `ClassTeacher` 已删除)。作业与成绩的写权限必须走
**`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**:
挂了科目就只能由该科的任课老师改,没挂科目则归班主任 / 管理员;
班级端只有**作业**可以写(成绩不给)。**新增"发布内容"类入口必须复用它**,否则又会出现
"某个页面能改别人的科目"。
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`, 6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。 改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、 7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
@@ -1095,6 +1113,42 @@ database / update`。
--- ---
63. **实时推送只走房间,而且没有"学生房间"。** 服务端 `src/realtime/`(`SOCKET_ROOMS`:
`class:{classId}`、**`session:{id}`**、`teacher:{id}`、`role:{role}`、`students`、`teachers`;
`bus.ts` 事件总线),客户端 `renderer/stores/realtime.ts` 订阅。
`session:{id}` 里的 id 是**会话主体**:教师/管理员是自己的账号 id,
ClassHelper 班级端是**班级 id**(见 `socket.ts` 的握手逻辑)—— 「叫人」这类定向消息就发到它。
**2026-10-06 起把 `user:{id}` 改名成了 `session:{id}` 并且不再有学生房间**:学生不是账号,
没有任何客户端会订阅它。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量 `SOCKET_EVENTS`,不要手写字符串。
64. **ClassHelper 班级端主体是「班级」,`classId` 即身份。** 班级码 + 班级密码登录后角色是
`CLASS_DEVICE`,该设备的「作业完成 / 通知已读」由 `lib/session.ts` 的 `resolvePersonalIds()`
展开成**全班学生 id** 后再写(所以教师端的"完成人数 / 已读人数"统计依然准确)。
写 `createdBy` 之类的字段前要把它换成该班班主任 —— 班级端的 `sub` 是**班级 id**,
直接写会撞 `User` 外键(见 §5 第 20 条「教室机器能录入作业」)。
班级端的成绩查询受 `Class.studentGradeQueryEnabled` 这个**按班开关**控制(默认开启)。
65. **课表同步的主方向是「服务端 → 教室」,教室不再自动回传。**
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端改完课表自动下发),
`syncScheduleToServer` 默认 **false**。要取教室的课表走
`POST /integrations/devices/:id/request-schedule` —— 它只置一个待办
(`requestScheduleReport`),插件在下一次心跳看到就把课表推上来并清除标记。
Web 端点击前**必须弹警告**(会覆盖 / 合并本班课表)。
插件侧对应 `ReportSettingsDto.scheduleRequested` + `ReportAsync(..., forceSchedule: true)`,
且补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)——
`verify:classisland-plugin` 有 6 条护栏盯着这段契约,别删。
66. **学期周次可以逐周配,别再用"开学日期 + 每周七天"硬推。**
`TermWeek`(`classId = ''` 表示全校默认)存每一周的 `startDate`/`endDate`;
`lib/term.ts` 的 `loadTermContext(classId)` 负责把"班级覆盖 → 全校默认 → 纯线性"三级回落算出来,
`resolveCurrentWeek(now, context)` 优先按区间判定。
**调休 / 周末补课 / 错峰开学会让线性推算整体错位一周以上**,而课表、作业、成绩都按周次组织。
改周次(`PUT /api/term`、`POST /api/term/auto`)之后要 `notifyScheduleChanged()`,
让变更随课表下发给教室的 ClassHelper 班级端。
联网拉取调休建议(`GET /api/term/holidays`)**只给建议、不改数据**,且
**机房无外网时 `ok:false` 是正常结果**,失败结果同样进缓存(否则断网点一次要白等一个超时)。
## 6. 代码风格 ## 6. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀** - **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
@@ -1310,9 +1364,9 @@ pnpm lint # ESLint(0 error 为底线)
pnpm typecheck # 全仓库类型检查 pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端 pnpm dev:server # 另开终端
pnpm verify:e2e # 后端端到端(2026-10-05 实测 190 项全过) pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过)
pnpm verify:classisland # ClassIsland 联动链路(实测 42 项) pnpm verify:classisland # ClassIsland 联动链路(实测 44 项)
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项) pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 74 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条) pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
pnpm build && pnpm verify:web # Web 端真实点击回归(同上) pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
``` ```
+1 -1
View File
@@ -43,7 +43,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
### Web 管理端(老师 / 管理员) ### Web 管理端(老师 / 管理员)
- [x] 仪表盘:本班人数、课程、课表、作业、通知、成绩一屏概览 - [x] 仪表盘:本班人数、课程、课表、作业、通知、成绩一屏概览
- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、添加协作教师 - [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、按科目配置各科任课老师
- [x] 学生 / 教师管理:单个录入或导入名单(xlsx / xls / csv / tsv) - [x] 学生 / 教师管理:单个录入或导入名单(xlsx / xls / csv / tsv)
- [x] 课表管理:按周排课、单双周,可直接从 ClassIsland 档案导入课表与节次时间 - [x] 课表管理:按周排课、单双周,可直接从 ClassIsland 档案导入课表与节次时间
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单 - [x] 作业发布:按「所属日期」归类,支持附件与未交名单
+1 -1
View File
@@ -1,4 +1,4 @@
# 班级小助手服务端 · 生产镜像 # ClassHelper 服务端 · 生产镜像
# #
# 两阶段构建: # 两阶段构建:
# build —— 安装依赖并编译 shared / server / web-admin # build —— 安装依赖并编译 shared / server / web-admin
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 班级小助手 · 运维命令(Linux) # ClassHelper · 运维命令(Linux)
# #
# 安装位置:<安装目录>/bin/classhelper,并在 /usr/local/bin/classhelper 建软链, # 安装位置:<安装目录>/bin/classhelper,并在 /usr/local/bin/classhelper 建软链,
# 因此任何用户在任何目录都能直接敲 `classhelper`。 # 因此任何用户在任何目录都能直接敲 `classhelper`。
@@ -668,7 +668,7 @@ EOF
return 0 return 0
fi fi
printf '%s班级小助手服务端%s v%s\n\n' "$C_BOLD" "$C_RESET" "$(current_version)" printf '%sClassHelper 服务端%s v%s\n\n' "$C_BOLD" "$C_RESET" "$(current_version)"
printf ' %-10s %s\n' "服务状态" "$([ "$active" = "active" ] && printf '%s运行中%s' "$C_GREEN" "$C_RESET" || printf '%s未运行%s' "$C_RED" "$C_RESET")(开机自启:${enabled})" printf ' %-10s %s\n' "服务状态" "$([ "$active" = "active" ] && printf '%s运行中%s' "$C_GREEN" "$C_RESET" || printf '%s未运行%s' "$C_RED" "$C_RESET")(开机自启:${enabled})"
printf ' %-10s %s\n' "健康探针" "$([ "$healthy" = "yes" ] && printf '%s通过%s' "$C_GREEN" "$C_RESET" || printf '%s无响应%s' "$C_RED" "$C_RESET")" printf ' %-10s %s\n' "健康探针" "$([ "$healthy" = "yes" ] && printf '%s通过%s' "$C_GREEN" "$C_RESET" || printf '%s无响应%s' "$C_RED" "$C_RESET")"
printf ' %-10s %s\n' "安装形态" "${MODE}" printf ' %-10s %s\n' "安装形态" "${MODE}"
@@ -1522,7 +1522,7 @@ EOF
cmd_help() { cmd_help() {
cat <<EOF cat <<EOF
${C_BOLD}班级小助手 · 运维命令${C_RESET} ${C_BOLD}ClassHelper · 运维命令${C_RESET}
用法:classhelper <子命令> [参数] 用法:classhelper <子命令> [参数]
@@ -1534,7 +1534,7 @@ ${C_BOLD}查看与诊断${C_RESET}
${C_BOLD}账号${C_RESET} ${C_BOLD}账号${C_RESET}
password [用户名] 修改密码(默认 admin);交互输入、不回显、不进历史 password [用户名] 修改密码(默认 admin);交互输入、不回显、不进历史
password --list 列出可登录账号与班级账号(含是否已设密码) password --list 列出可登录账号与班级账号(含是否已设密码)
password --class <班级码> 修改班级密码(学生端用「班级码 + 班级密码」登录) password --class <班级码> 修改班级密码(ClassHelper 班级端用「班级码 + 班级密码」登录)
${C_BOLD}备份与恢复${C_RESET} ${C_BOLD}备份与恢复${C_RESET}
backup [--note 说明] [--keep N] 备份配置 + 数据库到 ${BACKUP_DIR} backup [--note 说明] [--keep N] 备份配置 + 数据库到 ${BACKUP_DIR}
+2 -2
View File
@@ -1,4 +1,4 @@
# 班级小助手 · Docker 单容器部署(SQLite + 数据卷) # ClassHelper · Docker 单容器部署(SQLite + 数据卷)
# #
# 适用:云服务器 / 与别的服务共用一台机器。相比 deploy/docker-compose.yml(MySQL 变体) # 适用:云服务器 / 与别的服务共用一台机器。相比 deploy/docker-compose.yml(MySQL 变体)
# 少跑一个数据库容器,内存占用小很多;数据落在 Docker 卷 classhelper-data 里。 # 少跑一个数据库容器,内存占用小很多;数据落在 Docker 卷 classhelper-data 里。
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.1.2} image: classhelper-server:${APP_VERSION:-1.2.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+2 -2
View File
@@ -1,4 +1,4 @@
# 班级小助手 · 生产部署(MySQL + 服务端 + 可选 HTTPS 反代) # ClassHelper · 生产部署(MySQL + 服务端 + 可选 HTTPS 反代)
# #
# 使用: # 使用:
# 1) 复制环境变量模板并填写密钥:cp deploy/.env.example deploy/.env # 1) 复制环境变量模板并填写密钥:cp deploy/.env.example deploy/.env
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.1.2} image: classhelper-server:${APP_VERSION:-1.2.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+8 -8
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 班级小助手 · 服务端 Linux 一键安装器 # ClassHelper · 服务端 Linux 一键安装器
# #
# 支持:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+ # 支持:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+
# (自动识别 apt / yum / dnf;直接安装形态要求 systemd) # (自动识别 apt / yum / dnf;直接安装形态要求 systemd)
@@ -53,7 +53,7 @@ set -euo pipefail
# ---------------------------------------------------------------- 集中变量 # ---------------------------------------------------------------- 集中变量
PRODUCT="班级小助手" PRODUCT="ClassHelper"
SERVICE_NAME="classhelper" SERVICE_NAME="classhelper"
INSTALL_DIR="/opt/classhelper" INSTALL_DIR="/opt/classhelper"
@@ -179,7 +179,7 @@ usage() {
return 0 return 0
fi fi
cat <<'USAGE' cat <<'USAGE'
班级小助手 · 服务端 Linux 安装器 ClassHelper · 服务端 Linux 安装器
(本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数; (本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数;
完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/management/linux-deploy.md ) 完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/management/linux-deploy.md )
@@ -793,7 +793,7 @@ write_config() {
cat >"$CONFIG_FILE" <<EOF cat >"$CONFIG_FILE" <<EOF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 班级小助手服务端配置(由 install.sh 生成于 $(date '+%F %T')) # ClassHelper 服务端配置(由 install.sh 生成于 $(date '+%F %T'))
# #
# 这是**唯一的配置真身**:安装目录里的 .env 是指向本文件的软链, # 这是**唯一的配置真身**:安装目录里的 .env 是指向本文件的软链,
# systemd 通过 EnvironmentFile 读本文件;改完执行 classhelper restart 生效。 # systemd 通过 EnvironmentFile 读本文件;改完执行 classhelper restart 生效。
@@ -830,7 +830,7 @@ BCRYPT_ROUNDS=10
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
${admin_line} ${admin_line}
# 新建班级 / 重置教师账号时的默认密码(学生端用「班级码 + 班级密码」登录) # 新建班级 / 重置教师账号时的默认密码(ClassHelper 班级端用「班级码 + 班级密码」登录)
DEFAULT_CLASS_PASSWORD=123456 DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456 DEFAULT_TEACHER_PASSWORD=123456
@@ -973,7 +973,7 @@ install_docker_engine() {
write_compose() { write_compose() {
local compose="${CONFIG_DIR}/compose.yml" local compose="${CONFIG_DIR}/compose.yml"
cat >"$compose" <<EOF cat >"$compose" <<EOF
# 班级小助手 · 由 install.sh 生成的 compose 文件(${MODE} 形态) # ClassHelper · 由 install.sh 生成的 compose 文件(${MODE} 形态)
# 配置见 ${CONFIG_FILE}(改完执行 classhelper restart 重建容器) # 配置见 ${CONFIG_FILE}(改完执行 classhelper restart 重建容器)
# 数据在 Docker 卷 classhelper-data 里;备份用 classhelper backup # 数据在 Docker 卷 classhelper-data 里;备份用 classhelper backup
# #
@@ -1025,7 +1025,7 @@ setup_nginx() {
install -m 600 "${INSTALL_DIR}/nginx.conf" "$target" install -m 600 "${INSTALL_DIR}/nginx.conf" "$target"
else else
cat >"$target" <<EOF cat >"$target" <<EOF
# 班级小助手 · Nginx 反向代理(替换 server_name 与证书路径后启用) # ClassHelper · Nginx 反向代理(替换 server_name 与证书路径后启用)
server { server {
listen 80; listen 80;
server_name class.example.com; server_name class.example.com;
@@ -1148,7 +1148,7 @@ print_result() {
printf ' 1. 改掉默认密码:%sclasshelper password admin%s\n' "$C_CYAN" "$C_RESET" printf ' 1. 改掉默认密码:%sclasshelper password admin%s\n' "$C_CYAN" "$C_RESET"
printf ' 2. 公网访问请上 HTTPS:改好 %s/nginx.conf 的域名与证书,并把 CORS_ORIGIN\n' "$CONFIG_DIR" printf ' 2. 公网访问请上 HTTPS:改好 %s/nginx.conf 的域名与证书,并把 CORS_ORIGIN\n' "$CONFIG_DIR"
printf ' 收敛到你的域名(classhelper config set CORS_ORIGIN https://your.domain)\n' printf ' 收敛到你的域名(classhelper config set CORS_ORIGIN https://your.domain)\n'
printf ' 3. 学生端「服务器地址」填 http://%s:%s 或你的域名\n' "$ip" "$PORT" printf ' 3. ClassHelper 班级端「服务器地址」填 http://%s:%s 或你的域名\n' "$ip" "$PORT"
printf '%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET" printf '%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET"
} }
+1 -1
View File
@@ -1,5 +1,5 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 班级小助手 · 日志轮转 # ClassHelper · 日志轮转
# 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper # 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper
# #
# copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关" # copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关"
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "1.1.2", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0", "@prisma/adapter-libsql": "7.10.0",
+1 -1
View File
@@ -1,5 +1,5 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 班级小助手 · 登录 shell 环境变量 # ClassHelper · 登录 shell 环境变量
# #
# 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。 # 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。
# 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。 # 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs) * ClassHelper · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
* *
* 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急, * 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急,
* 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。 * 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。
@@ -88,7 +88,7 @@ async function loadServerModules() {
return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password }; return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password };
} }
/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */ /** 目标账号:普通用户按 username,班级账号按 Class.code(ClassHelper 班级端登录用的是班级码) */
async function resolveTarget(prisma, key) { async function resolveTarget(prisma, key) {
if (key.startsWith('class:')) { if (key.startsWith('class:')) {
const code = key.slice('class:'.length).toUpperCase(); const code = key.slice('class:'.length).toUpperCase();
@@ -100,7 +100,7 @@ async function resolveTarget(prisma, key) {
if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND }; if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND };
if (user.role === 'STUDENT') { if (user.role === 'STUDENT') {
return { return {
error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`, error: `学生没有个人账号(${key} 只是名单记录),ClassHelper 班级端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
code: EXIT.NOT_FOUND, code: EXIT.NOT_FOUND,
}; };
} }
@@ -174,7 +174,7 @@ async function cmdList(modules, asJson) {
for (const user of users) { for (const user of users) {
process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`); process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`);
} }
process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n'); process.stdout.write('\n班级账号(ClassHelper 班级端用「班级码 + 班级密码」登录):\n');
for (const item of classes) { for (const item of classes) {
const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)'; const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)';
process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`); process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`);
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 班级小助手 · Linux 安装与运维链路验收(在**已安装的服务器上**运行) # ClassHelper · Linux 安装与运维链路验收(在**已安装的服务器上**运行)
# #
# 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 → # 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 →
# 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载 # 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载
@@ -74,7 +74,7 @@ CURL=(curl -fsS --noproxy '*')
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi
printf '%s班级小助手 · Linux 安装验收%s\n' "$B" "$N" printf '%sClassHelper · Linux 安装验收%s\n' "$B" "$N"
note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}" note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}"
# ---------------------------------------------------------------- 1. 安装结果 # ---------------------------------------------------------------- 1. 安装结果
+1 -1
View File
@@ -12,7 +12,7 @@
<base href="{{BASE}}" /> <base href="{{BASE}}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>页面不存在 | 班级小助手文档</title> <title>页面不存在 | ClassHelper文档</title>
<script> <script>
// 与 docfx 主题选择同一套 key(theme 存 light / dark / auto),避免两边主题不一致 // 与 docfx 主题选择同一套 key(theme 存 light / dark / auto),避免两边主题不一致
const theme = localStorage.getItem('theme') || 'auto'; const theme = localStorage.getItem('theme') || 'auto';
+1 -1
View File
@@ -19,7 +19,7 @@ description: 老师点名让学生过来:普通与紧急两档、快捷短语
## 两档 ## 两档
| 级别 | 落库级别 | 学生端行为 | | 级别 | 落库级别 | ClassHelper 班级端行为 |
| ------------ | -------- | ----------------------------------------------------------------------------------------------------- | | ------------ | -------- | ----------------------------------------------------------------------------------------------------- |
| **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) | | **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) |
| **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 | | **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
+6 -6
View File
@@ -1,11 +1,11 @@
--- ---
title: 班级、学生与教师 title: 班级、学生与教师
description: 班级账号、学生名单、班主任与协作教师,以及"谁能看什么、谁能改什么"。 description: 班级账号、学生名单、班主任与各科任课老师,以及"谁能看什么、谁能改什么"。
--- ---
# 班级、学生与教师 # 班级、学生与教师
## 班级账号:学生端的身份 ## 班级账号:ClassHelper 班级端的身份
学生**没有个人账号**。一台教室机器用一个**班级账号**登录: 学生**没有个人账号**。一台教室机器用一个**班级账号**登录:
@@ -36,7 +36,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
| 密码 | **没有密码概念**。学生的 `passwordHash` 是空串占位,不能登录,也没有任何重置密码接口 | | 密码 | **没有密码概念**。学生的 `passwordHash` 是空串占位,不能登录,也没有任何重置密码接口 |
| 所属班级 | 一个学生属于一个主班级 | | 所属班级 | 一个学生属于一个主班级 |
> 用学生用户名去登录会被拒。就算拿教师 / 管理员账号去试,服务端也会在**密码校验之前**先判角色 —— > 学生**不是账号**(`Student` 表里没有密码、没有角色),拿学号去登录只会得到 401 ——
> 否则"密码错(401)"和"密码对但学生账号已停用(403)"的差异会把学生账号密码变成可离线验证的预言机。 > 否则"密码错(401)"和"密码对但学生账号已停用(403)"的差异会把学生账号密码变成可离线验证的预言机。
## 教师 ## 教师
@@ -46,7 +46,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
| 新建 / 编辑 / 删除 | 「教师管理」 | **仅管理员** | | 新建 / 编辑 / 删除 | 「教师管理」 | **仅管理员** |
| 导入名单(xlsx/csv) | 「教师管理 → 导入名单」 | **仅管理员** | | 导入名单(xlsx/csv) | 「教师管理 → 导入名单」 | **仅管理员** |
| 重置密码 | 「教师管理」(可指定新密码,留空用默认初始密码) | **仅管理员** | | 重置密码 | 「教师管理」(可指定新密码,留空用默认初始密码) | **仅管理员** |
| 加进某个班 | 班级详情 → 协作教师 | 管理员 / 教师 | | 加进某个班 | 班级详情 → 任课老师(班级 + 科目 + 教师) | 管理员 |
| 换班主任 | 班级详情顶部「更换班主任」 | **仅管理员** | | 换班主任 | 班级详情顶部「更换班主任」 | **仅管理员** |
> **教师录入只有管理员能做**:`/api/teachers` 整组接口都是 `requireRole('ADMIN')`,教师调用返回 403。 > **教师录入只有管理员能做**:`/api/teachers` 整组接口都是 `requireRole('ADMIN')`,教师调用返回 403。
@@ -58,7 +58,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
1. 他还是某个班的**班主任**; 1. 他还是某个班的**班主任**;
2. 名下有**课程**; 2. 名下有**课程**;
3. 有**班级分配**(协作教师); 3. 有**任课关系**(`Course.teacherId === 他`);
4. **发布过作业或通知**。 4. **发布过作业或通知**。
(后两项曾经漏检,会静默删掉该教师发过的作业与通知。) (后两项曾经漏检,会静默删掉该教师发过的作业与通知。)
@@ -71,7 +71,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
| ------------------ | ------------------------------ | -------------------------- | | ------------------ | ------------------------------ | -------------------------- |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | | `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | | 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | `ClassTeacher` 里有当前用户 | 被分配的班级 | | 科任老师 `SUBJECT` | 该班 `Course.teacherId` 有当前用户 | 被任课的班级 |
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) | | 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) |
### 权限矩阵(摘要) ### 权限矩阵(摘要)
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
title: 成绩 title: 成绩
description: 成绩录入与导入、等级与得分率、学生端看到的全班总览。 description: 成绩录入与导入、等级与得分率、ClassHelper 班级端看到的全班总览。
--- ---
# 成绩 # 成绩
@@ -31,7 +31,7 @@ Web 管理端「成绩录入」页:
## 学生看到的是什么 ## 学生看到的是什么
学生端(教室机器)用的是**班级账号**,所以它看到的不是"我的成绩",而是**全班成绩总览**: ClassHelper 班级端(教室机器)用的是**班级账号**,所以它看到的不是"我的成绩",而是**全班成绩总览**:
- 表格:分数 / **得分率** / **等级**; - 表格:分数 / **得分率** / **等级**;
- 柱状图; - 柱状图;
+4 -4
View File
@@ -15,7 +15,7 @@ description: 成绩、学生名单、教师名单的表格导入,以及从 Cla
1. **下载模板** —— `GET /imports/template?kind=grades&format=xlsx|csv`。 1. **下载模板** —— `GET /imports/template?kind=grades&format=xlsx|csv`。
CSV 带 BOM,Excel 直接双击打开不乱码。 CSV 带 BOM,Excel 直接双击打开不乱码。
2. **上传预览** —— 服务端返回列名、前 20 行、总行数与**建议的字段映射**,以及校验问题。 2. **上传预览** —— 服务端返回列名、前 20 行、总行数与**建议的字段映射**,以及校验问题。
列名按同义词自动映射(「学号→学生用户名」「得分→分数」「满分→总分」……),也可以手动改。 列名按同义词自动映射(「用户名→学号」「得分→分数」「满分→总分」「课程→科目」……),也可以手动改。
**缺必填列不会静默通过**,会直接给出「缺少必填列「…」」。 **缺必填列不会静默通过**,会直接给出「缺少必填列「…」」。
3. **确认导入** —— 按映射与写入模式提交: 3. **确认导入** —— 按映射与写入模式提交:
@@ -34,7 +34,7 @@ description: 成绩、学生名单、教师名单的表格导入,以及从 Cla
## 1. 成绩表格导入 ## 1. 成绩表格导入
- 入口:成绩录入 → 「导入表格」 - 入口:成绩录入 → 「导入表格」
- 模板列:`学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程` - 模板列:`学生学号 / 学生姓名 / 考试名称 / 分数 / 总分 / 科目 / 等级`
- 支持 `.xlsx / .xls / .csv / .tsv`(自动识别分隔符) - 支持 `.xlsx / .xls / .csv / .tsv`(自动识别分隔符)
- **重复判定**:同班级 + 同学生 + 同考试 + 同课程 - **重复判定**:同班级 + 同学生 + 同考试 + 同课程
- 学生按「用户名」优先、「姓名」兜底匹配 - 学生按「用户名」优先、「姓名」兜底匹配
@@ -42,14 +42,14 @@ description: 成绩、学生名单、教师名单的表格导入,以及从 Cla
## 2. 学生名单导入 ## 2. 学生名单导入
- 入口:学生管理 → 「导入名单」(仅管理员) - 入口:学生管理 → 「导入名单」(仅管理员)
- 模板列:`用户名 / 姓名` —— 学生没有账号属性,**没有密码列** - 模板列:`学号 / 姓名 / 班级 / 性别 / 家长手机号` —— 学生**不是账号**,没有密码列
- 用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$` - 用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$`
- 用户名已经被某个教师 / 管理员账号占用时,**这一行进错误列表**,不会覆盖他人账号 - 用户名已经被某个教师 / 管理员账号占用时,**这一行进错误列表**,不会覆盖他人账号
## 3. 教师名单导入 ## 3. 教师名单导入
- 入口:教师管理 → 「导入名单」(仅管理员) - 入口:教师管理 → 「导入名单」(仅管理员)
- 模板列:`用户名 / 姓名 / 初始密码 / 角色` - 模板列:`工号 / 姓名 / 手机号 / 初始密码 / 角色`
- 角色取 `ADMIN` / `TEACHER`,留空按 `TEACHER` - 角色取 `ADMIN` / `TEACHER`,留空按 `TEACHER`
- 与新建教师同一套校验,导入完可以直接登录 - 与新建教师同一套校验,导入完可以直接登录
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
title: 应用帮助 title: 应用帮助
description: 班级小助手每一项功能怎么用:课表、作业、通知、叫人、成绩、班级、灵动岛、外观与导入。 description: ClassHelper每一项功能怎么用:课表、作业、通知、叫人、成绩、班级、灵动岛、外观与导入。
--- ---
# 应用帮助 # 应用帮助
@@ -15,7 +15,7 @@ description: 班级小助手每一项功能怎么用:课表、作业、通知
| [通知与提醒](notifications.md) | 三档优先级、上课时段策略、二次确认、已读与红点 | | [通知与提醒](notifications.md) | 三档优先级、上课时段策略、二次确认、已读与红点 |
| [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 | | [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 |
| [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 | | [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 |
| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与协作教师、谁看得见什么 | | [班级、学生与教师](classes.md) | 班级账号、名单、班主任与各科任课老师、谁看得见什么 |
| [灵动岛](island.md) | 胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏 | | [灵动岛](island.md) | 胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏 |
| [外观、主题与动效](appearance.md) | 深浅色、设计令牌、动效语言、降低动态效果 | | [外观、主题与动效](appearance.md) | 深浅色、设计令牌、动效语言、降低动态效果 |
| [表格与课表导入](imports.md) | 成绩 / 名单 / 教师表格导入与 ClassIsland 档案导入 | | [表格与课表导入](imports.md) | 成绩 / 名单 / 教师表格导入与 ClassIsland 档案导入 |
+2 -2
View File
@@ -10,7 +10,7 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
## 三档优先级 ## 三档优先级
| 优先级 | 学生端表现 | | 优先级 | ClassHelper 班级端表现 |
| ------ | -------------------------------------------------------------------------------------------- | | ------ | -------------------------------------------------------------------------------------------- |
| 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 | | 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 |
| 重要 | 同上,卡片上有优先级标签 | | 重要 | 同上,卡片上有优先级标签 |
@@ -22,7 +22,7 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后: 「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后:
| 学生端行为 | 上课时段 | 课间 / 放学 | | ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 |
| ---------- | ---------------------------------------- | ----------- | | ---------- | ---------------------------------------- | ----------- |
| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 | | 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 |
| 紧急通知 | **立刻弹** | 立刻弹 | | 紧急通知 | **立刻弹** | 立刻弹 |
+5 -5
View File
@@ -1,6 +1,6 @@
--- ---
title: REST API title: REST API
description: 班级小助手后端全部接口一览:方法、路径、权限与说明。 description: ClassHelper后端全部接口一览:方法、路径、权限与说明。
--- ---
# REST API # REST API
@@ -17,13 +17,13 @@ description: 班级小助手后端全部接口一览:方法、路径、权限
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ | | --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) | | POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 | | POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) | | PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 协作教师) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) | | POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 | | PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | | PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
@@ -33,7 +33,7 @@ description: 班级小助手后端全部接口一览:方法、路径、权限
| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 | | PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 |
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 | | GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 |
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 | | DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 | | PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) |
| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 | | GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 |
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) | | GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) |
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | | GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
@@ -72,7 +72,7 @@ description: 班级小助手后端全部接口一览:方法、路径、权限
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | | GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | | POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | | POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 14 张表行数 / 备份列表 / 定时配置) | | GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) | | POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | | GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | | POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
+67 -24
View File
@@ -1,6 +1,6 @@
--- ---
title: 数据模型 title: 数据模型
description: 14 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。 description: 16 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
--- ---
# 数据模型 # 数据模型
@@ -15,45 +15,83 @@ description: 14 张表的职责、跨库通用约定、迁移历史与启动时
- 因此切库只需改 `provider` 与连接串(`pnpm db:switch:mysql` 就地改写它,没有第二份 schema); - 因此切库只需改 `provider` 与连接串(`pnpm db:switch:mysql` 就地改写它,没有第二份 schema);
- 主键统一用 cuid 字符串,便于多端离线生成与合并。 - 主键统一用 cuid 字符串,便于多端离线生成与合并。
## 14 张表 ## 两条核心不变量
这两条是 2026-10-06 那次重构立下的,后面所有表都围着它们转:
**① 学生不是账号。** `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号 /
状态,**没有 `passwordHash`、没有 `role`、没有登录字段** —— 「学生不能登录」由表结构保证,
不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
`User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
**② 任课关系的唯一来源是 `Course`。** `Course(classId, name, teacherId)` 就是
「班级 + 科目 + 教师」这条关系本身,作业与成绩都挂在它上面;班主任另由 `Class.teacherId` 表示
(每班 1 人)。历史上那张**没有科目**的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
## 16 张表
| 表 | 职责 | 值得注意的字段 | | 表 | 职责 | 值得注意的字段 |
| ------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `User` | 教师 / 管理员 / 学生统一存储 | `role`、`classId`;学生 `passwordHash` 是空串占位,不能登录 | | `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`;**学生不在这里** |
| `Class` | 班级 | `code` + `passwordHash`(班级账号)、`termWeeks`、`notificationChannel` | | `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
| `ClassTeacher` | 教师-班级 分配关系(协作教师) | `@@unique([classId, teacherId])` | | `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
| `Enrollment` | 学生-班级 归属记录 | 与 `User.classId` 同步,为将来多班预留 | | `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
| `Course` | 课程(班级维度) | `@@unique([classId, name])` | | `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`notificationChannel`、`studentGradeQueryEnabled`、`archivedYearId` |
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) | | `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) |
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 | | `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, userId])` | | `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
| `Notification` | 通知 | `priority` | | `Notification` | 通知 | `priority` |
| `NotificationRead` | 已读记录 | `@@unique([notificationId, userId])` | | `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` | | `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
| `Grade` | 成绩 | `score` / `totalScore` / `examName` | | `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
| `IntegrationDevice` | ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`tokenHint`、两个开关、最后一次上报的快照字段 | | `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 |
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` | | `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` |
### 三个容易忽略的设计 ### 几个容易忽略的设计
**`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。 **`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。
设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。 设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。
`expiresAt` 防止学生几天后突然弹出旧提醒。 `expiresAt` 防止学生几天后突然弹出旧提醒。
**`Schedule.source`** —— 决定插件全量上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。 **在线判定看 `lastHeartbeatAt`,不看 `lastSeenAt`** —— 同一台设备的上报其实有两种:
心跳(每 60 秒一次,只表明"我还在线")与带状态的上报(课表 / 上课状态)。
合成一个字段后无法区分"在线但一直没新状态"和"整个失联",所以拆开:
`lastHeartbeatAt` 每次上报都刷(60 秒窗口判在线),`lastSeenAt` 只在带运行状态时刷。
**课表同步方向:服务端 → 教室是主方向。**
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端自动下发),
`syncScheduleToServer` 默认 **false**(教室不再自动回传)。要取教室的课表,走
`POST /integrations/devices/:id/request-schedule` 这个**人工动作**(前端点击前会弹警告)——
自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
**`Schedule.source`** —— 决定插件上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。
**`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。 **`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。 **`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。 保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
## 迁移历史(10 个) **`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
## 迁移历史(16 个)
``` ```
init → add_time_layout → add_class_account → add_schedule_week_parity → init → add_time_layout → add_class_account → add_schedule_week_parity →
add_class_term_weeks → add_classisland_integration → add_class_notification_channel → add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
add_homework_assign_date → add_push_kind → add_schedule_source add_homework_assign_date → add_push_kind → add_schedule_source →
add_student_table → add_student_grade_query → add_grade_level →
add_classhelper_heartbeat → add_archives → add_term_weeks
``` ```
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
把 `User` 里 `role='STUDENT'` 的行**按 id 原样搬过去**、把 `Grade` / `HomeworkStatus` /
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
### 启动时自动迁移 ### 启动时自动迁移
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名: `src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
@@ -69,19 +107,24 @@ add_homework_assign_date → add_push_kind → add_schedule_source
## 备份格式就是这份模型的拓扑序导出 ## 备份格式就是这份模型的拓扑序导出
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:14 张表按拓扑序导出, 「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:16 张表按拓扑序导出
恢复时**临时关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User` (见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`),恢复时**临时关闭外键检查** ——
**互相引用**,任何排序都会被其中一侧卡死。见[数据库管理](../management/database.md)。 因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
见[数据库管理](../management/database.md)。
## 开发时的模型改动 ## 开发时的模型改动
1. 改 `schema.prisma`; 1. 改 `schema.prisma`;
2. 在 `prisma/migrations/` 下加一个迁移目录(本机用 2. 在 `prisma/migrations/` 下加一个迁移目录(本机用
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** —— `pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
它在 Windows 上不可靠); 它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
3. 改 schema 时先看**哪些外键指向 `User` 且是 Cascade**,再回来核对 `pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
`deleteTeacher` 的护栏清单(它必须覆盖 `Class.teacherId` / `Course.teacherId` / 生成骨架,再手工补数据搬运语句;
`ClassTeacher.teacherId` / `Homework.createdBy` / `Notification.createdBy` 五项)。 3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单
(它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` /
`Notification.createdBy` 四项;`ClassIslandPush.createdBy` 是裸列、无外键、不级联,不用加);
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准; 模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
本文里的数字标注的是当前版本(1.1.2)的状态。 本文里的数字标注的是当前版本(1.2.0)的状态。
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
title: 开发文档 title: 开发文档
description: 班级小助手的架构、接口、数据模型、权限、动效层,以及验收与发版流程。 description: ClassHelper的架构、接口、数据模型、权限、动效层,以及验收与发版流程。
--- ---
# 开发文档 # 开发文档
班级小助手是一个 **pnpm monorepo**,一份代码产出四个交付物: ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
| 交付物 | 位置 | 形态 | | 交付物 | 位置 | 形态 |
| -------------------- | ----------------------------- | --------------------------------------------------------------- | | -------------------- | ----------------------------- | --------------------------------------------------------------- |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` | | 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) | | Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生端 / 教室机器,含灵动岛浮窗) | | 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 | | 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) | | ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) |
@@ -38,7 +38,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 | | [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) | | [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 | | [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
| [数据模型](data-model.md) | 14 张表、迁移历史、跨库约定 | | [数据模型](data-model.md) | 16 张表、迁移历史、跨库约定 |
| [角色与权限](permissions.md) | 权限矩阵与实现位置 | | [角色与权限](permissions.md) | 权限矩阵与实现位置 |
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 | | [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 | | [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
+54 -28
View File
@@ -1,6 +1,6 @@
--- ---
title: 角色与权限 title: 角色与权限
description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条纪律。 description: 权限矩阵、科目维度的收口规则,以及"前端隐藏 ≠ 权限"这条纪律。
--- ---
# 角色与权限 # 角色与权限
@@ -17,33 +17,55 @@ description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条
## 角色判定 ## 角色判定
角色只有三种,**没有 `STUDENT`** —— 学生不是账号(见[数据模型](data-model.md))。
班级内的角色由「账号角色 + 与该班级的关系」共同决定: 班级内的角色由「账号角色 + 与该班级的关系」共同决定:
| 角色 | 判定方式 | 可见范围 | | 角色 | 判定方式 | 可见范围 |
| ------------------ | ------------------------------ | -------------------------- | | --------------------------- | ------------------------------------------ | ------------------------------------ |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | | `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | | 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 | | 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) | | `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录(`classSession`) | 本班(代全班读写个人数据) |
| `NONE` | 与这个班没有任何关系 | 什么都看不到 | | `NONE` | 与这个班没有任何关系 | 什么都看不到 |
判定函数是 `resolveClassRole()`。 判定函数是 `resolveClassRole()`。「科任」的判定源是 **`Course`**(班级 + 科目 + 教师),
不是那张已经没有的 `ClassTeacher` 协作表。
## 权限矩阵 ## 权限矩阵
| 操作 | 管理员 | 班主任 | 科任老师 | | 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
| -------------------------------------------------- | :----: | :----------: | :------: | | -------------------------------------------------------- | :----: | :----------: | :------: | :----: |
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | | 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
| 分配班主任与科任老师 | ✅ | ❌ | ❌ | | 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
| 学生名单管理(增删 / 导入) | ✅ | ❌ | ❌ | | 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | | 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ |
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | | 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ |
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | | 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ |
| 布置作业 | ✅ | ✅ | ✅ | | 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 叫人 | ✅ | ✅ | ✅ | | 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ |
| 发布通知 | ✅ | ✅ | ✅ | | 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ |
| 成绩录入 / 修改 / 导入(仅本班) | ✅ | ✅(仅本班) | ❌ | | 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ |
| 班级密码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | | 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ |
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 |
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ |
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ |
## 科目维度的收口(本次重构的核心规则)
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
| 情形 | 谁可以写 |
| --------------------------------------- | ------------------------------------------ |
| 管理员 | 全部 |
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
| 没挂科目(`courseId` 为空) | 本班**班主任** |
| ClassHelper 班级端 | 只有**作业**可以(老师在教室随手录一条);**成绩不给** |
于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
作业 / 成绩 / 叫人三处写入统一走它。
## 实现要点 ## 实现要点
@@ -53,22 +75,27 @@ description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条
``` ```
assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster
assertCanManageSchedule / assertCanPublishContent / assertCanManageGrades assertCanManageSchedule / assertCanPublishNotification
assertCanManageGrades / assertCanManageSubjectContent
``` ```
**列表数据用 `resolveClassScope()` 收敛**(`classId in [...]`),单条接口直接断言; **列表数据用 `resolveClassScope()` 收敛**(`classId in [...]`),单条接口直接断言;
越权一律 403。 越权一律 403。
**鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次用户与班级关系, **鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次账号,
所以改了班级 / 角色会**立即生效**,不用等令牌过期。班级会话则回查 `Class` 表。 所以改了角色 / 班级关系会**立即生效**,不用等令牌过期。班级端会话则回查 `Class` 表。
**班级端的成绩查询开关**:`Class.studentGradeQueryEnabled`(默认开启)。
关闭后班级端调 `GET /grades/student/:studentNo` 一律 403,教师端不受影响 ——
这就是需求「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
## 前端隐藏 ≠ 权限 ## 前端隐藏 ≠ 权限
前端的隐藏只是"别让人看见入口": 前端的隐藏只是"别让人看见入口":
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 数据库管理」; - 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理」;
- 课表页的增删改按钮只对「管理员或本班班主任」显示; - 课表页的增删改按钮只对「管理员或本班班主任」显示;
- 成绩页的录入 / 删除按钮同理。 - 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘), 但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。 而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
@@ -76,10 +103,9 @@ assertCanManageSchedule / assertCanPublishContent / assertCanManageGrades
> 新增视图时别忘了两处:路由的 `meta.roles` + 服务层对应 service 里的 `assertCanXxx`。 > 新增视图时别忘了两处:路由的 `meta.roles` + 服务层对应 service 里的 `assertCanXxx`。
> 只做其中一个都等于没做。 > 只做其中一个都等于没做。
## 学生账号的两个反向约束 ## 学生没有账号的两个反向约束
1. **学生不能登录**。`/auth/login` 遇到 `role=STUDENT` 直接 403; 1. **学生不能登录,而且根本不存在可以登录的学生账号**。`User` 表里没有学生行,
且**角色判定在密码校验之前** —— 否则"密码错(401) / 密码对但学生账号已停用(403)"的差异, 拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
会把学生账号密码变成可离线验证的预言机。
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404), 2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
新建学生也不设密码(`passwordHash` 是空串占位)。 新建学生也不设密码 —— `Student` 表里压根没有那一列。
+1 -1
View File
@@ -44,7 +44,7 @@ io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权
| `user:{userId}` | 某个用户自己的连接 | | `user:{userId}` | 某个用户自己的连接 |
| `teacher:{teacherId}` | 某个教师 | | `teacher:{teacherId}` | 某个教师 |
| `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) | | `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) |
| `students` | 全部学生端连接 | | `students` | 全部 ClassHelper 班级端连接 |
| `teachers` | 全部教师端连接 | | `teachers` | 全部教师端连接 |
服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 —— 服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 ——
+1 -1
View File
@@ -575,7 +575,7 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
--- ---
## 附:班级小助手实际采纳了哪些(本次重写对照表) ## 附:ClassHelper实际采纳了哪些(本次重写对照表)
| WinIsland 做法 | 出处 | 我们的落地 | 验证 | | WinIsland 做法 | 出处 | 我们的落地 | 验证 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
+3 -3
View File
@@ -16,8 +16,8 @@
"output": "_site", "output": "_site",
"template": ["default", "modern", "templates/classhelper"], "template": ["default", "modern", "templates/classhelper"],
"globalMetadata": { "globalMetadata": {
"_appName": "班级小助手", "_appName": "ClassHelper",
"_appTitle": "班级小助手文档", "_appTitle": "ClassHelper文档",
"_appLogoPath": "logo.png", "_appLogoPath": "logo.png",
"_appLogoUrl": "", "_appLogoUrl": "",
"_appFaviconPath": "favicon.png", "_appFaviconPath": "favicon.png",
@@ -27,7 +27,7 @@
"repo": "https://github.com/LoveChengke/classhelper", "repo": "https://github.com/LoveChengke/classhelper",
"branch": "master" "branch": "master"
}, },
"_appFooter": "<div class=\"ch-footer\"><span>班级小助手(Class Helper)· 文档</span><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper\">GitHub 仓库</a><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper/releases\">下载与更新日志</a><span class=\"ch-footer-sep\">·</span><span>构建于 docfx</span></div>" "_appFooter": "<div class=\"ch-footer\"><span>ClassHelper(Class Helper)· 文档</span><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper\">GitHub 仓库</a><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper/releases\">下载与更新日志</a><span class=\"ch-footer-sep\">·</span><span>构建于 docfx</span></div>"
}, },
"sitemap": { "sitemap": {
"baseUrl": "https://lovechengke.github.io/ClassHelper/docs/", "baseUrl": "https://lovechengke.github.io/ClassHelper/docs/",
+2 -2
View File
@@ -32,7 +32,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
### 安装包装不上,说应用正在运行 ### 安装包装不上,说应用正在运行
同上。托盘 → 退出班级小助手。 同上。托盘 → 退出 ClassHelper。
## 连接与登录 ## 连接与登录
@@ -104,7 +104,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
### 学生登录不了 ### 学生登录不了
**学生没有个人账号。** 学生端统一用**班级码 + 班级密码**登录(见[装学生端](get-started/client.md))。 **学生没有个人账号。** ClassHelper 班级端统一用**班级码 + 班级密码**登录(见[装 ClassHelper 班级端](get-started/client.md))。
用学生用户名去登录一定会失败,这是设计。 用学生用户名去登录一定会失败,这是设计。
### 客户端里找不到"修改密码" ### 客户端里找不到"修改密码"
+8 -8
View File
@@ -1,20 +1,20 @@
--- ---
title: 接入 ClassIsland title: 接入 ClassIsland
description: 把教室里的 ClassIsland 接上班级小助手:下课表上报、大屏提醒下发、课表镜像三个方向。 description: 把教室里的 ClassIsland 接上 ClassHelper:下课表上报、大屏提醒下发、课表镜像三个方向。
--- ---
# 接入 ClassIsland # 接入 ClassIsland
课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。 课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。
它跟班级小助手管的事情不重叠,接起来之后两边各拿各的好处: 它跟 ClassHelper管的事情不重叠,接起来之后两边各拿各的好处:
| 方向 | 做什么 | 老师在哪看 | | 方向 | 做什么 | 老师在哪看 |
| ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- | | ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- |
| ClassIsland → 班级小助手 | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 | | ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 |
| 班级小助手 → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 | | ClassHelper → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 |
| (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 | | (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 |
> 没接 ClassIsland 也能用班级小助手,只是教室里少一个大屏播报的出口。**这一步是可选的。** > 没接 ClassIsland 也能用 ClassHelper,只是教室里少一个大屏播报的出口。**这一步是可选的。**
## 前置:ClassIsland 装好了 ## 前置:ClassIsland 装好了
@@ -46,7 +46,7 @@ description: 把教室里的 ClassIsland 接上班级小助手:下课表上报
### 填令牌 ### 填令牌
ClassIsland 的**设置 → 班级小助手联动**里填: ClassIsland 的**设置 → ClassHelper 联动**里填:
| 设置项 | 说明 | | 设置项 | 说明 |
| ---------------------------- | --------------------------------------------------------------- | | ---------------------------- | --------------------------------------------------------------- |
@@ -85,7 +85,7 @@ ClassIsland 的**设置 → 班级小助手联动**里填:
| 要同步的内容 | 需要打开 | | 要同步的内容 | 需要打开 |
| -------------------------------- | ------------------------------------------------------------------------------- | | -------------------------------- | ------------------------------------------------------------------------------- |
| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 | | 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 |
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到班级小助手」 | | ClassIsland → 服务端(上报课表) | 插件设置「上报课表到 ClassHelper」 |
| 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 | | 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 |
## 上课时段:两边同一套规则 ## 上课时段:两边同一套规则
@@ -110,7 +110,7 @@ ClassIsland 侧的暂存由插件实现:它订阅课程服务,下课后把
## 镜像:把小助手的课表写回 ClassIsland ## 镜像:把小助手的课表写回 ClassIsland
开启后,插件会新建一份名为 `班级小助手-<班级名>` 的档案课表,**不动老师原有的课表**。 开启后,插件会新建一份名为 `ClassHelper-<班级名>` 的档案课表,**不动老师原有的课表**。
镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。 镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。
这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。 这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。
+5 -5
View File
@@ -1,9 +1,9 @@
--- ---
title: 装学生端 title: 装 ClassHelper 班级端
description: 在教室机器上安装班级小助手桌面客户端,用班级码登录,并第一次跑通实时通知。 description: 在教室机器上安装 ClassHelper桌面客户端,用班级码登录,并第一次跑通实时通知。
--- ---
# 装学生端 # 装 ClassHelper 班级端
桌面客户端装在**教室的那台机器**上(教室大屏、讲台电脑)。它是学生看课表作业的地方, 桌面客户端装在**教室的那台机器**上(教室大屏、讲台电脑)。它是学生看课表作业的地方,
也是「消息到达的第一现场」—— 灵动岛就长在这一端。 也是「消息到达的第一现场」—— 灵动岛就长在这一端。
@@ -14,8 +14,8 @@ description: 在教室机器上安装班级小助手桌面客户端,用班级
| 产物 | 用途 | | 产物 | 用途 |
| ------------------------------------ | ------------------------------------------ | | ------------------------------------ | ------------------------------------------ |
| `班级小助手-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 | | `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
| `班级小助手-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 | | `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
要自己打: 要自己打:
+6 -6
View File
@@ -1,6 +1,6 @@
--- ---
title: 发布第一条内容 title: 发布第一条内容
description: 发通知、布置作业、叫人、录成绩——把班级小助手真正用起来的第一轮操作。 description: 发通知、布置作业、叫人、录成绩——把 ClassHelper真正用起来的第一轮操作。
--- ---
# 发布第一条内容 # 发布第一条内容
@@ -11,7 +11,7 @@ description: 发通知、布置作业、叫人、录成绩——把班级小助
**通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。 **通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。
| 优先级 | 学生端表现 | | 优先级 | ClassHelper 班级端表现 |
| ------ | -------------------------------------------------------------------------- | | ------ | -------------------------------------------------------------------------- |
| 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 | | 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 |
| 重要 | 同上,卡片上带优先级标签 | | 重要 | 同上,卡片上带优先级标签 |
@@ -32,14 +32,14 @@ description: 发通知、布置作业、叫人、录成绩——把班级小助
附件支持站内相对路径或 `http(s)://` 链接(其他 scheme 服务端会拒,避免被当成脚本执行)。 附件支持站内相对路径或 `http(s)://` 链接(其他 scheme 服务端会拒,避免被当成脚本执行)。
学生端那边默认是**看板模式**:按科目分卡片、每条作业完整显示要求、整个看板缩放到刚好铺满屏幕; ClassHelper 班级端那边默认是**看板模式**:按科目分卡片、每条作业完整显示要求、整个看板缩放到刚好铺满屏幕;
老师也可以在教室机器上直接**录入作业**、勾选**未交名单**(教室机器用的是班级账号)。 老师也可以在教室机器上直接**录入作业**、勾选**未交名单**(教室机器用的是班级账号)。
## 叫一位同学 ## 叫一位同学
**通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。 **通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。
| 级别 | 学生端 | | 级别 | ClassHelper 班级端 |
| -------- | ------------------------------------------------------------------ | | -------- | ------------------------------------------------------------------ |
| 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 | | 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 |
| 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 | | 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
@@ -53,11 +53,11 @@ description: 发通知、布置作业、叫人、录成绩——把班级小助
**成绩录入** → 选班级与考试名称 → 单条录入 / 批量录入 / 导入表格。 **成绩录入** → 选班级与考试名称 → 单条录入 / 批量录入 / 导入表格。
- **科目**下拉同样是全校统一目录,选一个该班还没有的科目会自动建课。 - **科目**下拉同样是全校统一目录,选一个该班还没有的科目会自动建课。
- 导入表格支持 `.xlsx / .xls / .csv / .tsv`,列名按同义词自动映射(「学号→学生用户名」「得分→分数」), - 导入表格支持 `.xlsx / .xls / .csv / .tsv`,列名按同义词自动映射(「用户名→学号」「得分→分数」),
也可以手动改。**文件上限 8MB**,超了会返回明确的 413 而不是一句英文的内部错误。 也可以手动改。**文件上限 8MB**,超了会返回明确的 413 而不是一句英文的内部错误。
- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。 - 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。
学生端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。 ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
## 在教室大屏上播一条提醒(需要 ClassIsland) ## 在教室大屏上播一条提醒(需要 ClassIsland)
+3 -3
View File
@@ -1,11 +1,11 @@
--- ---
title: 快速上手 title: 快速上手
description: 从零把班级小助手跑起来:装服务端、认管理端、建班级排课、装学生端、接 ClassIsland、发第一条内容。 description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、接 ClassIsland、发第一条内容。
--- ---
# 快速上手 # 快速上手
班级小助手不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**: ClassHelper不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**:
| 组成部分 | 装在哪 | 谁用 | 必须先装? | | 组成部分 | 装在哪 | 谁用 | 必须先装? |
| ------------------------ | --------------------------------------- | -------------------------- | ---------------- | | ------------------------ | --------------------------------------- | -------------------------- | ---------------- |
@@ -20,7 +20,7 @@ description: 从零把班级小助手跑起来:装服务端、认管理端、
2. [安装服务端](server.md) —— Windows 安装包 / Linux 一键脚本 / Docker / 源码跑 2. [安装服务端](server.md) —— Windows 安装包 / Linux 一键脚本 / Docker / 源码跑
3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面 3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面
4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提 4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提
5. [装学生端](client.md) —— 教室机器用班级码登录 5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录
6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选) 6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选)
7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩 7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
title: 系统要求 title: 系统要求
description: 班级小助手服务端、Web 管理端、桌面客户端的运行环境与网络要求。 description: ClassHelper 服务端、Web 管理端、桌面客户端的运行环境与网络要求。
--- ---
# 系统要求 # 系统要求
@@ -38,7 +38,7 @@ ldd --version | head -1
| 屏幕 | 桌面 1280×720 以上;手机(≤768px)有一套独立的抽屉式布局,同样可用 | | 屏幕 | 桌面 1280×720 以上;手机(≤768px)有一套独立的抽屉式布局,同样可用 |
| 安装 PWA | 手机 / 桌面 Chrome 里「添加到主屏幕」可当 App 用(需要 HTTPS 或 localhost) | | 安装 PWA | 手机 / 桌面 Chrome 里「添加到主屏幕」可当 App 用(需要 HTTPS 或 localhost) |
## 桌面客户端(学生端 / 教室机器) ## 桌面客户端(ClassHelper 班级端 / 教室机器)
| 项目 | 要求 | | 项目 | 要求 |
| -------- | ---------------------------------------------------------------------------- | | -------- | ---------------------------------------------------------------------------- |
+4 -4
View File
@@ -1,6 +1,6 @@
--- ---
title: 安装服务端 title: 安装服务端
description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把班级小助手服务端跑起来。 description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把 ClassHelper 服务端跑起来。
--- ---
# 安装服务端 # 安装服务端
@@ -21,7 +21,7 @@ description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把班
### 生成安装包 ### 生成安装包
安装包不入库(体积大),从 [Releases](https://github.com/LoveChengke/classhelper/releases) 直接下载 安装包不入库(体积大),从 [Releases](https://github.com/LoveChengke/classhelper/releases) 直接下载
`班级小助手服务端-<版本>-x64-setup.exe` 即可。要自己打: `ClassHelper 服务端-<版本>-x64-setup.exe` 即可。要自己打:
```bash ```bash
pnpm dist:server # 首次会执行一次 npm install(约 2 分钟) pnpm dist:server # 首次会执行一次 npm install(约 2 分钟)
@@ -32,7 +32,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
| 路径 | 说明 | | 路径 | 说明 |
| --------------------------------------------------------------------- | ------------------------------------------- | | --------------------------------------------------------------------- | ------------------------------------------- |
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) | | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
| `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 | | `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 |
包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env` 包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env`
@@ -43,7 +43,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
双击安装程序即可,**不需要管理员权限**(当前用户级安装)。 双击安装程序即可,**不需要管理员权限**(当前用户级安装)。
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-<版本>-x64-setup.exe" /S` - 静默安装:`"ClassHelper 服务端-<版本>-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 自动加入当前用户开机自启 - 自动加入当前用户开机自启
+5 -5
View File
@@ -20,7 +20,7 @@ Web 管理端 → **班级管理** → 「新建班级」:
| 班级码 | 保存时自动生成(字母数字 4–16 位,存大写),可以改成好记的,例如 `G203` | | 班级码 | 保存时自动生成(字母数字 4–16 位,存大写),可以改成好记的,例如 `G203` |
| 班级密码 | 新建班级时的初始密码就是它 —— 学生在教室机器上要用这个登录 | | 班级密码 | 新建班级时的初始密码就是它 —— 学生在教室机器上要用这个登录 |
> **班级码 + 班级密码 = 学生端的登录凭据。** 学生端没有个人账号,一台教室机器用班级账号登录后, > **班级码 + 班级密码 = ClassHelper 班级端的登录凭据。** ClassHelper 班级端没有个人账号,一台教室机器用班级账号登录后,
> 那台机器上的「已读 / 完成」是**代全班**操作的。 > 那台机器上的「已读 / 完成」是**代全班**操作的。
> >
> 学期周数只有管理员能改:它决定"现在是第几教学周",乱改会让课表的周次过滤和上课时段判定错位。 > 学期周数只有管理员能改:它决定"现在是第几教学周",乱改会让课表的周次过滤和上课时段判定错位。
@@ -30,7 +30,7 @@ Web 管理端 → **班级管理** → 「新建班级」:
**学生管理** → 「新建」或「导入名单」: **学生管理** → 「新建」或「导入名单」:
- **单个新建**:填姓名即可,登录名会自动生成。 - **单个新建**:填姓名即可,登录名会自动生成。
- **导入名单**:下载模板(列只有 `用户名 / 姓名`,学生没有账号属性,**没有密码列**),填好上传。 - **导入名单**:下载模板(列是 `学号 / 姓名 / 班级 / 性别 / 家长手机号`,学生**不是账号**,没有密码列),填好上传。
用户名要匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复用户名在 `append` 模式下跳过、`upsert` 模式下更新姓名。 用户名要匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复用户名在 `append` 模式下跳过、`upsert` 模式下更新姓名。
用户名已经属于某个教师 / 管理员账号时,这一行会进错误列表,不会覆盖别人的账号。 用户名已经属于某个教师 / 管理员账号时,这一行会进错误列表,不会覆盖别人的账号。
@@ -39,7 +39,7 @@ Web 管理端 → **班级管理** → 「新建班级」:
## 3. 加科任老师 ## 3. 加科任老师
教师的**录入**只有管理员能做(**教师管理**页,支持单个录入与导入名单)。班级层面的**分配**在班级详情里: 教师的**录入**只有管理员能做(**教师管理**页,支持单个录入与导入名单)。班级层面的**分配**在班级详情里:
把老师加进「协作教师」,他就能看到这个班、给这个班发作业 / 通知 / 叫人。 在「班级详情 → 任课老师」里给某位老师配一门课,他就能看到这个班,并**只能改自己任教科目**的作业与成绩。
角色与可见范围见[班级、学生与教师](../app/classes.md)与[角色与权限](../dev/permissions.md)。 角色与可见范围见[班级、学生与教师](../app/classes.md)与[角色与权限](../dev/permissions.md)。
@@ -62,10 +62,10 @@ Web 管理端 → **班级管理** → 「新建班级」:
## 5. 确认「上课时段」判定是对的 ## 5. 确认「上课时段」判定是对的
班级小助手有一整套「上课时段策略」:普通通知在上课时段只进队列、下课自动补弹,紧急通知与叫人则立刻弹。 ClassHelper有一整套「上课时段策略」:普通通知在上课时段只进队列、下课自动补弹,紧急通知与叫人则立刻弹。
这套判定完全基于课表,所以**课表排错,提醒就会在该弹的时候不弹**。 这套判定完全基于课表,所以**课表排错,提醒就会在该弹的时候不弹**。
排完课之后建议立刻验一下:在**通知发布**页选这个班,随便发一条普通通知, 排完课之后建议立刻验一下:在**通知发布**页选这个班,随便发一条普通通知,
看客户端是不是在课间弹出来、上课时是不是安静地收着。规则详见[通知与提醒](../app/notifications.md)。 看客户端是不是在课间弹出来、上课时是不是安静地收着。规则详见[通知与提醒](../app/notifications.md)。
下一步:[装学生端](client.md)。 下一步:[装 ClassHelper 班级端](client.md)。
+1 -1
View File
@@ -8,7 +8,7 @@
href: web-admin.md href: web-admin.md
- name: 建班级、排课表 - name: 建班级、排课表
href: setup-class.md href: setup-class.md
- name: 装学生端 - name: 装 ClassHelper 班级端
href: client.md href: client.md
- name: 接入 ClassIsland - name: 接入 ClassIsland
href: classisland.md href: classisland.md
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
title: 认识 Web 管理端 title: 认识 Web 管理端
description: 登录班级小助手 Web 管理端,认一认 12 个页面、新手引导与手机端布局。 description: 登录 ClassHelper Web 管理端,认一认 12 个页面、新手引导与手机端布局。
--- ---
# 认识 Web 管理端 # 认识 Web 管理端
+2 -2
View File
@@ -12,7 +12,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
| 能力 | 说明 | | 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、14 张表的行数、连接串(MySQL 密码脱敏) | | 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、16 张表的行数、连接串(MySQL 密码脱敏) |
| 连接测试 | 对任意目标库试连(切换前的第一步) | | 连接测试 | 对任意目标库试连(切换前的第一步) |
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** | | **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 | | 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
@@ -23,7 +23,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**: **备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
- 14 张表按**拓扑序**导出; - 16 张表按**拓扑序**导出;
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User` - 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
是**互相引用**的,任何排序都会被其中一侧卡死; 是**互相引用**的,任何排序都会被其中一侧卡死;
- 分批 `createMany` 写入。 - 分批 `createMany` 写入。
+3 -3
View File
@@ -285,7 +285,7 @@ classhelper logs --unit classhelper-backup # 看定时备份的日志
sudo classhelper password # 改 admin sudo classhelper password # 改 admin
sudo classhelper password teacher1 # 改指定教师 sudo classhelper password teacher1 # 改指定教师
sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码) sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码)
sudo classhelper password --class G101 # 改某个班的班级密码(学生端登录用) sudo classhelper password --class G101 # 改某个班的班级密码(ClassHelper 班级端登录用)
``` ```
安全约定(照要求逐条落实): 安全约定(照要求逐条落实):
@@ -432,7 +432,7 @@ sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB | | 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` | | `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) | | 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
| 学生端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 | | ClassHelper 班级端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" | | CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
--- ---
@@ -527,7 +527,7 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
```bash ```bash
# ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录 # ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录
# ② 学生端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知 # ② ClassHelper 班级端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包> # ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包>
# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线) # 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线)
# ④ 卸载:sudo classhelper uninstall --yes # ④ 卸载:sudo classhelper uninstall --yes
+5 -5
View File
@@ -4,7 +4,7 @@
| 形态 | 适用场景 | 产物 | 本机验证情况 | | 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | | --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | | **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | | **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **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 | | **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 项端到端) | | **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
@@ -25,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
| 路径 | 说明 | | 路径 | 说明 |
| ----------------------------------------------------------------------- | ----------------------------------------------- | | ----------------------------------------------------------------------- | ----------------------------------------------- |
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 | | `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) | | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、 打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。 生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
@@ -35,9 +35,9 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
双击安装程序即可,**无需管理员权限**(当前用户级安装): 双击安装程序即可,**无需管理员权限**(当前用户级安装):
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-1.1.2-x64-setup.exe" /S` - 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`班级小助手服务端.lnk`(打开管理端) - 桌面:`ClassHelper 服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
- 卸载信息注册到「应用和功能」 - 卸载信息注册到「应用和功能」
@@ -314,7 +314,7 @@ start.cmd :: Windows
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 | | `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) | | `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | | `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 | | `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(ClassHelper 班级端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) | | `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 | | `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 | | `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
+1 -1
View File
@@ -107,7 +107,7 @@ docker compose up -d
日志里会明确写出「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。 日志里会明确写出「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。
### 客户端(学生端) ### 客户端(ClassHelper 班级端)
从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。 从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。
+114 -34
View File
@@ -1,7 +1,7 @@
# ClassHelper · 完整参考 # ClassHelper · 完整参考
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、 班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
**桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.2**。 **桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.2.0**。
核心链路: 核心链路:
@@ -175,23 +175,28 @@ pnpm verify:desktop
### 默认账号与种子数据 ### 默认账号与种子数据
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,**2026-09-30 实测**: `pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,**2026-10-06 实测**:
用户 18(1 管理员 + 2 教师 + 15 名学生名单)/ 班级 3 / 课程 54(每班 18 科)/ 课表 93 / 作业 15 / 通知 12 / 成绩 90): 账号 3 / 在读班级 3 / 学生 15 / 课程 54(每班 18 科)/ 课表 93 / 作业 16 / 通知 13 / 成绩 90 /
调班历史 2 / 已归档届别 1):
| 角色 | 用户名 | 密码 | 说明 | | 角色 | 凭据 | 密码 | 说明 |
| ------ | ------------- | ------------ | ------------------------------------------ | | ----------------------- | ------------- | ------------ | ----------------------------------------------------------- |
| 管理员 | `admin` | `admin123` | 可访问全部班级 | | 管理员 | `admin` | `admin123` | 可访问全部班级、可执行毕业归档 / 学年升级 / 学期周次 |
| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | | 教师 | `teacher1` | `teacher123` | 张老师:`2026级1班` 班主任,兼该班数学 |
| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | | 教师 | `teacher2` | `teacher123` | 李老师:`2026级2班` 班主任,兼 `2026级1班` 语文 |
| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**ClassHelper 班级端唯一登录方式**) | | ClassHelper 班级端 | 班级码 `G101` | `123456` | `2026级1班`(**教室机器唯一的登录方式**) |
| 班级 | 班级码 `G102` | `123456` | 高一(2)班 | | ClassHelper 班级端 | 班级码 `G102` | `123456` | `2026级2班` |
| 班级 | 班级码 `G203` | `123456` | 高二(3)班(不是 `G103`) | | ClassHelper 班级端 | 班级码 `G203` | `123456` | `2025级3班`(不是 `G103`) |
- 密码由**种子脚本硬编码**(`admin123` / `teacher123` / 班级 `123456`);`.env` 的 - 密码由**种子脚本硬编码**(`admin123` / `teacher123` / 班级 `123456`);`.env` 的
`DEFAULT_CLASS_PASSWORD` 只决定**新建**班级时的初始班级密码。 `DEFAULT_CLASS_PASSWORD` 只决定**新建**班级时的初始班级密码。
- **学生没有个人账号**(2026-10-01 起彻底清理):学生只是名单记录(成绩 / 未交名单 / 已读人数 / 叫人都按名单), - **学生不是账号**(2026-10-06 起由表结构保证):`Student` 表里没有 `passwordHash` / `role`,
没有用户名密码、不能登录;ClassHelper 班级端统一用班级码 + 班级密码。 学号(`202601`…`202615`)只是名单记录与查询键 —— 拿学号去登录只会得到 401。
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。 - 任课关系刻意让**语文**由另一位老师任教,所以班主任在语文上没有编辑权,
正好覆盖「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
- 演示数据里还有一个**已归档**的 `2023级1班`(3 名毕业生),用于验证「毕业归档」页。
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算;管理员可在「学期周次」页逐周覆盖它,
见[学期周次](#412-学期周次可逐周覆盖仅管理员)。
### 常用命令 ### 常用命令
@@ -283,7 +288,7 @@ Element Plus 也通过对齐它的 `--el-border-radius-*` 一并改造:
| 能力 | 说明 | | 能力 | 说明 |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **教师管理**(独立页面) | 单个录入(姓名 / 用户名 / 初始密码 / 角色)+导入名单(xlsx/csv)+编辑(改名 / 改角色)+修改密码+删除 | | **教师管理**(独立页面) | 单个录入(姓名 / **工号** / 手机号 / 初始密码 / 角色)+导入名单(xlsx/csv)+编辑(改名 / 改工号 / 改角色)+修改密码+删除 |
| 录入教师仅管理员 | 页面只对 `ADMIN` 显示,路由 `meta.roles=['ADMIN']`;服务端 `/api/teachers` 全量接口 `requireRole('ADMIN')`(教师调用 403) | | 录入教师仅管理员 | 页面只对 `ADMIN` 显示,路由 `meta.roles=['ADMIN']`;服务端 `/api/teachers` 全量接口 `requireRole('ADMIN')`(教师调用 403) |
| 删除教师有护栏 | 四种情况一律 409:还是**班主任**、名下有课程 / 班级分配、**发布过作业或通知**(后两项也是级联删除,删账号会连带删掉他在各班发过的内容) | | 删除教师有护栏 | 四种情况一律 409:还是**班主任**、名下有课程 / 班级分配、**发布过作业或通知**(后两项也是级联删除,删账号会连带删掉他在各班发过的内容) |
| 创建班级仅管理员 | `POST /api/classes` = `requireRole('ADMIN')`,教师(含班主任)403 | | 创建班级仅管理员 | `POST /api/classes` = `requireRole('ADMIN')`,教师(含班主任)403 |
@@ -608,27 +613,30 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
界面:Web 管理端 →「成绩录入」→「导入表格」(`components/TableImportDialog.vue`)。 界面:Web 管理端 →「成绩录入」→「导入表格」(`components/TableImportDialog.vue`)。
1. **下载模板**:`GET /imports/template?kind=grades&format=xlsx|csv` 1. **下载模板**:`GET /imports/template?kind=grades&format=xlsx|csv`
模板列:`学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程`(CSV 带 BOM,Excel 直接双击不乱码)。 模板列:`学生学号 / 学生姓名 / 考试名称 / 分数 / 总分 / 科目 / 等级`(CSV 带 BOM,Excel 直接双击不乱码)。
2. **上传预览**:`POST /imports/table/preview` → 列名、前 20 行、`totalRows`、**建议字段映射**与校验问题。 2. **上传预览**:`POST /imports/table/preview` → 列名、前 20 行、`totalRows`、**建议字段映射**与校验问题。
- 支持 `.xlsx / .xls / .csv / .tsv`(SheetJS,自动识别分隔符),**表格文件上限 8MB**; - 支持 `.xlsx / .xls / .csv / .tsv`(SheetJS,自动识别分隔符),**表格文件上限 8MB**;
文件以 base64 放进 JSON,请求体上限设为 12MB 才能让 8MB 文件真正传得进来;超限返回 413 `IMPORT_TOO_LARGE`。 文件以 base64 放进 JSON,请求体上限设为 12MB 才能让 8MB 文件真正传得进来;超限返回 413 `IMPORT_TOO_LARGE`。
- 列名按同义词自动映射(如「学号→学生用户名」「得分→分数」「满分→总分」),也可手动改; - 列名按同义词自动映射(如「用户名→学号」「得分→分数」「满分→总分」「课程→科目」),也可手动改;
缺必填列不会静默通过,直接给出「缺少必填列「…」」提示。 缺必填列不会静默通过,直接给出「缺少必填列「…」」提示。
3. **确认导入**:`POST /imports/table/commit`,参数 `mapping`(规范字段 → 列名)+ `mode`: 3. **确认导入**:`POST /imports/table/commit`,参数 `mapping`(规范字段 → 列名)+ `mode`:
- `upsert`(默认)已存在则更新;`append` 已存在则跳过; - `upsert`(默认)已存在则更新;`append` 已存在则跳过;
- 成绩重复判定:**同班级 + 同学生 + 同考试 + 同课程**;学生按「用户名」优先、「姓名」兜底匹配; - 成绩重复判定:**同班级 + 同学生 + 同考试 + 同科目**;学生按「学号」优先、「姓名」兜底匹配;
- 返回 `{ total, inserted, updated, skipped, failed, errors[], warnings[] }`, - 返回 `{ total, inserted, updated, skipped, failed, errors[], warnings[] }`,
`errors[].row` 是 **Excel 视角的行号**(含表头),便于老师改完再导。 `errors[].row` 是 **Excel 视角的行号**(含表头),便于老师改完再导。
#### 2)学生名单导入(管理员) #### 2)学生名单导入(管理员)
界面:Web 管理端 →「学生管理」→「导入名单」(同一组件,`kind=students`)。模板列 `用户名 / 姓名` 界面:Web 管理端 →「学生管理」→「导入名单」(同一组件,`kind=students`)。模板列
(学生没有账号属性,**没有密码列**);用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复按同一用户名 `学号 / 姓名 / 班级 / 性别 / 家长手机号`(学生不是账号,**没有密码列**);学号必须匹配
(`append` 跳过 / `upsert` 更新姓名与班级);用户名被教师 / 管理员占用时该行进错误行,不会覆盖他人账号。 `^[A-Za-z0-9._-]{1,32}$`(入库前去掉全部空白并统一大写,所以唯一约束天然大小写无关);
重复按同一学号(`append` 跳过 / `upsert` 更新姓名、班级与联系方式);
表格里带「班级」列时按班级名解析(重名会报错让老师改用班级码),否则用弹窗里选的班级。
**只写 `Student` 名单,不创建任何账号。**
#### 3)教师名单导入(管理员) #### 3)教师名单导入(管理员)
界面:Web 管理端 →「教师管理」→「导入名单」(`kind=teachers`)。模板列 `用户名 / 姓名 / 初始密码 / 角色`; 界面:Web 管理端 →「教师管理」→「导入名单」(`kind=teachers`)。模板列 `工号 / 姓名 / 手机号 / 初始密码 / 角色`;
角色取 `ADMIN` / `TEACHER`(留空按 `TEACHER`);与新建教师同一套校验,导入后可直接登录。 角色取 `ADMIN` / `TEACHER`(留空按 `TEACHER`);与新建教师同一套校验,导入后可直接登录。
#### 4)ClassIsland 时间配置导入(管理员 / 本班班主任) #### 4)ClassIsland 时间配置导入(管理员 / 本班班主任)
@@ -757,14 +765,14 @@ Web 管理端「数据库管理」页(`/database`,仅 ADMIN 可见,后端
| 能力 | 说明 | | 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、14 张表行数、连接串(MySQL 密码脱敏) | | 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、16 张表行数、连接串(MySQL 密码脱敏) |
| 连接测试 | 任意目标库的连通性试连(切换前的第一步) | | 连接测试 | 任意目标库的连通性试连(切换前的第一步) |
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 | | **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 |
| 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 | | 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
| 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) | | 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) |
| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) | | 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) |
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,14 张表按拓扑序导出, 关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,16 张表按拓扑序导出,
恢复时临时关外键检查 —— `User.classId` 与 `Class.teacherId` 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。 恢复时临时关外键检查 —— `User.classId` 与 `Class.teacherId` 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。
MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,不再是「切换时手动安装」)。 MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,不再是「切换时手动安装」)。
@@ -818,6 +826,72 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
## 5. 技术参考 ## 5. 技术参考
### 4.11 毕业归档(仅管理员)
按**年度**把毕业的班级与学生归档,并留档当年转出的学生、以及毕业班级发过的作业与通知。
入口:Web 管理端 →「毕业归档」。
**归档 = 打标记 + 只读,不删任何数据**:
| 动作 | 结果 |
| ------------ | ---------------------------------------------------------------------------------------- |
| 归档一个届别 | 该届班级从常规列表隐去(`Class.archivedYearId`)、在读学生标记为「已毕业」(`Student.archivedYearId`) |
| 作业与通知 | **原样留在库里**,在「届别详情 → 看作业与通知」里只读浏览 |
| 撤销归档 | 班级与学生回到在读状态;同样不删任何数据(转出的学生保持转出 —— 那是事实,不是归档造成的) |
| 班级端登录 | 归档班级的教室机器**不能再登录**(403 `CLASS_ARCHIVED`),继续登进去只会看到一个只读的壳子 |
**「未毕业而升级的班级不归档」**:学年升级在「班级管理 → 班级设置 → 学年升级」里做,
**只改 `Class.grade`**(高一 → 高二),班级记录、学生名单、学号、作业与成绩全部沿用。
**班级称呼是「XXXX级X班」(入学年份 + 班号)**,不是「高一(1)班」—— 后者每年都要改一遍,
前者一路跟着这届学生到毕业。称呼由服务端按 `enrollmentYear` + `classIndex` 生成,表单里只填这两个数。
**调班 / 转出**(Web 管理端 →「学生管理」):
- **调班**(单个 / 批量同一接口):只改学生**当前班级**,**学号不变**、不创建账号;
原班级 / 新班级 / 操作人 / 时间记入 `StudentClassTransfer`,可查;
历史作业与成绩保留原归属(它们挂在各自的 `classId` 上,不随学生走)。
- **转出**:学籍离开本校(不是调班)。班级归属保留作历史,状态置为 `transferred`,
并写一条 `mode='transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
### 4.12 学期周次(可逐周覆盖,仅管理员)
入口:Web 管理端 →「学期周次」。
课表、作业、成绩都按**教学周**组织,而「开学日期 + 每周七天」的线性推算遇到法定节假日调休、
周末补课、错峰开学就会**整体错位**(错一周全错)。所以支持逐周指定实际日期:
| 操作 | 说明 |
| ---------------- | ------------------------------------------------------------------------ |
| 选配置范围 | 留空 = **全校默认**;也可以挑某个班单独配(该班优先用自己的) |
| 学期开始日期 | 第 1 教学周的周一;填完点「按开学日期生成」得到一份标准学期 |
| 逐周日期区间 | 用日期范围选择器改任意一周(调休那一周拉长 / 开学前那几天并进第 1 周…) |
| 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** |
| 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** |
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`;
- 判定:`lib/term.ts` 的 `loadTermContext()` 做「班级覆盖 → 全校默认 → 纯线性」三级回落,
`resolveCurrentWeek(now, context)` 优先按区间判定;
- 机房/服务器**没有外网时,"拉取放假安排"返回 `ok:false` 是正常结果**(不是错误),
失败结果同样进缓存,避免断网点一次要白等一个超时。
### 4.13 课表同步方向(服务端 → 教室)
2026-10-06 起,课表以**服务端为准**:
| 方向 | 开关 | 默认 | 谁触发 |
| --------------------- | --------------------------------- | ------ | ------------------------------------------ |
| 服务端 → 教室(下发) | `mirrorScheduleToClassIsland` | **开** | 老师改完课表自动镜像回教室 |
| 教室 → 服务端(上传) | `syncScheduleToServer` | **关** | 老师在 Web 端点「**从教室机器获取课表**」 |
为什么要反过来:自动回传会在老师手排课之后被教室的旧课表**悄悄覆盖**。
所以「取教室课表」改成一次显式的人工动作 —— 点击前会弹**警告对话框**说明会覆盖 / 合并本班课表,
确认后服务端置一个待办(`IntegrationDevice.requestScheduleReport`),
插件在下一次心跳(约 1 分钟)看到就把当前课表推上来并清除标记。
> 插件侧对应 `ReportSettingsDto.scheduleRequested` 与 `ReportAsync(..., forceSchedule: true)`;
> 补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)。
### 5.1 目录结构 ### 5.1 目录结构
``` ```
@@ -939,7 +1013,7 @@ class-helper/
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- | | --------------------------- | ------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- |
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) | | POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 | | POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) | | PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
@@ -994,7 +1068,7 @@ class-helper/
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | | GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | | POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | | POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 14 张表行数 / 备份列表 / 定时配置) | | GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) | | POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | | GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | | POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
@@ -1039,11 +1113,11 @@ class-helper/
(`packages/shared/src/permissions.ts`,后端 `lib/access.ts` 复用,前端用它隐藏入口): (`packages/shared/src/permissions.ts`,后端 `lib/access.ts` 复用,前端用它隐藏入口):
| 角色 | 判定方式 | 可见范围 | | 角色 | 判定方式 | 可见范围 |
| ------------------ | ------------------------------ | -------------------------- | | --------------------- | ---------------------------------------- | -------------------------- |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | | `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | | 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 | | 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) | | `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录 | 本班(代全班读写个人数据) |
**权限矩阵**(`PERMISSION_MATRIX`,验收脚本按此逐条断言): **权限矩阵**(`PERMISSION_MATRIX`,验收脚本按此逐条断言):
@@ -1118,15 +1192,21 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
> MySQL 驱动适配器 `@prisma/adapter-mariadb` **已随包内置**(数据库管理模块的连接测试 / 迁移需要),无需手动安装。 > MySQL 驱动适配器 `@prisma/adapter-mariadb` **已随包内置**(数据库管理模块的连接测试 / 迁移需要),无需手动安装。
数据模型共 **14 张表**:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` / `Homework` / 数据模型共 **16 张表**:`User`(纯账号表)/ `Student`(名单,不是账号)/ `StudentClassTransfer`(调班与转出历史)/
`HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` / `IntegrationDevice` / `ClassIslandPush`。 `ArchivedYear`(毕业归档届别)/ `Class` / `TermWeek`(学期周次区间)/ `Course`(= 班级 + 科目 + 教师的任课关系)/
其中 `Class.code` / `Class.passwordHash` 是班级账号字段;`Class.notificationChannel` 决定提醒弹在哪端; `Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` /
`IntegrationDevice` / `ClassIslandPush`。
其中 `Class.name` 是「2026级1班」这种**届别称呼**;`Class.code` / `Class.passwordHash` 是班级端账号字段;
`Class.notificationChannel` 决定提醒弹在哪端;`Class.studentGradeQueryEnabled` 是班级端查成绩的开关;
`Student.studentNo` 是学生的唯一标识与查询键;`Grade.levelType` / `level` 是等级口径与等级;
`Homework.assignDate` 是作业的「所属日期」;`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源; `Homework.assignDate` 是作业的「所属日期」;`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源;
`ClassIslandPush.kind` 区分通知类与叫人。 `ClassIslandPush.kind` 区分通知类与叫人。
迁移历史(**10 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` → 迁移历史(**16 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` →
`add_class_term_weeks` → `add_classisland_integration` → `add_class_notification_channel` → `add_class_term_weeks` → `add_classisland_integration` → `add_class_notification_channel` →
`add_homework_assign_date` → `add_push_kind` → `add_schedule_source`。 `add_homework_assign_date` → `add_push_kind` → `add_schedule_source` →
`add_student_table` → `add_student_grade_query` → `add_grade_level` →
`add_classhelper_heartbeat` → `add_archives` → `add_term_weeks`。
**启动时迁移(安装版自动升级)**:`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名 —— **启动时迁移(安装版自动升级)**:`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名 ——
全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过), 全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过),
+1 -1
View File
@@ -1,5 +1,5 @@
/* /*
* 班级小助手文档站 —— docfx modern 模板的样式覆盖。 * ClassHelper文档站 —— docfx modern 模板的样式覆盖。
* *
* docfx 按 `template` 数组的顺序合并模板,这个文件排在最后,因此覆盖掉 modern 模板自带的 * docfx 按 `template` 数组的顺序合并模板,这个文件排在最后,因此覆盖掉 modern 模板自带的
* `public/main.css`(它本身是空的,被留作官方覆盖点)。docfx.min.css 在本文件之前加载, * `public/main.css`(它本身是空的,被留作官方覆盖点)。docfx.min.css 在本文件之前加载,
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "1.1.2", "version": "1.2.0",
"private": true, "private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
+23 -23
View File
@@ -1,15 +1,15 @@
# 班级小助手联动插件 # ClassHelper 联动插件
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来: 把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。 让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
## 它能做什么 ## 它能做什么
| 方向 | 说明 | | 方向 | 说明 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- | | -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) | | ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 | | ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) | | ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
## 安装 ## 安装
@@ -17,23 +17,23 @@
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」; - 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录 - 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。 (Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。 2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
3. 打开「设置 → 班级小助手联动」,填入: 3. 打开「设置 → ClassHelper 联动」,填入:
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`; - **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。 - **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。 4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
## 配置项(全部在「设置 → 班级小助手联动」里) ## 配置项(全部在「设置 → ClassHelper 联动」里)
| 配置项 | 说明 | | 配置项 | 说明 |
| ---------------------------- | -------------------------------------------------------------------------------------------------- | | ---------------------------- | -------------------------------------------------------------------------------------------------- |
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) | | 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 | | 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) | | 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 | | 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 | | 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 | | 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) | | 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) | | 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 | | 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 | | 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
@@ -48,20 +48,20 @@
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段 > **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来, > (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。 > 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
> **两个方向各自需要的开关**: > **两个方向各自需要的开关**:
> >
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开; > - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开; > - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败; > 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。 > - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
> **提醒到底会不会弹,取决于两个开关:** > **提醒到底会不会弹,取决于两个开关:**
> >
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时, > 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
> 服务端根本不会推过来(此时本插件收不到东西,属正常); > 服务端根本不会推过来(此时本插件收不到东西,属正常);
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。 > 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
> >
> 两者都开着,提醒才会在 ClassIsland 上出现。 > 两者都开着,提醒才会在 ClassIsland 上出现。
@@ -75,7 +75,7 @@
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。 补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒, - **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了 才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。 「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上, - **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。 中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里 - **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
@@ -85,7 +85,7 @@
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN` - **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆; 与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。 3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表; - **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。 老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
## 上机联调记录(ClassIsland 2.1.0.1 实测) ## 上机联调记录(ClassIsland 2.1.0.1 实测)
@@ -129,7 +129,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
## 常见问题 ## 常见问题
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)** **填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里, 这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。 于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**; 插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
@@ -151,7 +151,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403); 2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。 3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
**课表上报了但班级小助手上没变化** **课表上报了但 ClassHelper上没变化**
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。 确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
**镜像开关打开了,但 ClassIsland 的课表没变** **镜像开关打开了,但 ClassIsland 的课表没变**
@@ -160,4 +160,4 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
## 许可 ## 许可
与班级小助手仓库一致。 与 ClassHelper仓库一致。
+8 -8
View File
@@ -1,19 +1,19 @@
# 班级小助手 ClassIsland 联动插件 —— 清单文件 # ClassHelper ClassIsland 联动插件 —— 清单文件
# #
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/ # 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。 # apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
id: classhelper.classisland.bridge id: classhelper.classisland.bridge
name: 班级小助手联动 name: ClassHelper 联动
description: |- description: |-
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。 把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
功能: 功能:
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程); 1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态; 2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读); 3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。 4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper url: https://github.com/LoveChengke/ClassHelper
version: 1.1.2.0 version: 1.2.0.0
apiVersion: 2.0.0.0 apiVersion: 2.0.0.0
author: 班级小助手 author: ClassHelper
+31 -3
View File
@@ -101,7 +101,7 @@ check(
check( check(
'设置页带 [SettingsPageInfo] 且声明了页面 id', '设置页带 [SettingsPageInfo] 且声明了页面 id',
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource), /\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
); );
check( check(
'设置页 XAML 的 x:Class 与代码后置类一致', '设置页 XAML 的 x:Class 与代码后置类一致',
@@ -255,6 +255,34 @@ check(
); );
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource)); check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
check(
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
);
check(
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
/scheduleRequested/.test(clientSource),
);
check(
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
/forceSchedule:\s*true/.test(bridgeSource),
);
check(
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
);
check(
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
);
check(
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
);
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删 // 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
check( check(
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)', '提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
@@ -286,7 +314,7 @@ check(
); );
check( check(
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")', '「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'), bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
); );
check( check(
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)', '提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
@@ -330,7 +358,7 @@ check(
); );
check( check(
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小', '2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
clientSource.includes('响应不是班级小助手的标准格式') && clientSource.includes('响应不是 ClassHelper的标准格式') &&
clientSource.includes('MaxResponseContentBufferSize'), clientSource.includes('MaxResponseContentBufferSize'),
); );
check( check(
@@ -80,6 +80,14 @@ public class ReportSettingsDto
{ {
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; } [JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
[JsonPropertyName("enabled")] public bool Enabled { get; set; } [JsonPropertyName("enabled")] public bool Enabled { get; set; }
/// <summary>
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
///
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
/// </summary>
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
} }
public class ReportResultDto public class ReportResultDto
@@ -157,10 +165,32 @@ public class ClassPlanMirrorDto
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = ""; [JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
} }
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
public class TermWeekRangeDto
{
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
}
public class PullResultDto public class PullResultDto
{ {
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; } [JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
[JsonPropertyName("week")] public int Week { get; set; } [JsonPropertyName("week")] public int Week { get; set; }
/// <summary>
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
///
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
/// </summary>
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = ""; [JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
} }
@@ -174,7 +204,7 @@ internal class ApiEnvelope<T>
[JsonPropertyName("code")] public string? Code { get; set; } [JsonPropertyName("code")] public string? Code { get; set; }
} }
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary> /// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
public class ApiCallResult public class ApiCallResult
{ {
public bool Ok { get; init; } public bool Ok { get; init; }
@@ -187,7 +217,7 @@ public class ApiCallResult
} }
/// <summary> /// <summary>
/// 班级小助手后端客户端。 /// ClassHelper后端客户端。
/// ///
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包, /// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动 /// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
@@ -214,7 +244,7 @@ public sealed class ClassHelperClient : IDisposable
}; };
/// <summary> /// <summary>
/// 与班级小助手通信的 HttpClient。 /// 与 ClassHelper通信的 HttpClient。
/// ///
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理, /// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404", /// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
@@ -335,7 +365,7 @@ public sealed class ClassHelperClient : IDisposable
// 老师截图给我们就能一眼定位。 // 老师截图给我们就能一眼定位。
message = envelope is null message = envelope is null
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" + ? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" + "响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
"或该系统代理是否拦截了内网请求" "或该系统代理是否拦截了内网请求"
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})"; : $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
} }
@@ -350,7 +380,7 @@ public sealed class ClassHelperClient : IDisposable
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。 // 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
LastError = LastError =
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" + $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求"; "响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
return new ApiEnvelope<T> { Success = false, Message = LastError }; return new ApiEnvelope<T> { Success = false, Message = LastError };
} }
@@ -365,7 +395,7 @@ public sealed class ClassHelperClient : IDisposable
} }
catch (HttpRequestException exception) catch (HttpRequestException exception)
{ {
LastError = $"无法连接班级小助手服务:{exception.Message}"; LastError = $"无法连接 ClassHelper服务:{exception.Message}";
return null; return null;
} }
catch (Exception exception) catch (Exception exception)
@@ -17,8 +17,11 @@ public class PluginSettings : ObservableObject
private string _deviceKey = ""; private string _deviceKey = "";
private bool _autoReport = true; private bool _autoReport = true;
private int _reportIntervalSeconds = 60; private int _reportIntervalSeconds = 60;
private bool _uploadSchedule = true; // 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
private bool _mirrorSchedule = false; // 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
private bool _uploadSchedule = false;
private bool _mirrorSchedule = true;
private bool _receiveNotifications = true; private bool _receiveNotifications = true;
private int _notificationDurationSeconds = 8; private int _notificationDurationSeconds = 8;
private int _notificationPollSeconds = 10; private int _notificationPollSeconds = 10;
@@ -26,7 +29,7 @@ public class PluginSettings : ObservableObject
private bool _showDebugInfo = false; private bool _showDebugInfo = false;
/// <summary> /// <summary>
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。 /// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
/// </summary> /// </summary>
public string ServerUrl public string ServerUrl
{ {
@@ -35,7 +38,7 @@ public class PluginSettings : ObservableObject
} }
/// <summary> /// <summary>
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。 /// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
/// </summary> /// </summary>
public string DeviceToken public string DeviceToken
{ {
@@ -74,8 +77,13 @@ public class PluginSettings : ObservableObject
} }
/// <summary> /// <summary>
/// 是否把 ClassIsland 的课表同步到班级小助手。 /// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。 ///
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
/// 关闭后仍会上报"当前上什么课"的状态。
/// </summary> /// </summary>
public bool UploadSchedule public bool UploadSchedule
{ {
@@ -84,8 +92,10 @@ public class PluginSettings : ObservableObject
} }
/// <summary> /// <summary>
/// 是否把班级小助手上排好的课表镜像回 ClassIsland /// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
/// (新建一份名为「班级小助手-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。 /// (新建一份名为「ClassHelper-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。
///
/// **默认开启**:这是现在课表同步的主方向。
/// </summary> /// </summary>
public bool MirrorSchedule public bool MirrorSchedule
{ {
@@ -94,7 +104,7 @@ public class PluginSettings : ObservableObject
} }
/// <summary> /// <summary>
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。 /// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
/// </summary> /// </summary>
public bool ReceiveNotifications public bool ReceiveNotifications
{ {
+7 -7
View File
@@ -54,7 +54,7 @@ public class Plugin : PluginBase
var application = AppBase.Current; var application = AppBase.Current;
if (application is null) if (application is null)
{ {
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动"); Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
return; return;
} }
@@ -72,14 +72,14 @@ public class Plugin : PluginBase
var lessons = IAppHost.TryGetService<ILessonsService>(); var lessons = IAppHost.TryGetService<ILessonsService>();
if (lessons is null) if (lessons is null)
{ {
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用"); Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
return; return;
} }
var bridge = IAppHost.TryGetService<BridgeService>(); var bridge = IAppHost.TryGetService<BridgeService>();
if (bridge is null) if (bridge is null)
{ {
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用"); Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
return; return;
} }
@@ -89,12 +89,12 @@ public class Plugin : PluginBase
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知): // 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
// 不这么做的话,最长要等一个上报间隔才会弹出来 // 不这么做的话,最长要等一个上报间隔才会弹出来
_ = bridge.PullPendingAsync(); _ = bridge.PullPendingAsync();
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl); Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
} }
catch (Exception exception) catch (Exception exception)
{ {
// 联动失败绝不能把 ClassIsland 本体拖下水 // 联动失败绝不能把 ClassIsland 本体拖下水
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败"); Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
} }
} }
@@ -120,7 +120,7 @@ public class Plugin : PluginBase
{ {
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查 // 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception, IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path); "ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
} }
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey); var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
@@ -165,7 +165,7 @@ public class Plugin : PluginBase
catch (Exception exception) catch (Exception exception)
{ {
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception, IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"班级小助手联动:保存设置失败({Path})", path); "ClassHelper 联动:保存设置失败({Path})", path);
} }
} }
} }
@@ -15,10 +15,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary> /// <summary>
/// 联动主循环。三件事: /// 联动主循环。三件事:
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒, /// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手; /// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒", /// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出; /// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。 /// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
/// ///
/// 为什么用"上报即拉取"而不是长连接: /// 为什么用"上报即拉取"而不是长连接:
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题), /// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
@@ -174,7 +174,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
} }
Raise(nameof(IsAttached)); Raise(nameof(IsAttached));
_logger.LogInformation("班级小助手联动:已订阅课程事件"); _logger.LogInformation("ClassHelper 联动:已订阅课程事件");
RequestReport("应用启动"); RequestReport("应用启动");
} }
@@ -216,7 +216,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/// 执行一次上报并返回是否真的发出去了。 /// 执行一次上报并返回是否真的发出去了。
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。 /// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
/// </summary> /// </summary>
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true) public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
{ {
if (_disposed) return false; if (_disposed) return false;
@@ -253,17 +253,21 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var layoutCount = 0; var layoutCount = 0;
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态", // 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。 // 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
if (includeSchedule && !_settings.UploadSchedule) // forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
if (includeSchedule && !uploadSchedule)
{ {
_logger.LogInformation( _logger.LogInformation(
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" + "ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
"需要把教室课表同步到班级小助手请打开该开关"); "需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
} }
if (includeSchedule && _settings.UploadSchedule && profile is not null) if (includeSchedule && uploadSchedule && profile is not null)
{ {
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings); var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning); foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
entryCount = entries.Count; entryCount = entries.Count;
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries }; request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**: // 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
@@ -272,8 +276,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{ {
request.Schedule = null; request.Schedule = null;
_logger.LogWarning( _logger.LogWarning(
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" + "ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份"); "请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
} }
var layout = ResolvePrimaryTimeLayout(profile); var layout = ResolvePrimaryTimeLayout(profile);
@@ -296,7 +300,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
.ConfigureAwait(false); .ConfigureAwait(false);
if (!result.Ok || data is null) if (!result.Ok || data is null)
{ {
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message); _logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
SetState(false, result.Message, entryCount, layoutCount, null); SetState(false, result.Message, entryCount, layoutCount, null);
return false; return false;
} }
@@ -306,15 +310,15 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
if (data.ScheduleApplied) if (data.ScheduleApplied)
{ {
_logger.LogInformation( _logger.LogInformation(
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})", "ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
data.ScheduleCreated, data.ScheduleUpdated); data.ScheduleCreated, data.ScheduleUpdated);
} }
var detail = entryCount > 0 var detail = entryCount > 0
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条" ? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
: _settings.UploadSchedule : uploadSchedule
? "仅状态(ClassIsland 里还没有启用的课表)" ? "仅状态(ClassIsland 里还没有启用的课表)"
: "仅状态(本机「上报课表到班级小助手」开关已关闭)"; : "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周", SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
entryCount, layoutCount, data.Week); entryCount, layoutCount, data.Week);
@@ -325,6 +329,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
NotificationReceived?.Invoke(this, pending); NotificationReceived?.Invoke(this, pending);
} }
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
{
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
}
if (_settings.MirrorSchedule) if (_settings.MirrorSchedule)
{ {
await MirrorScheduleAsync().ConfigureAwait(false); await MirrorScheduleAsync().ConfigureAwait(false);
@@ -338,7 +350,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的 // (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有, // "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。 // 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason); _logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
SetState(false, $"上报异常:{exception.Message}", 0, 0, null); SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
return false; return false;
} }
@@ -369,13 +381,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false); var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok) if (!result.Ok)
{ {
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message); _logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
return; return;
} }
foreach (var item in items) foreach (var item in items)
{ {
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id); _logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
NotificationReceived?.Invoke(this, item); NotificationReceived?.Invoke(this, item);
} }
} }
@@ -392,16 +404,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
if (result.Ok) if (result.Ok)
{ {
NotificationCount += 1; NotificationCount += 1;
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id); _logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
} }
else else
{ {
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次 // 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message); _logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
} }
} }
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary> /// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
public async Task MirrorScheduleAsync() public async Task MirrorScheduleAsync()
{ {
var profile = ResolveProfile(); var profile = ResolveProfile();
@@ -412,7 +424,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{ {
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开 // 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
_logger.LogInformation( _logger.LogInformation(
"班级小助手联动:跳过课表镜像({Message})。" + "ClassHelper 联动:跳过课表镜像({Message})。" +
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期", "需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
result.Message); result.Message);
return; return;
@@ -423,7 +435,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var signature = MirrorSignature(plan); var signature = MirrorSignature(plan);
if (signature == _lastMirrorSignature) if (signature == _lastMirrorSignature)
{ {
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像"); _logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
return; return;
} }
@@ -451,22 +463,22 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
if (!outcome.Applied) if (!outcome.Applied)
{ {
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})", _logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空"); outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
return; return;
} }
_lastMirrorSignature = signature; _lastMirrorSignature = signature;
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})", _logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」"))); string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
foreach (var warning in outcome.Warnings) foreach (var warning in outcome.Warnings)
{ {
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning); _logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
} }
} }
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogError(exception, "班级小助手联动:课表镜像失败"); _logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
} }
} }
@@ -495,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */ /* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary> /// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.1.2.0"; private const string PluginVersion = "1.2.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary> /// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion() private static string SafeAppVersion()
@@ -524,13 +536,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
catch (Exception exception) catch (Exception exception)
{ {
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报 // 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳"); _logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") }; return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
} }
} }
/// <summary> /// <summary>
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。 /// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
/// ///
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表, /// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去, /// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
@@ -578,7 +590,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
} }
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogWarning(exception, "班级小助手联动:读取档案失败"); _logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
return null; return null;
} }
} }
@@ -12,14 +12,14 @@ using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services; namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary> /// <summary>
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。 /// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
/// ///
/// 与 <see cref="BridgeService"/> 的分工: /// 与 <see cref="BridgeService"/> 的分工:
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack); /// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的 /// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
/// 全屏特效 / 语音 / 音效那一整套流程。 /// 全屏特效 / 语音 / 音效那一整套流程。
/// ///
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里, /// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。 /// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。 /// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
/// </summary> /// </summary>
@@ -27,9 +27,9 @@ namespace ClassHelper.ClassIslandPlugin.Services;
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。 // 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
[NotificationProviderInfo( [NotificationProviderInfo(
ProviderGuidValue, ProviderGuidValue,
"班级小助手提醒", "ClassHelper提醒",
"\uE8BD", "\uE8BD",
"显示老师从班级小助手下发的提醒。")] "显示老师从 ClassHelper下发的提醒。")]
public sealed class ClassHelperNotificationProvider : NotificationProviderBase public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{ {
/// <summary> /// <summary>
@@ -100,11 +100,11 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); _lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); _lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessonsHooked = true; _lessonsHooked = true;
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)"); _logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
} }
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试"); _logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
} }
} }
@@ -137,7 +137,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
var pending = _deferred.ToList(); var pending = _deferred.ToList();
_deferred.Clear(); _deferred.Clear();
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count); _logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
foreach (var item in pending) ShowOnUiThread(item); foreach (var item in pending) ShowOnUiThread(item);
} }
@@ -151,7 +151,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{ {
if (!_playing.Add(notification.Id)) if (!_playing.Add(notification.Id))
{ {
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id); _logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
return; return;
} }
} }
@@ -169,7 +169,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
if (IsInClass() && !IsImmediate(notification)) if (IsInClass() && !IsImmediate(notification))
{ {
_deferred.Add(notification); _deferred.Add(notification);
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title); _logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
return; return;
} }
@@ -177,7 +177,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失 // 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
AckWhenFinished(request, notification); AckWhenFinished(request, notification);
ShowNotification(request); ShowNotification(request);
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)", _logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
notification.Title, notification.DurationSeconds); notification.Title, notification.DurationSeconds);
} }
catch (Exception exception) catch (Exception exception)
@@ -185,7 +185,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。 // 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。 // 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
lock (_playing) _playing.Remove(notification.Id); lock (_playing) _playing.Remove(notification.Id);
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title); _logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
} }
} }
@@ -204,7 +204,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
var duration = TimeSpan.FromSeconds(seconds); var duration = TimeSpan.FromSeconds(seconds);
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds)); var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title; var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content; var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸) // 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
@@ -240,7 +240,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay }; return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
} }
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary> /// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification) private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
{ {
void Handler(object? sender, EventArgs args) void Handler(object? sender, EventArgs args)
@@ -5,10 +5,10 @@ using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services; namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary> /// <summary>
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。 /// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
/// ///
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/> /// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。 /// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。 /// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
/// ///
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与 /// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
@@ -25,10 +25,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
public static class ClassPlanWriter public static class ClassPlanWriter
{ {
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary> /// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
public const string PlanNamePrefix = "班级小助手-"; public const string PlanNamePrefix = "ClassHelper-";
/// <summary>时间表名字后缀。</summary> /// <summary>时间表名字后缀。</summary>
public const string TimeLayoutNameSuffix = "(班级小助手)"; public const string TimeLayoutNameSuffix = "(ClassHelper)";
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary> /// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
public sealed class ApplyResult public sealed class ApplyResult
@@ -146,7 +146,7 @@ public static class ClassPlanWriter
{ {
result.Warnings.Add( result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," + $"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
"两者重叠时以班级小助手同步的课表为准"); "两者重叠时以 ClassHelper同步的课表为准");
} }
} }
@@ -183,7 +183,7 @@ public static class ClassPlanWriter
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。 /// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
/// ///
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来, /// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表, /// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。 /// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
/// </summary> /// </summary>
private static (Guid Id, TimeLayout Layout) BuildTimeLayout( private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
@@ -226,7 +226,7 @@ public static class ClassPlanWriter
} }
/// <summary> /// <summary>
/// 课表名:单组时用「班级小助手-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。 /// 课表名:单组时用「ClassHelper-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。
/// </summary> /// </summary>
private static string BuildPlanName( private static string BuildPlanName(
string profileName, string profileName,
@@ -235,7 +235,7 @@ public static class ClassPlanWriter
int weekCountDivTotal, int weekCountDivTotal,
int groupCount) int groupCount)
{ {
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim(); var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
{ {
baseName = PlanNamePrefix + baseName; baseName = PlanNamePrefix + baseName;
@@ -10,7 +10,7 @@ namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary> /// <summary>
/// 课表 / 状态 → 上报载荷 的换算层。 /// 课表 / 状态 → 上报载荷 的换算层。
/// ///
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着 /// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
/// ClassIsland 的模型变化而修改: /// ClassIsland 的模型变化而修改:
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日; /// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN; /// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
@@ -91,7 +91,7 @@ public static class ScheduleMapper
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal); var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
if (plan.TimeRule.WeekCountDivTotal > 2) if (plan.TimeRule.WeekCountDivTotal > 2)
{ {
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理"; warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
} }
var classes = plan.Classes; var classes = plan.Classes;
@@ -102,7 +102,7 @@ public static class ScheduleMapper
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取 // 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
var point = classPoints[index]; var point = classPoints[index];
if (info is null) continue; if (info is null) continue;
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课) // 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
if (!info.IsEnabled) continue; if (!info.IsEnabled) continue;
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达 // 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
@@ -15,10 +15,10 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Classes="settings-container animated-intro" Spacing="0"> <StackPanel Classes="settings-container animated-intro" Spacing="0">
<!-- 连接 --> <!-- 连接 -->
<TextBlock Text="连接班级小助手" <TextBlock Text="连接 ClassHelper"
Theme="{StaticResource SubtitleTextBlockStyle}" /> Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap" <TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" /> Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
<TextBox Watermark="http://127.0.0.1:4000" <TextBox Watermark="http://127.0.0.1:4000"
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" /> Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
@@ -53,10 +53,10 @@
<TextBlock Margin="0 24 0 0" Text="同步内容" <TextBlock Margin="0 24 0 0" Text="同步内容"
Theme="{StaticResource SubtitleTextBlockStyle}" /> Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap" <TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" /> Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
<fa:SettingsExpander Header="上报课表到班级小助手" <fa:SettingsExpander Header="自动上报课表到 ClassHelper"
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。"> Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
<fa:SettingsExpander.IconSource> <fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8A5;" /> <ci:FluentIconSource Glyph="&#xE8A5;" />
</fa:SettingsExpander.IconSource> </fa:SettingsExpander.IconSource>
@@ -66,7 +66,7 @@
</fa:SettingsExpander> </fa:SettingsExpander>
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland" <fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。"> Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
<fa:SettingsExpander.IconSource> <fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8F1;" /> <ci:FluentIconSource Glyph="&#xE8F1;" />
</fa:SettingsExpander.IconSource> </fa:SettingsExpander.IconSource>
@@ -101,10 +101,10 @@
<TextBlock Margin="0 24 0 0" Text="提醒" <TextBlock Margin="0 24 0 0" Text="提醒"
Theme="{StaticResource SubtitleTextBlockStyle}" /> Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap" <TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" /> Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
<fa:SettingsExpander Header="接收班级小助手提醒" <fa:SettingsExpander Header="接收 ClassHelper提醒"
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。"> Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
<fa:SettingsExpander.IconSource> <fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8BD;" /> <ci:FluentIconSource Glyph="&#xE8BD;" />
</fa:SettingsExpander.IconSource> </fa:SettingsExpander.IconSource>
@@ -165,7 +165,7 @@
</StackPanel> </StackPanel>
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap" <TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" /> Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</controls:SettingsPageBase> </controls:SettingsPageBase>
@@ -11,7 +11,7 @@ using CommunityToolkit.Mvvm.Input;
namespace ClassHelper.ClassIslandPlugin.Views; namespace ClassHelper.ClassIslandPlugin.Views;
/// <summary> /// <summary>
/// 「班级小助手联动」设置页。 /// 「ClassHelper 联动」设置页。
/// ///
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项, /// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>, /// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
@@ -20,7 +20,7 @@ namespace ClassHelper.ClassIslandPlugin.Views;
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标 // 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
[SettingsPageInfo( [SettingsPageInfo(
"classhelper.classisland.bridge.settings", "classhelper.classisland.bridge.settings",
"班级小助手联动", "ClassHelper 联动",
"\uE71B", "\uE71B",
"\uE71B", "\uE71B",
SettingsPageCategory.External)] SettingsPageCategory.External)]
+3 -3
View File
@@ -1,5 +1,5 @@
appId: com.classhelper.desktop appId: com.classhelper.desktop
productName: 班级小助手 productName: ClassHelper
copyright: Copyright © 2026 Class Helper copyright: Copyright © 2026 Class Helper
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后), # 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
@@ -10,7 +10,7 @@ copyright: Copyright © 2026 Class Helper
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件, # 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退, # 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。 # 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。 # 另外安装包有硬底:`ClassHelper.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
electronLanguages: electronLanguages:
- zh-CN - zh-CN
@@ -57,7 +57,7 @@ nsis:
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
createDesktopShortcut: true createDesktopShortcut: true
createStartMenuShortcut: true createStartMenuShortcut: true
shortcutName: 班级小助手 shortcutName: ClassHelper
artifactName: ${productName}-${version}-${arch}-setup.${ext} artifactName: ${productName}-${version}-${arch}-setup.${ext}
portable: portable:
+1 -1
View File
@@ -21,7 +21,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
/> />
<title>班级小助手</title> <title>ClassHelper</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -8,7 +8,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
/> />
<title>班级小助手 · 灵动岛</title> <title>ClassHelper · 灵动岛</title>
<style> <style>
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */ /* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
html, html,
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "1.1.2", "version": "1.2.0",
"private": true, "private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
"main": "dist/main/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -13,7 +13,7 @@
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 * node scripts/dist-win.mjs --dir # 仅生成免安装目录
* *
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级): * 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap) * 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容 * 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z * 构建中间/ builder-*.yml、*.nsis.7z
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录; * electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
+1 -1
View File
@@ -58,7 +58,7 @@ if (online === undefined) {
} }
/** /**
* 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录): * 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456), * 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码, * 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。 * 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
@@ -827,7 +827,7 @@ onMounted(async () => {
<footer class="foot"> <footer class="foot">
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span> <span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span> <span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
<span v-else class="more muted">来自班级小助手</span> <span v-else class="more muted">来自 ClassHelper</span>
<span class="actions"> <span class="actions">
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button> <button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead"> <button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">
+1 -1
View File
@@ -60,7 +60,7 @@ function createWindow(): BrowserWindow {
minWidth: 1000, minWidth: 1000,
minHeight: 540, minHeight: 540,
center: true, center: true,
title: '班级小助手', title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者 // 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest, show: !isSmokeTest,
autoHideMenuBar: true, autoHideMenuBar: true,
+1 -1
View File
@@ -4253,7 +4253,7 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`); record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
record( record(
'窗口标题正确', '窗口标题正确',
dom?.title === '班级小助手' || (dom?.title ?? '').includes('班级小助手'), dom?.title === 'ClassHelper' || (dom?.title ?? '').includes('ClassHelper'),
`title=${dom?.title}`, `title=${dom?.title}`,
); );
record( record(
+2 -2
View File
@@ -34,13 +34,13 @@ export function createTray(handlers: TrayHandlers): Tray | null {
const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty(); const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
tray = new Tray(image); tray = new Tray(image);
tray.setToolTip('班级小助手(后台运行中)'); tray.setToolTip('ClassHelper(后台运行中)');
tray.setContextMenu( tray.setContextMenu(
Menu.buildFromTemplate([ Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => handlers.onShow() }, { label: '显示主窗口', click: () => handlers.onShow() },
{ label: '隐藏到托盘', click: () => handlers.onHide() }, { label: '隐藏到托盘', click: () => handlers.onHide() },
{ type: 'separator' }, { type: 'separator' },
{ label: '退出班级小助手', click: () => handlers.onQuit() }, { label: '退出 ClassHelper', click: () => handlers.onQuit() },
]), ]),
); );
// 左键单击/双击托盘图标:恢复窗口(用户习惯) // 左键单击/双击托盘图标:恢复窗口(用户习惯)
+1 -1
View File
@@ -136,7 +136,7 @@ onUnmounted(() => {
<template> <template>
<div v-if="booting" class="boot-screen"> <div v-if="booting" class="boot-screen">
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon> <el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
<p class="boot-title">班级小助手</p> <p class="boot-title">ClassHelper</p>
<!-- <!--
启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。 启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。
交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**, 交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**,
@@ -15,19 +15,20 @@ import {
type NotificationDto, type NotificationDto,
type ScheduleDto, type ScheduleDto,
type ScheduleWeekView, type ScheduleWeekView,
type StudentDto, type SessionUser,
type StudentGradeDetailDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { api } from './http.js'; import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */ /** ClassHelper 班级端只需要这一批接口(与后端同一套 REST 契约) */
export const authApi = { export const authApi = {
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */ /** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload), login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */ /** 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码 */
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> => classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
api.post('/auth/class-login', payload), api.post('/auth/class-login', payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}), logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me), me: (): Promise<SessionUser> => api.get(API_PATHS.auth.me),
/** /**
* 修改密码(设置页「账号信息」里的入口)。 * 修改密码(设置页「账号信息」里的入口)。
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 —— * 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
@@ -128,6 +129,24 @@ export const notificationApi = {
export const gradeApi = { export const gradeApi = {
my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`), my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`),
/**
* 按**学号**查某个学生的成绩明细。
* 学生没有账号,因此这是教室机器代学生查本人的通道;是否开放由班级上的
* `studentGradeQueryEnabled` 开关决定(关闭时服务端返回 403 并给出原因)。
*/
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
};
/**
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。
*/
export const classGradeQueryApi = {
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
}; };
export const dashboardApi = { export const dashboardApi = {
+1 -1
View File
@@ -315,7 +315,7 @@ export async function onlineScenario(credentials?: {
const detail: string[] = []; const detail: string[] = [];
try { try {
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。 // ClassHelper 班级端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
// 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包): // 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
// 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。 // 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
const code = credentials?.code ?? ''; const code = credentials?.code ?? '';
@@ -17,9 +17,9 @@ const onboarding = useOnboardingStore();
const steps = [ const steps = [
{ {
icon: 'School', icon: 'School',
title: '欢迎使用班级小助手', title: '欢迎使用 ClassHelper',
lines: [ lines: [
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。', '这台电脑将以「班级」的身份使用 ClassHelper:实时接收课表、作业、通知与成绩。',
'第一次使用,先花半分钟看看怎么开始。', '第一次使用,先花半分钟看看怎么开始。',
], ],
}, },
@@ -127,7 +127,7 @@ onUnmounted(() => {
<el-icon :size="18"><School /></el-icon> <el-icon :size="18"><School /></el-icon>
</span> </span>
<div class="onboarding-heading"> <div class="onboarding-heading">
<h2 class="onboarding-title">班级小助手</h2> <h2 class="onboarding-title">ClassHelper</h2>
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p> <p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
</div> </div>
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button> <el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
@@ -25,6 +25,7 @@ import {
Pointer, Pointer,
Refresh, Refresh,
School, School,
Search,
Setting, Setting,
Sunny, Sunny,
SwitchButton, SwitchButton,
@@ -75,6 +76,7 @@ const APP_ICONS = {
Pointer, Pointer,
Refresh, Refresh,
School, School,
Search,
Setting, Setting,
Sunny, Sunny,
SwitchButton, SwitchButton,
@@ -298,7 +298,7 @@ onUnmounted(() => {
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出: <!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 --> v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
<img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" /> <img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" />
<span class="brand-text">班级小助手</span> <span class="brand-text">ClassHelper</span>
</div> </div>
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。 <!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 --> pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
@@ -44,9 +44,9 @@ export const useAuthStore = defineStore('auth', () => {
} }
/** /**
* 班级账号登录(学生端主入口):班级码 + 班级密码。 * 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码。
* *
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体; * 需求 1:ClassHelper 班级端以「班级」为主体,个人学生不再是登录主体;
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。 * 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
* 保存的 username 字段记录班级码,便于下次预填。 * 保存的 username 字段记录班级码,便于下次预填。
*/ */
@@ -18,7 +18,7 @@ type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
/** /**
* 实时通道(学生端)。 * 实时通道(ClassHelper 班级端)。
* 与 Web 管理端使用同一套事件名;收到事件后除了通知用户, * 与 Web 管理端使用同一套事件名;收到事件后除了通知用户,
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。 * 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/ */
@@ -1,5 +1,5 @@
/* ============================================================================ /* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题) * ClassHelper 班级端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
* *
* 参考 ClassIsland(Windows 课件表应用)的观感: * 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边 * - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
@@ -7,6 +7,7 @@ import {
gradeLevel, gradeLevel,
gradePercent, gradePercent,
type GradeDto, type GradeDto,
type StudentGradeDetailDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { gradeApi } from '../api/index.js'; import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
@@ -16,7 +17,10 @@ import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore(); const appStore = useAppStore();
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */ /**
* ClassHelper 班级端看到的是**全班成绩总览**(学生不是账号,登录主体就是班级),
* 标题与副标题随会话类型变化。
*/
const auth = useAuthStore(); const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
@@ -42,15 +46,46 @@ const latestGrades = computed(() =>
[...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5), [...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5),
); );
/**
* 等级分布:优先用**库里存的等级**(教师可以手改、也可能是自定义等级),
* 只有老数据没有等级时才按得分率现算 —— 否则教师在 Web 端改过的等级在这里会被"算回去"。
*/
const levelCounts = computed(() => { const levelCounts = computed(() => {
const levels: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, E: 0 }; const levels: Record<string, number> = {};
for (const item of grades.value) { for (const item of grades.value) {
levels[gradeLevel(gradePercent(item.score, item.totalScore))] = const level = item.level || gradeLevel(gradePercent(item.score, item.totalScore));
(levels[gradeLevel(gradePercent(item.score, item.totalScore))] ?? 0) + 1; levels[level] = (levels[level] ?? 0) + 1;
} }
return levels; return levels;
}); });
/* ------------------------------------------------------------ 按学号查成绩明细
*
* 学生没有账号、不能登录,因此"查自己的成绩"这件事由**教室机器**代做:
* 输入学号 → 服务端返回该生的全部成绩明细(含等级)。
* 是否开放由教师/管理员在班级里开关控制(关闭后服务端返回 403,这里直接提示原因)。
*/
const queryNo = ref('');
const queryLoading = ref(false);
const queried = ref<StudentGradeDetailDto | null>(null);
const queryError = ref('');
async function queryByStudentNo(): Promise<void> {
const studentNo = queryNo.value.trim();
if (!studentNo) return;
queryLoading.value = true;
queryError.value = '';
queried.value = null;
try {
queried.value = await gradeApi.studentDetail(studentNo);
} catch (error) {
// 403 的文案由服务端给出("本班未开放学生自助查询成绩,请联系老师开启"),原样展示最准确
queryError.value = error instanceof Error ? error.message : '查询失败,请稍后重试';
} finally {
queryLoading.value = false;
}
}
/** /**
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。 * 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉 * 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
@@ -111,10 +146,55 @@ onUnmounted(() => {
</p> </p>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<el-input
v-model="queryNo"
placeholder="输入学号查个人明细"
clearable
style="width: 200px"
@keyup.enter="queryByStudentNo"
/>
<el-button :icon="'Search'" :loading="queryLoading" @click="queryByStudentNo">按学号查询</el-button>
<el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button> <el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button>
</div> </div>
</div> </div>
<!-- 按学号查询结果:ClassHelper 班级端代学生查本人明细 -->
<el-card v-if="queried || queryError" shadow="never" class="mt-12">
<template #header>
<div class="query-header">
<span v-if="queried">
学号 {{ queried.student.studentNo }} · {{ queried.student.name }}
<span class="text-muted">({{ queried.className ?? '未分班' }})</span>
</span>
<span v-else>查询失败</span>
<el-button link @click="((queried = null), (queryError = ''))">收起</el-button>
</div>
</template>
<el-alert v-if="queryError" type="warning" :closable="false" show-icon :title="queryError" />
<template v-else-if="queried">
<div class="text-muted" style="margin-bottom: 8px">
共 {{ queried.items.length }} 条记录 · 平均得分率 {{ queried.averagePercent }}%
</div>
<el-table :data="queried.items" size="small" empty-text="该生还没有成绩记录" max-height="320">
<el-table-column prop="examName" label="考试" width="150" />
<el-table-column label="科目" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="分数" width="120">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
<el-table-column label="等级" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.level || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="发布时间">
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
</el-table-column>
</el-table>
</template>
</el-card>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :xs="24" :md="8"> <el-col :xs="24" :md="8">
<div class="stat-card"> <div class="stat-card">
@@ -467,7 +467,7 @@ async function openSubmissions(item: HomeworkDto): Promise<void> {
const result = await homeworkApi.submissions(item.id); const result = await homeworkApi.submissions(item.id);
submissions.value = result.students; submissions.value = result.students;
// 默认沿用服务端已有的未交数据(老师只需改动的部分) // 默认沿用服务端已有的未交数据(老师只需改动的部分)
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId); notSubmittedIds.value = result.notSubmitted.map((student) => student.studentId);
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败'); ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败');
submissions.value = []; submissions.value = [];
@@ -482,7 +482,7 @@ function markAllSubmitted(): void {
} }
function markAllNotSubmitted(): void { function markAllNotSubmitted(): void {
notSubmittedIds.value = submissions.value.map((student) => student.userId); notSubmittedIds.value = submissions.value.map((student) => student.studentId);
} }
async function saveSubmissions(): Promise<void> { async function saveSubmissions(): Promise<void> {
@@ -496,7 +496,7 @@ async function saveSubmissions(): Promise<void> {
try { try {
const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value); const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value);
submissions.value = result.students; submissions.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId); notSubmittedIds.value = result.notSubmitted.map((student) => student.studentId);
target.completedCount = result.completedCount; target.completedCount = result.completedCount;
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`); ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
submissionsVisible.value = false; submissionsVisible.value = false;
@@ -835,7 +835,7 @@ onUnmounted(() => {
<div v-loading="submissionsLoading" class="submission-list"> <div v-loading="submissionsLoading" class="submission-list">
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" /> <el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
<el-checkbox-group v-model="notSubmittedIds"> <el-checkbox-group v-model="notSubmittedIds">
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId"> <el-checkbox v-for="student in submissions" :key="student.studentId" :value="student.studentId">
{{ student.name }} {{ student.name }}
</el-checkbox> </el-checkbox>
</el-checkbox-group> </el-checkbox-group>
@@ -95,7 +95,7 @@ async function enterOffline(): Promise<void> {
<div class="login-head"> <div class="login-head">
<img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" /> <img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" />
<div> <div>
<h1 class="login-title">班级小助手</h1> <h1 class="login-title">ClassHelper</h1>
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p> <p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
</div> </div>
</div> </div>
@@ -85,11 +85,11 @@ onMounted(async () => {
<el-icon><School /></el-icon> <el-icon><School /></el-icon>
{{ collapseItems[0].title }} {{ collapseItems[0].title }}
</span> </span>
<span class="about-extra">班级小助手 v{{ appInfo?.appVersion ?? '-' }}</span> <span class="about-extra">ClassHelper v{{ appInfo?.appVersion ?? '-' }}</span>
</template> </template>
<div class="about-section"> <div class="about-section">
<p class="about-copy"> <p class="about-copy">
Copyright © 2025-2026 班级小助手 Copyright © 2025-2026 ClassHelper
<br /> <br />
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛) 面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
</p> </p>
@@ -8,7 +8,7 @@ import {
formatDate, formatDate,
type ClassIslandNotificationChannel, type ClassIslandNotificationChannel,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { classIslandStatusApi } from '../../api/index.js'; import { classGradeQueryApi, classIslandStatusApi } from '../../api/index.js';
import { useAppStore } from '../../stores/app.js'; import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js'; import { useAuthStore } from '../../stores/auth.js';
@@ -37,6 +37,38 @@ const classIslandStatus = ref<{
classIslandVersion: string | null; classIslandVersion: string | null;
} | null>(null); } | null>(null);
/* ------------------------------------------------------------ 班级端成绩查询开关
*
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」——
* 教室机器在这里决定,值存在班级上;Web 端「班级管理」里也能改同一个开关。
*/
const gradeQueryEnabled = ref(true);
const gradeQuerySaving = ref(false);
async function loadGradeQuery(): Promise<void> {
const classId = auth.user?.classId;
if (!classId) return;
try {
const result = await classGradeQueryApi.get(classId);
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
} catch {
// 读不到就按默认值展示(默认开启),不阻断设置页
}
}
async function applyGradeQuery(value: boolean): Promise<void> {
const classId = auth.user?.classId;
if (!classId) return;
gradeQuerySaving.value = true;
try {
const result = await classGradeQueryApi.set(classId, value);
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
ElMessage.success(value ? '已允许按学号查询成绩明细' : '已关闭成绩查询');
} finally {
gradeQuerySaving.value = false;
}
}
const classIslandStatusText = computed(() => { const classIslandStatusText = computed(() => {
const status = classIslandStatus.value; const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入'; if (!status || !status.connected) return '未接入';
@@ -71,6 +103,8 @@ onMounted(async () => {
classIslandStatus.value = status; classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true; classIslandConnected.value = status?.connected === true;
} }
// 班级端成绩查询开关(存在班级上,与 Web 端共享同一个值)
await loadGradeQuery();
}); });
</script> </script>
@@ -94,7 +128,7 @@ onMounted(async () => {
</el-descriptions> </el-descriptions>
<p class="text-muted"> <p class="text-muted">
<strong>通知模式</strong> <strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland, :老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。 由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p> </p>
<el-radio-group <el-radio-group
@@ -114,11 +148,28 @@ onMounted(async () => {
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }} {{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected"> <template v-if="!classIslandConnected">
<br /> <br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在 提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
ClassIsland 上弹")。 ClassIsland 上弹")。
</template> </template>
</p> </p>
</el-card> </el-card>
<!-- 成绩查询:让教室机器能按学号代学生查本人明细 -->
<el-card shadow="never" class="mt-16">
<template #header><span>成绩查询</span></template>
<p class="text-muted">
打开后,可以在「成绩」页按<b>学号</b>查到本班学生的成绩明细(含等级)。
学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。
关闭后本机查成绩会被服务端拒绝,教师端不受影响。
</p>
<el-switch
:model-value="gradeQueryEnabled"
:loading="gradeQuerySaving"
active-text="允许按学号查询成绩明细"
inactive-text="已关闭"
@change="(value: string | number | boolean) => applyGradeQuery(value === true)"
/>
</el-card>
</div> </div>
</template> </template>
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "1.1.2", "version": "1.2.0",
"private": true, "private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
@@ -0,0 +1,186 @@
-- 学生从「账号」变成「名单记录」:新建 Student 表,个人数据外键改指向它
--
-- 为什么要有这一版:
-- 在此之前学生是 `User` 表里 role='STUDENT' 的行 —— 与教师登录名共用同一个全局唯一
-- 命名空间、靠 passwordHash='' 占位、靠"登录时先判 role 再 403"来阻止登录。
-- 「学生不是账号」这条不变量只靠约定维持,任何一处漏判都会让一个学生变成可登录账号。
-- 本迁移把它变成**结构性保证**:学生只存在于 Student 表,那里没有 passwordHash、没有 role。
--
-- 语义变化:
-- 1) `User` 收缩为纯账号表(ADMIN / TEACHER / CLASS_DEVICE),去掉 classId 与 role='STUDENT';
-- 2) `Student.studentNo`(学号)= 原 `User.username`,是学生唯一标识与查询键;
-- 3) `Grade.userId` / `HomeworkStatus.userId` / `NotificationRead.userId` → `studentId`,
-- 指向 Student(值就是原来那个 User.id,因此历史数据一条不丢);
-- 4) 删除 `Enrollment`(`Student.classId` 已是唯一归属,两处双写必然漂移)
-- 与 `ClassTeacher`(没有科目的协作关系;任课关系的唯一来源改为 `Course.teacherId`);
-- 5) `User.phone`(教师联系方式)。
--
-- 数据保全:
-- - 学生行按 id 原样搬进 Student,因此 Grade / HomeworkStatus / NotificationRead 里
-- 旧 `userId` 直接就是新的 `studentId`,无需任何映射表;
-- - 三张个人数据表回填时用 `IN (SELECT id FROM Student)` 兜底:历史上若有教师/管理员
-- 误留下个人记录(例如教师给自己勾过作业完成),这些行会被丢弃而不是撞外键失败;
-- - 成绩的 `level` 按旧口径(得分率)回填,保持升级后界面显示不变。
-- DropIndex
DROP INDEX "ClassTeacher_classId_teacherId_key";
DROP INDEX "ClassTeacher_teacherId_idx";
DROP INDEX "Enrollment_userId_classId_key";
DROP INDEX "Enrollment_classId_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "ClassTeacher";
DROP TABLE "Enrollment";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "Student" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"classId" TEXT,
"gender" TEXT NOT NULL DEFAULT '',
"guardianPhone" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- 把原「学生账号」整行搬成名单记录:id 保持不变,这样下面三张表的 userId 可以直接当 studentId 用。
INSERT INTO "Student" ("id", "studentNo", "name", "classId", "gender", "guardianPhone", "status", "createdAt", "updatedAt")
SELECT "id", "username", "name", "classId", '', '', 'active', "createdAt", "updatedAt"
FROM "User"
WHERE "role" = 'STUDENT';
CREATE TABLE "StudentClassTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentId" TEXT NOT NULL,
"studentNo" TEXT NOT NULL,
"studentName" TEXT NOT NULL,
"fromClassId" TEXT,
"fromClassName" TEXT NOT NULL DEFAULT '',
"toClassId" TEXT,
"toClassName" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"mode" TEXT NOT NULL DEFAULT 'single',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"passwordHash" TEXT,
"termWeeks" INTEGER NOT NULL DEFAULT 20,
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt" FROM "Class";
DROP TABLE "Class";
ALTER TABLE "new_Class" RENAME TO "Class";
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
CREATE TABLE "new_Grade" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"courseId" TEXT,
"studentId" TEXT NOT NULL,
"examName" TEXT NOT NULL,
"score" REAL NOT NULL,
"totalScore" REAL NOT NULL DEFAULT 100,
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Grade_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Grade_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Grade_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Grade" ("classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "studentId", "totalScore", "updatedAt")
SELECT "classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "userId", "totalScore", "updatedAt"
FROM "Grade"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "Grade";
ALTER TABLE "new_Grade" RENAME TO "Grade";
CREATE INDEX "Grade_studentId_publishedAt_idx" ON "Grade"("studentId", "publishedAt");
CREATE INDEX "Grade_classId_examName_idx" ON "Grade"("classId", "examName");
CREATE INDEX "Grade_courseId_idx" ON "Grade"("courseId");
CREATE TABLE "new_HomeworkStatus" (
"id" TEXT NOT NULL PRIMARY KEY,
"homeworkId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"completed" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "HomeworkStatus_homeworkId_fkey" FOREIGN KEY ("homeworkId") REFERENCES "Homework" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "HomeworkStatus_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_HomeworkStatus" ("completed", "homeworkId", "id", "studentId", "updatedAt")
SELECT "completed", "homeworkId", "id", "userId", "updatedAt"
FROM "HomeworkStatus"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "HomeworkStatus";
ALTER TABLE "new_HomeworkStatus" RENAME TO "HomeworkStatus";
CREATE INDEX "HomeworkStatus_studentId_idx" ON "HomeworkStatus"("studentId");
CREATE UNIQUE INDEX "HomeworkStatus_homeworkId_studentId_key" ON "HomeworkStatus"("homeworkId", "studentId");
CREATE TABLE "new_NotificationRead" (
"id" TEXT NOT NULL PRIMARY KEY,
"notificationId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"readAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationRead_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "NotificationRead_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_NotificationRead" ("id", "notificationId", "readAt", "studentId")
SELECT "id", "notificationId", "readAt", "userId"
FROM "NotificationRead"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "NotificationRead";
ALTER TABLE "new_NotificationRead" RENAME TO "NotificationRead";
CREATE INDEX "NotificationRead_studentId_idx" ON "NotificationRead"("studentId");
CREATE UNIQUE INDEX "NotificationRead_notificationId_studentId_key" ON "NotificationRead"("notificationId", "studentId");
-- User 收缩成纯账号表:只留 ADMIN / TEACHER(+ 未来的 CLASS_DEVICE),去掉 classId,加 phone。
-- 学生行在这里被丢弃 —— 它们已经在上面搬进 Student 了。
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'TEACHER',
"phone" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username")
SELECT "createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username"
FROM "User"
WHERE "role" <> 'STUDENT';
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE INDEX "User_role_idx" ON "User"("role");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
CREATE INDEX "Student_name_idx" ON "Student"("name");
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId");
@@ -0,0 +1,8 @@
-- ClassHelper 班级端成绩查询开关(按班级存,默认开启)
--
-- 需求:「ClassHelper 班级端可按学号查询本班学生成绩明细;是否允许学生通过班级端查看本人明细,
-- 由教师或管理员开关控制」。
--
-- 默认 true 是刻意的:教室机器装好就能查(开箱可用),要收紧的班级由班主任/管理员关掉即可。
-- 关闭后班级端调 `GET /api/grades/student/:studentNo` 一律 403,教师端不受影响。
ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,26 @@
-- 成绩等级:落到库里,教师可单个/批量手动编辑
--
-- 在此之前等级是**前端按得分率现算**的(`@classhelper/shared` 的 `gradeLevel(percent)`),
-- 教师既看不到存下来的等级,也无法手动调整 —— 而需求要求
-- 「发布成绩时可选百分制 / 等级制 A·B·C·D / 自定义等级,且可手动编辑等级(单个 + 批量)」。
--
-- `levelType`:
-- percent 百分制 —— 等级由得分率自动换算(教师仍可手改并覆盖)
-- letter 等级制 —— 直接给 A / B / C / D
-- custom 自定义等级 —— 教师填任意文本(优 / 良 / 合格 / 待提高…)
--
-- `level` 永远有值:percent 下写入换算结果,其余两种按教师指定写入。
ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent';
ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT '';
-- 回填旧数据:按升级前的同一套口径(得分率 ≥90 A / ≥80 B / ≥70 C / ≥60 D / 其余 E)算,
-- 保证升级前后界面上看到的等级完全一致。总分非法(≤0)一律落到 E。
UPDATE "Grade"
SET "level" = CASE
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 90 THEN 'A'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 80 THEN 'B'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 70 THEN 'C'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 60 THEN 'D'
ELSE 'E'
END
WHERE "level" = '';
@@ -0,0 +1,15 @@
-- ClassHelper 班级端心跳时间:把「在线判定」和「最后带状态的上报」拆成两个字段
--
-- 原先只有一个 `lastSeenAt`(每次上报都刷)。但同一台设备的上报其实有两种:
-- - **心跳**:插件每隔 60 秒打一次,只表明"我还在线";
-- - **状态上报**:带运行状态 / 课表,表明"我拿到了新东西"。
-- 合成一个字段后,教师网页无法区分「设备在线但一直没有新状态」和「设备整个失联」。
--
-- 于是:
-- `lastHeartbeatAt` 每次上报都刷新 —— **在线判定看它**(60 秒窗口,见 shared 的
-- `CLASS_HELPER_ONLINE_WINDOW_MS`);
-- `lastSeenAt` 只在带运行状态的上报时刷新 —— 展示为「最后在线时间」。
--
-- 回填:老库只有 lastSeenAt,就把它当作心跳时间的初值(否则所有设备升级后都会显示"从未心跳")。
ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME;
UPDATE "IntegrationDevice" SET "lastHeartbeatAt" = "lastSeenAt" WHERE "lastHeartbeatAt" IS NULL;
@@ -0,0 +1,145 @@
-- 毕业归档:届别档案 + 班级/学生的归档标记
--
-- 背景(需求):管理员要有一个「归档」页,按**年度**记录毕业的班级与学生、当年转出的学生,
-- 以及毕业班级发过的作业与通知;**未毕业而升级的班级不归档、数据沿用**。
--
-- 关键设计:**归档是打标记 + 只读,不删任何数据**。
-- `ArchivedYear` 是届别档案(入学年份 + 毕业年份 + 归档时的统计快照);
-- `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
-- 毕业班级的作业与通知原样留在库里(`Homework.classId` / `Notification.classId` 不变),
-- 归档详情页只读地查它们 —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
--
-- 升级 ≠ 归档:高一 → 高二 只改 `Class.grade`,班级记录、学生名单、作业成绩全部沿用,
-- 不碰 archivedYearId。因此归档只能由管理员在归档页显式执行。
--
-- 班级称呼改成「XXXX级X班」:新列 `enrollmentYear`(入学年份)+ `classIndex`(班号),
-- `Class.name` 由服务端按二者生成。下面会对**已有班级**做一次尽力而为的推断回填。
-- CreateTable
CREATE TABLE "ArchivedYear" (
"id" TEXT NOT NULL PRIMARY KEY,
"enrollmentYear" INTEGER NOT NULL,
"graduationYear" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"classCount" INTEGER NOT NULL DEFAULT 0,
"studentCount" INTEGER NOT NULL DEFAULT 0,
"transferredCount" INTEGER NOT NULL DEFAULT 0,
"homeworkCount" INTEGER NOT NULL DEFAULT 0,
"notificationCount" INTEGER NOT NULL DEFAULT 0,
"archivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"enrollmentYear" INTEGER,
"classIndex" INTEGER,
"teacherId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"passwordHash" TEXT,
"termWeeks" INTEGER NOT NULL DEFAULT 20,
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
"studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true,
"archivedYearId" TEXT,
"archivedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Class_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt" FROM "Class";
DROP TABLE "Class";
ALTER TABLE "new_Class" RENAME TO "Class";
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
CREATE INDEX "Class_enrollmentYear_idx" ON "Class"("enrollmentYear");
CREATE INDEX "Class_archivedYearId_idx" ON "Class"("archivedYearId");
-- 已有班级的届别推断(尽力而为):
-- 入学年份 = 当前学年起始年 − 该班现在的年级偏移。学年在 9 月切换,
-- 所以 9 月及以后算今年起始、之前算去年起始;高一偏移 0、高二 1、高三 2,
-- 认不出的年级(初中 / 四年制等)按高三处理(宁可偏早,管理员可在班级管理页改正)。
UPDATE "Class"
SET "enrollmentYear" = (
CASE WHEN CAST(strftime('%m', 'now') AS INTEGER) >= 9
THEN CAST(strftime('%Y', 'now') AS INTEGER)
ELSE CAST(strftime('%Y', 'now') AS INTEGER) - 1 END
) - CASE "grade" WHEN '高二' THEN 1 WHEN '高三' THEN 2 ELSE 0 END
WHERE "enrollmentYear" IS NULL;
-- 班号按「同一届内按创建先后」编号(老名字里的括号数字没法跨库 reliably 解析,这是等价的近似)。
UPDATE "Class"
SET "classIndex" = (
SELECT COUNT(*) FROM "Class" c2
WHERE c2."enrollmentYear" = "Class"."enrollmentYear"
AND (c2."createdAt" < "Class"."createdAt"
OR (c2."createdAt" = "Class"."createdAt" AND c2."id" <= "Class"."id"))
)
WHERE "classIndex" IS NULL;
-- 按新口径重写称呼:「2026级1班」。管理员若觉得推断不对,可在班级管理页改入学年份/班号。
UPDATE "Class"
SET "name" = CAST("enrollmentYear" AS TEXT) || '级' || CAST("classIndex" AS TEXT) || '班'
WHERE "enrollmentYear" IS NOT NULL AND "classIndex" IS NOT NULL;
CREATE TABLE "new_Student" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"classId" TEXT,
"gender" TEXT NOT NULL DEFAULT '',
"guardianPhone" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'active',
"archivedYearId" TEXT,
"archivedAt" DATETIME,
"transferredAt" DATETIME,
"transferNote" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Student_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Student" ("classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt") SELECT "classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt" FROM "Student";
DROP TABLE "Student";
ALTER TABLE "new_Student" RENAME TO "Student";
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
CREATE INDEX "Student_name_idx" ON "Student"("name");
CREATE INDEX "Student_status_idx" ON "Student"("status");
CREATE INDEX "Student_archivedYearId_idx" ON "Student"("archivedYearId");
CREATE TABLE "new_StudentClassTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentId" TEXT NOT NULL,
"studentNo" TEXT NOT NULL,
"studentName" TEXT NOT NULL,
"fromClassId" TEXT,
"fromClassName" TEXT NOT NULL DEFAULT '',
"toClassId" TEXT,
"toClassName" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"mode" TEXT NOT NULL DEFAULT 'single',
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_StudentClassTransfer" ("createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName") SELECT "createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName" FROM "StudentClassTransfer";
DROP TABLE "StudentClassTransfer";
ALTER TABLE "new_StudentClassTransfer" RENAME TO "StudentClassTransfer";
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "ArchivedYear_enrollmentYear_key" ON "ArchivedYear"("enrollmentYear");
@@ -0,0 +1,79 @@
-- 学期周次配置 + 课表同步方向反转
--
-- 一、学期周次(需求:「第几周的周次数据可以由 admin 修改」)
-- `TermWeek` 让管理员为**每一个教学周**指定实际的起止日期(调休、周末补课、错峰开学),
-- `classId = ''` 表示全校默认;没配到的周次仍按学期开始日期线性兜底。
-- `Class.termStartDate` 允许某个班单独覆盖全局的开学日期(它随镜像课表下发给教室的 ClassIsland)。
--
-- 二、课表同步方向反转(需求:「课表不再自动从教室的 ClassIsland 获取,改为手动获取;
-- ClassHelper 班级端的课表由服务端自动下发」)
-- `syncScheduleToServer` 默认 true → **false**:插件不再自动把教室的课表推上来
-- `mirrorScheduleToClassIsland` 默认 false → **true**:服务端的课表自动下发给教室
-- `requestScheduleReport` 新增:Web 端点「从教室机器获取课表」时置位,
-- 插件下次心跳看到就把当前课表推上来并清除标记
--
-- 下面会把**已有设备**也改成新方向:这正是需求要的行为变化(否则升级后教室里
-- 仍然在自动回传,老师手排的课会被教室的旧课表悄悄覆盖)。设备管理员可以逐台改回去。
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "termStartDate" TEXT;
-- CreateTable
CREATE TABLE "TermWeek" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL DEFAULT '',
"weekNumber" INTEGER NOT NULL,
"startDate" TEXT NOT NULL,
"endDate" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_IntegrationDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"deviceKey" TEXT NOT NULL,
"mode" TEXT NOT NULL DEFAULT 'plugin',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"tokenHash" TEXT NOT NULL,
"tokenHint" TEXT NOT NULL,
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT false,
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT true,
"requestScheduleReport" BOOLEAN NOT NULL DEFAULT false,
"classIslandVersion" TEXT,
"pluginVersion" TEXT,
"lastSeenAt" DATETIME,
"lastHeartbeatAt" DATETIME,
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
"currentSubject" TEXT,
"currentTimeState" TEXT,
"currentPeriodStart" TEXT,
"currentPeriodEnd" TEXT,
"nextSubject" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_IntegrationDevice" ("classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt") SELECT "classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt" FROM "IntegrationDevice";
-- 已有设备切到新方向:不再自动回传课表,改为由服务端自动下发。
UPDATE "new_IntegrationDevice" SET "syncScheduleToServer" = false, "mirrorScheduleToClassIsland" = true;
DROP TABLE "IntegrationDevice";
ALTER TABLE "new_IntegrationDevice" RENAME TO "IntegrationDevice";
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "TermWeek_classId_idx" ON "TermWeek"("classId");
-- CreateIndex
CREATE UNIQUE INDEX "TermWeek_classId_weekNumber_key" ON "TermWeek"("classId", "weekNumber");
+229 -68
View File
@@ -1,4 +1,4 @@
// 班级小助手 - 数据模型 // ClassHelper - 数据模型
// //
// 设计说明: // 设计说明:
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。 // 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
@@ -6,10 +6,22 @@
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。 // 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`), // MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。 // 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、 // 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'CLASS_DEVICE'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验, // 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。 // 这样 SQLite / MySQL 行为完全一致。
// 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。 // 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。
//
// **学生不是账号**(2026-10-06 重构的核心不变量):
// `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号,
// 没有 passwordHash、没有 role、没有登录字段 —— 「学生不能登录」由表结构保证,
// 不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
// `User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
//
// **任课关系唯一来源是 `Course`**:`Course(classId, name, teacherId)` 就是
// 「班级 + 科目 + 教师」这条关系本身。班主任另由 `Class.teacherId` 表示(每班 1 人)。
// 历史上那张没有科目的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
// 同一教师可以在同一个班同时是班主任和某科科任老师:两处都指向同一个 `User`,
// 不重复建号,权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
@@ -20,101 +32,229 @@ datasource db {
provider = "sqlite" provider = "sqlite"
} }
/// 用户:教师 / 管理员 / 学生统一存储 /// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique username String @unique
passwordHash String passwordHash String
name String name String
role String @default("STUDENT") /// ADMIN | TEACHER | CLASS_DEVICE
classId String? role String @default("TEACHER")
/// 手机号(教师联系方式,可空)
phone String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// 学生的主班级(一个学生属于一个主班级) /// 教师担任班主任的班级(每班 1 人)
class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull)
// 教师创建(负责)的班级
ownedClasses Class[] @relation("ClassOwner") ownedClasses Class[] @relation("ClassOwner")
// 教师被分配协作的班级 /// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
classAssignments ClassTeacher[]
courses Course[] courses Course[]
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
enrollments Enrollment[]
createdHomeworks Homework[] @relation("HomeworkCreator") createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator") createdNotices Notification[] @relation("NotificationCreator")
@@index([classId])
@@index([role]) @@index([role])
} }
/// 学生:**只是班级名单里的一条记录**,没有账号、没有密码、不能登录。
///
/// `studentNo`(学号)是学生在系统里的唯一标识与查询键:
/// 名单、成绩、作业提交、导入模板、导出、查询显示一律用它。
/// 历史作业与成绩挂在 `Homework.classId` / `Grade.classId` 上,
/// 因此**调班只改本表的 classId,历史数据保留原归属**。
model Student {
id String @id @default(cuid())
/// 学号:学生唯一标识(全校唯一)
studentNo String @unique
name String
/// 当前班级;null = 未分班(转出/毕业后班级归属仍保留,靠 status 区分)
classId String?
/// 性别:'' | 'MALE' | 'FEMALE'(字符串存,跨库通用)
gender String @default("")
/// 家长手机号(可空)
guardianPhone String @default("")
/// active = 在读 / graduated = 已毕业 / transferred = 已转出 / inactive = 停用
status String @default("active")
/// 毕业归档所属届别(`ArchivedYear.id`);null = 未归档
archivedYearId String?
archivedAt DateTime?
/// 转出时间(status='transferred' 时写入)
transferredAt DateTime?
/// 转出去向 / 备注
transferNote String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
transfers StudentClassTransfer[]
@@index([classId])
@@index([name])
@@index([status])
@@index([archivedYearId])
}
/// 调班 / 转出历史:原班级 / 新班级 / 操作人 / 时间。
///
/// 班级 id 与班级名**都存快照且不建外键** —— 班级被删除后这条历史仍要能读出来,
/// 而外键的 Cascade 会把历史一起带走。
model StudentClassTransfer {
id String @id @default(cuid())
studentId String
/// 学号快照(学生被删除后历史仍可读)
studentNo String
studentName String
fromClassId String?
fromClassName String @default("")
toClassId String?
toClassName String @default("")
/// 操作人(User.id);账号被删后留空,姓名快照保留
operatorId String?
operatorName String @default("")
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校)
mode String @default("single")
note String @default("")
createdAt DateTime @default(now())
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([studentId, createdAt])
@@index([createdAt])
}
/// 届别档案:一次「毕业归档」的留痕(管理员在「归档管理」页执行)。
///
/// 归档是**打标记 + 只读**,不删任何数据:
/// - `Class.archivedYearId` / `Student.archivedYearId` 指向本届档案,
/// 常规的班级/学生列表按它过滤掉归档项;
/// - 毕业班级的作业与通知原样留在库里,通过归档详情只读查看
/// (需求「记录每个年度毕业班级的消息和作业」就是这么满足的)。
///
/// 「未毕业而升级的班级不归档」:升级只改 `Class.grade`(高一→高二→高三),
/// 班级记录、学生名单、作业成绩全部沿用,不碰 `archivedYearId`。
model ArchivedYear {
id String @id @default(cuid())
/// 届别 = 入学年份(2026 表示 2026 级,班级称呼因此是「2026级1班」)
enrollmentYear Int @unique
/// 毕业年份(= 入学年份 + 学制年数)
graduationYear Int
/// 届别名,如「2026 级」
name String
note String @default("")
operatorId String?
operatorName String @default("")
/// 归档时的统计快照(给人看的一行汇总;明细实时查班级与学生)
classCount Int @default(0)
studentCount Int @default(0)
transferredCount Int @default(0)
homeworkCount Int @default(0)
notificationCount Int @default(0)
archivedAt DateTime @default(now())
classes Class[]
students Student[]
}
/// 学期周次区间:管理员可以为**每一个教学周**指定实际的起止日期。
///
/// 为什么不能只靠"学期开始日期 + 每周七天"线性推算:法定节假日调休、周末补课、
/// 错峰开学都会让某一周变长或变短(甚至整周不上课),线性推算出来的"第几周"
/// 会与教室里的实际情况整体错位一周以上 —— 而课表、作业、成绩都按周次组织。
///
/// 因此支持逐周设置:
/// `classId = ''` 表示**全校默认**(没有为某个班单独配置时用它);
/// 有班级维度的行则覆盖默认。
/// 没配置的周次仍按学期开始日期线性兜底(见 shared 的 `resolveCurrentWeek`)。
model TermWeek {
id String @id @default(cuid())
/// 归属班级;**空串 = 全校默认**(用空串而不是 null,唯一约束才在两个库上都生效)
classId String @default("")
/// 教学周次(1 起)
weekNumber Int
/// 该周的起始日期(YYYY-MM-DD,本地日期)
startDate String
/// 该周的结束日期(YYYY-MM-DD)
endDate String
note String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([classId, weekNumber])
@@index([classId])
}
/// 班级 /// 班级
///
/// **班级称呼是「XXXX级X班」**(入学年份 + 班号),不是「高一(1)班」:
/// 后者每年都要改,前者一路跟着这届学生到毕业。`name` 由服务端按
/// `enrollmentYear` + `classIndex` 生成(见 shared 的 `formatClassName()`),
/// 保留成列是为了兼容既有引用,不在别处手写。
///
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class { model Class {
id String @id @default(cuid()) id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String name String
/// 当前年级(高一 / 高二 / 高三),学年升级时更新
grade String grade String
/// 入学年份(届别):2026 → 称呼「2026级…」、毕业年份 2029
enrollmentYear Int?
/// 班号(同一届内的序号)
classIndex Int?
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
teacherId String teacherId String
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写) /// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
code String @unique code String @unique
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录 /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
passwordHash String? passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它 /// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20) termWeeks Int @default(20)
/// 通知显示位置(由教室的班级客户端在设置页里选): /// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland /// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
termStartDate String?
/// 通知显示位置(由 ClassHelper 班级端在设置页里选):
/// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
notificationChannel String @default("both") notificationChannel String @default("both")
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
/// 关闭后班级端查成绩一律 403;教师端不受影响。
studentGradeQueryEnabled Boolean @default(true)
/// 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的)
archivedYearId String?
archivedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade) teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
students User[] @relation("ClassStudents") archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
teachers ClassTeacher[] students Student[]
courses Course[] courses Course[]
schedules Schedule[] schedules Schedule[]
homeworks Homework[] homeworks Homework[]
notifications Notification[] notifications Notification[]
grades Grade[] grades Grade[]
enrollments Enrollment[]
timeLayouts TimeLayout[] timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[] integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[] classIslandPushes ClassIslandPush[]
@@index([teacherId]) @@index([teacherId])
@@index([enrollmentYear])
@@index([archivedYearId])
} }
/// 教师-班级 分配关系(一个教师可管理多个班级) /// 课程:同时也是「班级 + 科目 + 教师」这条**任课关系**的载体。
model ClassTeacher { ///
id String @id @default(cuid()) /// `teacherId` 就是该班该科的任课老师(管理员在「班级管理 → 任课老师」里指定)。
classId String /// 作业与成绩都挂在 Course 上,因此"这位老师能不能动这一科的作业/成绩"
teacherId String /// 就是查 `Course.teacherId`—— 不需要第二张任课表。
createdAt DateTime @default(now())
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade)
@@unique([classId, teacherId])
@@index([teacherId])
}
/// 学生-班级 选课/归属记录(与 User.classId 保持同步,支持未来多班)
model Enrollment {
id String @id @default(cuid())
userId String
classId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([userId, classId])
@@index([classId])
}
/// 课程
model Course { model Course {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
/// 该班该科的任课老师
teacherId String teacherId String
classId String classId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -127,6 +267,7 @@ model Course {
@@unique([classId, name]) @@unique([classId, name])
@@index([classId]) @@index([classId])
@@index([teacherId])
} }
/// 课表条目 /// 课表条目
@@ -190,19 +331,19 @@ model Homework {
@@index([dueAt]) @@index([dueAt])
} }
/// 学生作业完成状态 /// 学生作业完成状态(按**学生名单记录**记,学生没有账号)
model HomeworkStatus { model HomeworkStatus {
id String @id @default(cuid()) id String @id @default(cuid())
homeworkId String homeworkId String
userId String studentId String
completed Boolean @default(false) completed Boolean @default(false)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([homeworkId, userId]) @@unique([homeworkId, studentId])
@@index([userId]) @@index([studentId])
} }
/// 通知 /// 通知
@@ -227,14 +368,14 @@ model Notification {
model NotificationRead { model NotificationRead {
id String @id @default(cuid()) id String @id @default(cuid())
notificationId String notificationId String
userId String studentId String
readAt DateTime @default(now()) readAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([notificationId, userId]) @@unique([notificationId, studentId])
@@index([userId]) @@index([studentId])
} }
@@ -256,34 +397,44 @@ model TimeLayout {
} }
/// 成绩 /// 成绩
///
/// `classId` 是**发布时的归属班级**,调班不改变它 —— 历史成绩留在原班级,
/// 后续新成绩按学生当前班级生成(需求「历史作业和成绩保留原归属」)。
model Grade { model Grade {
id String @id @default(cuid()) id String @id @default(cuid())
classId String classId String
courseId String? courseId String?
userId String /// 学生(名单记录)id
studentId String
examName String examName String
score Float score Float
totalScore Float @default(100) totalScore Float @default(100)
/// 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级
levelType String @default("percent")
/// 等级文本。percent 下由服务端按得分率生成初值,教师可手改覆盖
level String @default("")
publishedAt DateTime @default(now()) publishedAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade) class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
student User @relation(fields: [userId], references: [id], onDelete: Cascade) student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([userId, publishedAt]) @@index([studentId, publishedAt])
@@index([classId, examName]) @@index([classId, examName])
@@index([courseId]) @@index([courseId])
} }
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端) /// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
/// ///
/// 说明: /// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示; /// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录, /// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项; /// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。 /// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
model IntegrationDevice { model IntegrationDevice {
id String @id @default(cuid()) id String @id @default(cuid())
classId String classId String
@@ -300,12 +451,22 @@ model IntegrationDevice {
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查 /// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务 /// 是否允许插件把 ClassIsland 的课表回传到本服务
syncScheduleToServer Boolean @default(true) ///
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行) /// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
mirrorScheduleToClassIsland Boolean @default(false) /// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
classIslandVersion String? classIslandVersion String?
pluginVersion String? pluginVersion String?
/// 最后一次**带运行状态**的上报时间
lastSeenAt DateTime? lastSeenAt DateTime?
/// 最后一次心跳时间(每次上报都刷新);「在线」看它
lastHeartbeatAt DateTime?
/// 以下为最后一次上报的快照 /// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false) classPlanLoaded Boolean @default(false)
currentSubject String? currentSubject String?
+208 -42
View File
@@ -2,15 +2,25 @@
* 种子数据:pnpm db:seed * 种子数据:pnpm db:seed
* *
* 会清空业务表后重建一套完整演示数据: * 会清空业务表后重建一套完整演示数据:
* 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩 * 1 管理员 / 2 教师 / 3 个在读班级 / 1 个已毕业届别 / 15 名学生 /
* 课程(含各科任课老师)/ 课表 / 作业 / 通知 / 成绩 / 调班与转出历史
*
* 班级称呼一律是「XXXX级X班」(入学年份 + 班号)—— 见 shared 的 `formatClassName()`。
* *
* 演示账号(密码见文件末尾打印结果): * 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员 * admin / admin123 - 系统管理员
* teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任) * teacher1 / teacher123 - 张老师(2026级1班 班主任,兼 2026级1班 数学)
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师) * teacher2 / teacher123 - 李老师(2026级2班 班主任,兼 2026级1班 语文)
* student01..student15 / student123 * (学生只有名单、没有账号:ClassHelper 班级端统一用班级码 + 班级密码登录)
*/ */
import { SUBJECT_CATALOG, dayKeyLocal } from '@classhelper/shared'; import {
SUBJECT_CATALOG,
dayKeyLocal,
formatClassName,
formatYearLabel,
gradeLevel,
gradePercent,
} from '@classhelper/shared';
import { disconnectPrisma, prisma } from '../src/lib/db.js'; import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js'; import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js'; import { hashPassword } from '../src/lib/password.js';
@@ -67,11 +77,13 @@ async function resetDatabase(): Promise<void> {
await prisma.notification.deleteMany(); await prisma.notification.deleteMany();
await prisma.schedule.deleteMany(); await prisma.schedule.deleteMany();
await prisma.course.deleteMany(); await prisma.course.deleteMany();
await prisma.enrollment.deleteMany(); await prisma.studentClassTransfer.deleteMany();
await prisma.classTeacher.deleteMany(); // 学生要排在调班历史之后、班级之前(Student.classId 是 SetNull,但历史是 Cascade)
await prisma.user.updateMany({ data: { classId: null } }); await prisma.student.deleteMany();
await prisma.user.deleteMany(); await prisma.user.deleteMany();
// 班级归到届别上,所以先删班级再删届别
await prisma.class.deleteMany(); await prisma.class.deleteMany();
await prisma.archivedYear.deleteMany();
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -86,11 +98,11 @@ async function main(): Promise<void> {
const random = createRandom(20260901); const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次 // 同一角色共用密码,只需各哈希一次
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录 // 学生没有密码:个人学生账号已清理(2026-10-01),ClassHelper 班级端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([ const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'), hashPassword('admin123'),
hashPassword('teacher123'), hashPassword('teacher123'),
// 班级账号默认密码(学生端「班级登录」) // 班级账号默认密码(ClassHelper 班级端「班级登录」)
hashPassword('123456'), hashPassword('123456'),
]); ]);
@@ -105,19 +117,24 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash }, data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
}); });
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置) // 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
//
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
// 毕业年份 = 入学年份 + 3,所以这一届 2026 级将在 2029 年归档。
const classSeeds = [ const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' }, { enrollmentYear: 2026, classIndex: 1, grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' }, { enrollmentYear: 2026, classIndex: 2, grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' }, { enrollmentYear: 2025, classIndex: 3, grade: '高二', teacherId: teacher1.id, code: 'G203' },
]; ];
const classes = []; const classes = [];
for (const [index, seed] of classSeeds.entries()) { for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({ const created = await prisma.class.create({
data: { data: {
name: seed.name, name: formatClassName(seed.enrollmentYear, seed.classIndex),
grade: seed.grade, grade: seed.grade,
enrollmentYear: seed.enrollmentYear,
classIndex: seed.classIndex,
teacherId: seed.teacherId, teacherId: seed.teacherId,
code: seed.code, code: seed.code,
passwordHash: classPasswordHash, passwordHash: classPasswordHash,
@@ -126,37 +143,41 @@ async function main(): Promise<void> {
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' }); classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
} }
// 高二(3)班:李老师作为协作教师 // ---------------------------------------------------------------- 学生(只是名单,不是账号)
await prisma.classTeacher.create({
data: { classId: classes[2]!.id, teacherId: teacher2.id },
});
// ---------------------------------------------------------------- 学生
const students = []; const students = [];
for (let index = 0; index < STUDENT_NAMES.length; index += 1) { for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
const classIndex = Math.floor(index / 5); const classSlot = Math.floor(index / 5);
const targetClass = classes[classIndex] ?? classes[0]!; const targetClass = classes[classSlot] ?? classes[0]!;
const student = await prisma.user.create({ // 学号:入学年份 + 两位序号(与班级称呼同一套「届别」口径)
const student = await prisma.student.create({
data: { data: {
username: `student${String(index + 1).padStart(2, '0')}`, studentNo: `${targetClass.enrollmentYear}${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`, name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id, classId: targetClass.id,
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到 gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
passwordHash: '', guardianPhone: `138${String(10000000 + index * 137).slice(0, 8)}`,
}, },
}); });
students.push(student); students.push(student);
await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
} }
// ---------------------------------------------------------------- 课程 // ---------------------------------------------------------------- 课程(= 班级 + 科目 + 教师的任课关系)
//
// 每门课都要挂一位**任课老师**:作业与成绩能不能被某位老师改动,查的就是 `Course.teacherId`。
// 演示数据刻意让「语文」由另一位老师任教 —— 这样班主任在语文上就没有编辑权,
// 正好覆盖需求「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
const OTHER_TEACHER_SUBJECT = '语文';
const coursesByClass = new Map<string, { id: string; name: string }[]>(); const coursesByClass = new Map<string, { id: string; name: string }[]>();
for (const targetClass of classes) { for (const targetClass of classes) {
const otherTeacher = targetClass.teacherId === teacher1.id ? teacher2.id : teacher1.id;
const created = []; const created = [];
for (const courseName of COURSE_NAMES) { for (const courseName of COURSE_NAMES) {
const course = await prisma.course.create({ const course = await prisma.course.create({
data: { name: courseName, classId: targetClass.id, teacherId: targetClass.teacherId }, data: {
name: courseName,
classId: targetClass.id,
teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId,
},
}); });
created.push({ id: course.id, name: course.name }); created.push({ id: course.id, name: course.name });
} }
@@ -280,7 +301,7 @@ async function main(): Promise<void> {
const completed = (index + homework.title.length) % 3 !== 0; const completed = (index + homework.title.length) % 3 !== 0;
if (!completed && index % 2 === 1) continue; if (!completed && index % 2 === 1) continue;
await prisma.homeworkStatus.create({ await prisma.homeworkStatus.create({
data: { homeworkId: homework.id, userId: student.id, completed }, data: { homeworkId: homework.id, studentId: student.id, completed },
}); });
} }
} }
@@ -331,7 +352,7 @@ async function main(): Promise<void> {
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3); const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
for (const student of classmates) { for (const student of classmates) {
await prisma.notificationRead.create({ await prisma.notificationRead.create({
data: { notificationId: notification.id, userId: student.id }, data: { notificationId: notification.id, studentId: student.id },
}); });
} }
} }
@@ -339,23 +360,46 @@ async function main(): Promise<void> {
} }
// ---------------------------------------------------------------- 成绩 // ---------------------------------------------------------------- 成绩
//
// 三种等级口径各演示一门,方便在界面上看出差别:
// 数学(percent)—— 按得分率自动换算 A~E
// 语文(custom) —— 自定义等级文本
// 英语(letter) —— A / B / C / D
const examNames = ['第一次月考', '期中考试']; const examNames = ['第一次月考', '期中考试'];
const customLevels = ['优', '良', '合格'] as const;
const letterLevels = ['A', 'B', 'C', 'D'] as const;
const levelPlan = [
{ type: 'percent' as const },
{ type: 'custom' as const },
{ type: 'letter' as const },
];
for (const targetClass of classes) { for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? []; const classCourses = coursesByClass.get(targetClass.id) ?? [];
const classmates = studentsByClass.get(targetClass.id) ?? []; const classmates = studentsByClass.get(targetClass.id) ?? [];
for (const [examIndex, examName] of examNames.entries()) { for (const [examIndex, examName] of examNames.entries()) {
for (const course of classCourses.slice(0, 3)) { for (const [courseIndex, course] of classCourses.slice(0, 3).entries()) {
const plan = levelPlan[courseIndex % levelPlan.length]!;
for (const student of classmates) { for (const student of classmates) {
const base = 60 + random() * 38; const base = 60 + random() * 38;
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2))); const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
const percent = gradePercent(score, 100);
const level =
plan.type === 'percent'
? gradeLevel(percent)
: plan.type === 'letter'
? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D')
: (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? '合格');
await prisma.grade.create({ await prisma.grade.create({
data: { data: {
classId: targetClass.id, classId: targetClass.id,
courseId: course.id, courseId: course.id,
userId: student.id, studentId: student.id,
examName, examName,
score, score,
totalScore: 100, totalScore: 100,
levelType: plan.type,
level,
publishedAt: new Date(now - (examIndex + 1) * 6 * day), publishedAt: new Date(now - (examIndex + 1) * 6 * day),
}, },
}); });
@@ -364,24 +408,146 @@ async function main(): Promise<void> {
} }
} }
// ---------------------------------------------------------------- 调班 / 转出历史
//
// 调班只改 `Student.classId`(学号不变,历史作业与成绩留在原班级),并留一条可查的痕迹。
const movedStudent = students[7]!; // 赵依然:2026级2班 → 2026级1班(曾经)
await prisma.student.update({
where: { id: movedStudent.id },
data: { classId: classes[0]!.id },
});
await prisma.studentClassTransfer.create({
data: {
studentId: movedStudent.id,
studentNo: movedStudent.studentNo,
studentName: movedStudent.name,
fromClassId: classes[0]!.id,
fromClassName: classes[0]!.name,
toClassId: classes[1]!.id,
toClassName: classes[1]!.name,
operatorId: admin.id,
operatorName: admin.name,
mode: 'batch',
note: '军训后按分班考试成绩重新均衡',
createdAt: new Date(now - 30 * day),
},
});
// 转出:学籍离开本校(不是调班),班级归属保留作历史
const transferredStudent = students[13]!; // 朱佳琪
await prisma.student.update({
where: { id: transferredStudent.id },
data: {
status: 'transferred',
transferredAt: new Date(now - 12 * day),
transferNote: '随家长工作调动转学至外地',
},
});
await prisma.studentClassTransfer.create({
data: {
studentId: transferredStudent.id,
studentNo: transferredStudent.studentNo,
studentName: transferredStudent.name,
fromClassId: transferredStudent.classId,
fromClassName: classes[2]!.name,
toClassId: null,
toClassName: '转出本校',
operatorId: admin.id,
operatorName: admin.name,
mode: 'transfer-out',
note: '随家长工作调动转学至外地',
createdAt: new Date(now - 12 * day),
},
});
// ---------------------------------------------------------------- 已毕业届别(归档页的演示数据)
//
// 归档 = 打标记 + 只读:班级与它的作业/通知原样留在库里,只是从常规列表里隐去。
const graduatedClass = await prisma.class.create({
data: {
name: formatClassName(2023, 1),
grade: '高三',
enrollmentYear: 2023,
classIndex: 1,
teacherId: teacher1.id,
code: 'G231',
passwordHash: classPasswordHash,
},
});
const archivedYear = await prisma.archivedYear.create({
data: {
enrollmentYear: 2023,
graduationYear: 2023 + 3,
name: formatYearLabel(2023),
note: '演示用:2023 级已于 2026 年 6 月毕业',
operatorId: admin.id,
operatorName: admin.name,
archivedAt: new Date(now - 100 * day),
},
});
await prisma.class.update({
where: { id: graduatedClass.id },
data: { archivedYearId: archivedYear.id, archivedAt: new Date(now - 100 * day) },
});
const graduateNames = ['毕业演示甲', '毕业演示乙', '毕业演示丙'];
for (const [index, name] of graduateNames.entries()) {
await prisma.student.create({
data: {
studentNo: `2023${String(index + 1).padStart(2, '0')}`,
name,
classId: graduatedClass.id,
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
status: 'graduated',
archivedYearId: archivedYear.id,
archivedAt: new Date(now - 100 * day),
},
});
}
// 这一届也要有作业与通知,「记录每个年度毕业班级的消息和作业」才有东西可看
await prisma.homework.create({
data: {
classId: graduatedClass.id,
title: '高考前最后一轮复习',
content: '按考点清单过一遍错题本,重点看函数与数列。',
assignDate: dayKeyLocal(new Date(now - 120 * day)),
createdBy: teacher1.id,
},
});
await prisma.notification.create({
data: {
classId: graduatedClass.id,
title: '毕业典礼安排',
content: '6 月 20 日上午 9 点在礼堂举行毕业典礼,请全体同学准时参加。',
priority: 'HIGH',
createdBy: teacher1.id,
createdAt: new Date(now - 110 * day),
},
});
const counts = { const counts = {
用户: await prisma.user.count(), 账号: await prisma.user.count(),
班级: await prisma.class.count(), 在读班级: await prisma.class.count({ where: { archivedYearId: null } }),
学生: await prisma.student.count({ where: { status: 'active' } }),
课程: await prisma.course.count(), 课程: await prisma.course.count(),
课表: await prisma.schedule.count(), 课表: await prisma.schedule.count(),
作业: await prisma.homework.count(), 作业: await prisma.homework.count(),
通知: await prisma.notification.count(), 通知: await prisma.notification.count(),
成绩: await prisma.grade.count(), 成绩: await prisma.grade.count(),
调班历史: await prisma.studentClassTransfer.count(),
归档届别: await prisma.archivedYear.count(),
}; };
logger.info('种子数据写入完成', counts); logger.info('种子数据写入完成', counts);
console.log('\n================ 演示账号 ================'); console.log('\n================ 演示账号 ================');
console.log(`管理员 admin / admin123 (${admin.name})`); console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`); console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`); console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`);
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456'); console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)'); console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`);
console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`);
console.log('==========================================\n'); console.log('==========================================\n');
} }
@@ -38,8 +38,53 @@ const COLUMNS = [
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`, sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
note: '班级教学周数(班主任可调,默认 20)', note: '班级教学周数(班主任可调,默认 20)',
}, },
{
table: 'Class',
column: 'studentGradeQueryEnabled',
sql: `ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true`,
note: 'ClassHelper 班级端按学号查成绩的开关(默认开启)',
},
{
table: 'Grade',
column: 'levelType',
sql: `ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent'`,
note: '成绩等级口径:percent / letter / custom',
},
{
table: 'Grade',
column: 'level',
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
note: '成绩等级文本(percent 下由得分率换算,可手改)',
},
{
table: 'IntegrationDevice',
column: 'lastHeartbeatAt',
sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
},
{
table: 'User',
column: 'phone',
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
]; ];
/**
* ⚠ 本脚本只能补**新增的列**,补不了结构变化。
*
* 2026-10-06 那版「学生从账号变成名单记录」(`prisma/migrations/20261006120000_add_student_table`)
* 是一次真正的表重构:新建 `Student` / `StudentClassTransfer`、把 `Grade`/`HomeworkStatus`/
* `NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
* 这些**必须走正式的迁移链路**(`prisma migrate deploy`,安装程序升级时自动执行):
*
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js" # 只补列
* pnpm --filter @classhelper/server db:deploy # 结构变化走这条
*
* 只跑本脚本、不跑迁移的库会停在"列有了但表没换"的中间态 —— 下面的列已经能把
* 界面撑起来不报错,但学生数据仍留在 `User` 里,属于不可用状态。
*/
(async () => { (async () => {
let prisma; let prisma;
try { try {
+28 -5
View File
@@ -444,11 +444,34 @@ async function main() {
}); });
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`); record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取) // ---------------------------------------------------------------- 5. 课表镜像
//
// 同步方向在 2026-10-06 反转:**服务端 → 教室自动下发**成为主方向(默认开启),
// 「把教室课表传上来」改成 Web 端的一次性人工动作。因此这里先验证"默认就下发",
// 再验证"关掉之后确实不下发"。
const mirrorDefault = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'课表默认由服务端自动下发(新方向:服务端 → 教室)',
mirrorDefault.status === 200 && mirrorDefault.payload?.data?.classPlan?.entries?.length >= 0,
`status=${mirrorDefault.status} data=${mirrorDefault.payload?.data === null ? 'null' : 'object'}`,
);
const disabled = await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { mirrorScheduleToClassIsland: false },
});
record(
'关闭「镜像课表到 ClassIsland」',
disabled.status === 200 && disabled.payload?.data?.mirrorScheduleToClassIsland === false,
`status=${disabled.status}`,
);
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken }); const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
record( record(
'镜像开关关闭时不下发课表', '关掉镜像后不再下发课表',
mirrorOff.status === 200 && mirrorOff.payload?.data === null, mirrorOff.status === 200 &&
(mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`, `data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
); );
@@ -458,7 +481,7 @@ async function main() {
body: { mirrorScheduleToClassIsland: true }, body: { mirrorScheduleToClassIsland: true },
}); });
record( record(
'打开「镜像课表到 ClassIsland」', '重新打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true, enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`, `status=${enabled.status}`,
); );
@@ -562,7 +585,7 @@ async function main() {
// 第二次上报:只报第二节 → 第一节应当被清理。 // 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课), // 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// 学生端与 Web 端会一直显示一节实际上已经不上的课。 // ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', { const pruneReport = await api('/integrations/classisland/report', {
method: 'POST', method: 'POST',
deviceToken, deviceToken,
+360 -118
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* 端到端联调验证(对应验收标准): * 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到 * 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成 * 2. 教师发布作业 -> ClassHelper 班级端实时收到,并可标记完成
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查 * 3. 教师录入成绩 -> ClassHelper 班级端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口 * 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用 * 5. 课表按周查询可用
* *
@@ -52,7 +52,7 @@ function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
} }
async function main() { async function main() {
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`); console.log(`\n=== ClassHelper 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查 // ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health'); const health = await api('/health');
@@ -97,12 +97,12 @@ async function main() {
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`); record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
if (!adminToken) process.exit(1); if (!adminToken) process.exit(1);
// 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口) // ClassHelper 班级端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
const classesForLogin = await api('/classes', { token: teacherToken }); const classesForLogin = await api('/classes', { token: teacherToken });
const loginClass = classesForLogin.payload?.data?.[0]; const loginClass = classesForLogin.payload?.data?.[0];
if (!loginClass?.code) { if (!loginClass?.code) {
record( record(
'学生端班级登录(班级码 + 班级密码 → classSession)', 'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
false, false,
'没有可用班级码,请先执行 pnpm db:seed', '没有可用班级码,请先执行 pnpm db:seed',
); );
@@ -127,15 +127,19 @@ async function main() {
const studentToken = studentLogin.payload?.data?.token; const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user; const studentUser = studentLogin.payload?.data?.user;
record( record(
'学生端班级登录(班级码 + 班级密码 → classSession)', 'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
Boolean(studentToken) && studentUser?.classSession === true, Boolean(studentToken) && studentUser?.classSession === true,
`code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`, `code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`,
); );
// 学生不是账号:`User` 表里根本没有学生行,所以拿学号当账号登录只可能是"账号不存在"
const studentAsAccount = await api('/auth/login', {
method: 'POST',
body: { username: '202601', password: '123456' },
});
record( record(
'个人学生账号登录已停用(403)', '学生没有账号:拿学号当账号登录被拒(401)',
(await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } })) studentAsAccount.status === 401,
.status === 403, `status=${studentAsAccount.status}`,
'student01 → 403',
); );
const wrongPassword = await api('/auth/login', { const wrongPassword = await api('/auth/login', {
@@ -149,7 +153,7 @@ async function main() {
const classes = myClasses.payload?.data ?? []; const classes = myClasses.payload?.data ?? [];
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`); record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0]; const ownClass = classes.find((item) => item.name?.includes('2026级1')) ?? classes[0];
const classId = ownClass?.id; const classId = ownClass?.id;
if (!classId) { if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed'); record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
@@ -169,14 +173,17 @@ async function main() {
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? []; const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */ /** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = []; const probeClassIds = [];
/** 权限矩阵用例里新建的作业(收尾统一删除) */
const tempHomeworkIds = [];
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null; let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
if (!foreignClass) { if (!foreignClass) {
const created = await api('/classes', { const created = await api('/classes', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { body: {
name: `权限校验班 ${Date.now()}`,
grade: '高一', grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 93,
code: `PV${Date.now().toString().slice(-6)}`, code: `PV${Date.now().toString().slice(-6)}`,
...(teacher2Id ? { teacherId: teacher2Id } : {}), ...(teacher2Id ? { teacherId: teacher2Id } : {}),
}, },
@@ -194,6 +201,8 @@ async function main() {
const classmates = studentList.payload?.data ?? []; const classmates = studentList.payload?.data ?? [];
// 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例) // 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例)
const targetStudent = classmates[0]; const targetStudent = classmates[0];
/** 同班第二位学生的学号(导入用例里要造「两条不同的学号」) */
const classmateTwoNo = classmates[1]?.studentNo ?? '202602';
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`); record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken }); const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
@@ -420,7 +429,7 @@ async function main() {
}); });
}); });
record( record(
'学生端 Socket.IO 连接与鉴权', 'ClassHelper 班级端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error), Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`, connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
); );
@@ -441,9 +450,9 @@ async function main() {
try { try {
const { payload, elapsed } = await notificationWait; const { payload, elapsed } = await notificationWait;
record('学生端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`); record('ClassHelper 班级端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) { } catch (error) {
record('学生端 5 秒内收到 notification:new', false, error.message); record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
} }
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成 // ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
@@ -499,9 +508,9 @@ async function main() {
try { try {
const { payload, elapsed } = await homeworkWait; const { payload, elapsed } = await homeworkWait;
record('学生端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`); record('ClassHelper 班级端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) { } catch (error) {
record('学生端 5 秒内收到 homework:new', false, error.message); record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
} }
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, { const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
@@ -517,7 +526,7 @@ async function main() {
/* ---------------------------------------------------------------- 5.1 未交名单 */ /* ---------------------------------------------------------------- 5.1 未交名单 */
const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken }); const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken });
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.userId); const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.studentId);
record( record(
'教师可读取作业提交名单(全班学生 + 完成状态)', '教师可读取作业提交名单(全班学生 + 完成状态)',
submissions.status === 200 && submissions.status === 200 &&
@@ -531,13 +540,13 @@ async function main() {
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, { const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH', method: 'PATCH',
token: teacherToken, token: teacherToken,
body: { notSubmittedUserIds: [firstStudentId] }, body: { notSubmittedStudentIds: [firstStudentId] },
}); });
record( record(
'保存未交名单:勾选者标记未交、其余学生自动标记已交', '保存未交名单:勾选者标记未交、其余学生自动标记已交',
savedSubmissions.status === 200 && savedSubmissions.status === 200 &&
(savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 && (savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 &&
savedSubmissions.payload?.data?.notSubmitted?.[0]?.userId === firstStudentId && savedSubmissions.payload?.data?.notSubmitted?.[0]?.studentId === firstStudentId &&
savedSubmissions.payload?.data?.completedCount === studentIds.length - 1, savedSubmissions.payload?.data?.completedCount === studentIds.length - 1,
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` + `status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`, `已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
@@ -584,7 +593,7 @@ async function main() {
body: { body: {
classId, classId,
courseId, courseId,
userId: targetStudent?.id, studentId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`, examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88, score: 88,
totalScore: 100, totalScore: 100,
@@ -596,16 +605,16 @@ async function main() {
try { try {
const { payload, elapsed } = await gradeWait; const { payload, elapsed } = await gradeWait;
record( record(
'学生端 5 秒内收到 grade:updated', 'ClassHelper 班级端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS, elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`, `${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
); );
} catch (error) { } catch (error) {
record('学生端 5 秒内收到 grade:updated', false, error.message); record('ClassHelper 班级端 5 秒内收到 grade:updated', false, error.message);
} }
} else { } else {
gradeWait.catch(() => {}); gradeWait.catch(() => {});
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验'); record('ClassHelper 班级端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
} }
const myGrades = await api('/grades/my', { token: studentToken }); const myGrades = await api('/grades/my', { token: studentToken });
@@ -658,23 +667,42 @@ async function main() {
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`); record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
} }
const assigned = await api(`/classes/${classId}/teachers`, { // 任课关系 = 班级 + 科目 + 教师,唯一写入口是 PUT /classes/:id/subject-teachers。
method: 'POST', // 用一个演示数据里没用来录成绩的科目,验证完就还回班主任,避免影响后面的成绩权限用例。
const probeSubject = '通用技术';
const adminAssignSubject = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken, token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id }, body: { assignments: [{ subjectName: probeSubject, teacherId: teacher2Login.payload?.data?.user?.id }] },
}); });
record('分配协作教师', assigned.status === 201, `status=${assigned.status}`); record(
const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, { '管理员设置某班某科的任课老师(PUT /subject-teachers)',
method: 'DELETE', adminAssignSubject.status === 200 &&
(adminAssignSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject &&
item.teacherId === teacher2Login.payload?.data?.user?.id,
),
`status=${adminAssignSubject.status}`,
);
const restoreSubject = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken, token: adminToken,
body: { assignments: [{ subjectName: probeSubject, teacherId: ownClass?.teacherId }] },
}); });
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`); record(
'任课老师可改回班主任(同一个人既是班主任又是科任,不重复建号)',
restoreSubject.status === 200 &&
(restoreSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject && item.teacherId === ownClass?.teacherId,
),
`status=${restoreSubject.status}`,
);
const tempUsername = `e2e_student_${Date.now()}`; const tempUsername = `E2E${Date.now().toString().slice(-8)}`;
const addedStudent = await api(`/classes/${classId}/students`, { const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { username: tempUsername, name: '联调学生' }, body: { studentNo: tempUsername, name: '联调学生' },
}); });
const addedStudentId = addedStudent.payload?.data?.id; const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`); record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
@@ -682,8 +710,10 @@ async function main() {
if (addedStudentId) { if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken }); const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
record( record(
'学生名单包含新加入学生', '学生名单包含新加入学生(按学号)',
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId), (studentListAfter.payload?.data ?? []).some(
(item) => item.id === addedStudentId && item.studentNo === tempUsername.toUpperCase(),
),
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`, `共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
); );
@@ -728,7 +758,7 @@ async function main() {
courseId, courseId,
examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`, examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`,
totalScore: 100, totalScore: 100,
items: classmates.slice(0, 3).map((item, index) => ({ userId: item.id, score: 70 + index * 5 })), items: classmates.slice(0, 3).map((item, index) => ({ studentId: item.id, score: 70 + index * 5 })),
}, },
}); });
record( record(
@@ -739,9 +769,13 @@ async function main() {
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken }); const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record( record(
// 等级分布不再固定 A~E 五档:等级制(A-D)与自定义等级(优/良/合格)都在用,
// 因此断言"有分布且每个档位都有人",而不是数档位个数。
'成绩统计 /grades/stats', '成绩统计 /grades/stats',
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5, gradeStats.status === 200 &&
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`, (gradeStats.payload?.data?.distribution ?? []).length >= 1 &&
(gradeStats.payload?.data?.distribution ?? []).every((item) => typeof item.level === 'string'),
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}% 档位=${(gradeStats.payload?.data?.distribution ?? []).map((item) => item.level).join('/')}`,
); );
const unread = await api('/notifications/unread-count', { token: studentToken }); const unread = await api('/notifications/unread-count', { token: studentToken });
@@ -931,7 +965,7 @@ async function main() {
`status=${callByTeacher.status} title="${callTitle}"`, `status=${callByTeacher.status} title="${callTitle}"`,
); );
record( record(
'普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队', '普通叫人(默认)不是紧急级别:priority=HIGH,ClassHelper 班级端按普通通知排队',
callByTeacher.payload?.data?.priority === 'HIGH', callByTeacher.payload?.data?.priority === 'HIGH',
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`, `priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
); );
@@ -947,7 +981,7 @@ async function main() {
}, },
}); });
record( record(
'紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)', '紧急叫人(urgent=true → priority=URGENT,ClassHelper 班级端无视上课时段立即展开)',
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT', urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`, `status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
); );
@@ -1013,27 +1047,28 @@ async function main() {
{ {
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录'); record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
// 科任老师:teacher2 是某班的协作(科任)老师。 // 科任老师:teacher2 在某班**有任课关系**(Course.teacherId === 他)。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个: // 同样不依赖演示数据:推断出来的班级不可用(被人工改过 / 与外班同一个)时现建一个,
// teacher1 当班主任 + teacher2 协作,收尾删除。 // 用 teacher1 当班主任 + 给 teacher2 配一门课,收尾删除。
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null; let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
if (!subjectClass || subjectClass.id === foreignClass?.id) { if (!subjectClass || subjectClass.id === foreignClass?.id) {
const created = await api('/classes', { const created = await api('/classes', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { body: {
name: `科任校验班 ${Date.now()}`,
grade: '高一', grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 90,
code: `SJ${Date.now().toString().slice(-6)}`, code: `SJ${Date.now().toString().slice(-6)}`,
teacherId: teacher1Id, teacherId: teacher1Id,
}, },
}); });
const createdId = created.payload?.data?.id; const createdId = created.payload?.data?.id;
if (createdId) { if (createdId) {
await api(`/classes/${createdId}/teachers`, { await api(`/classes/${createdId}/subject-teachers`, {
method: 'POST', method: 'PUT',
token: adminToken, token: adminToken,
body: { teacherId: teacher2Id }, body: { assignments: [{ subjectName: '数学', teacherId: teacher2Id }] },
}); });
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' }; subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
probeClassIds.push(createdId); probeClassIds.push(createdId);
@@ -1050,53 +1085,52 @@ async function main() {
const headCreate = await api('/classes', { const headCreate = await api('/classes', {
method: 'POST', method: 'POST',
token: teacherToken, token: teacherToken,
body: { name: '班主任越权班', grade: '高一' }, body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 91 },
}); });
record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`); record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`);
const subjectCreate = await api('/classes', { const subjectCreate = await api('/classes', {
method: 'POST', method: 'POST',
token: teacher2Token, token: teacher2Token,
body: { name: '科任越权班', grade: '高一' }, body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 92 },
}); });
record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`); record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`);
const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken }); const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken });
record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`); record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`);
const headAssign = await api(`/classes/${classId}/teachers`, { const headAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'POST', method: 'PUT',
token: teacherToken, token: teacherToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id }, body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
}); });
record('班主任分配科任老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`); record('班主任分配任课老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
const subjectAssign = await api(`/classes/${classId}/teachers`, { const subjectAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'POST', method: 'PUT',
token: teacher2Token, token: teacher2Token,
body: { teacherId: teacher2Login.payload?.data?.user?.id }, body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
}); });
record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`); record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`);
// 管理员可以分配(用"先加后删"验证) // 管理员可以分配(用"先加后删"验证)
const adminAssign = await api(`/classes/${classId}/teachers`, { const adminAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'POST', method: 'PUT',
token: adminToken, token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id }, body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
}); });
record( record(
'管理员分配科任老师成功(201/200)', '管理员分配任课老师成功(200)',
[200, 201].includes(adminAssign.status), adminAssign.status === 200,
`status=${adminAssign.status}`, `status=${adminAssign.status}`,
); );
const adminUnassign = await api( // 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
{ method: 'PUT',
method: 'DELETE',
token: adminToken, token: adminToken,
}, body: { assignments: [{ subjectName: '音乐', teacherId: ownClass?.teacherId }] },
); });
record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`); record('管理员解除任课关系成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 课表:班主任可管理本班,科任老师不可 // 课表:班主任可管理本班,科任老师不可
if (subjectClassId) { if (subjectClassId) {
@@ -1120,16 +1154,59 @@ async function main() {
); );
} }
// 新规则:**作业按科目授权**。
// 科任老师只能布置自己任教科目(Course.teacherId)的作业;
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
const subjectCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId === teacher2Id);
const subjectHomework = await api('/homeworks', { const subjectHomework = await api('/homeworks', {
method: 'POST', method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken, token: subjectClassId ? teacher2Token : teacherToken,
body: { body: {
classId: subjectClassId, classId: subjectClassId,
courseId: subjectCourse?.id,
title: '科任老师布置的作业', title: '科任老师布置的作业',
content: '权限矩阵校验用', content: '权限矩阵校验用',
}, },
}); });
record('科任老师可布置作业(201)', subjectHomework.status === 201, `status=${subjectHomework.status}`); record(
'科任老师可布置自己任教科目的作业(201)',
subjectHomework.status === 201,
`status=${subjectHomework.status} 科目=${subjectCourse?.name ?? '-'}`,
);
if (subjectHomework.payload?.data?.id) {
tempHomeworkIds.push(subjectHomework.payload.data.id);
}
const subjectHomeworkNoCourse = await api('/homeworks', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
body: { classId: subjectClassId, title: '科任老师的不指定科目作业', content: '应被拒绝' },
});
record(
'科任老师布置不指定科目的作业被拒绝(403)',
subjectHomeworkNoCourse.status === 403,
`status=${subjectHomeworkNoCourse.status}`,
);
// 科任老师改**别人任教科目**的作业同样被拒
if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
const foreignHomework = await api('/homeworks', {
method: 'POST',
token: teacher2Token,
body: {
classId: subjectClassId,
courseId: otherCourse?.id,
title: '科任老师替别人发作业',
content: '应被拒绝',
},
});
record(
'科任老师给非任教科目布置作业被拒绝(403)',
foreignHomework.status === 403,
`status=${foreignHomework.status} 科目=${otherCourse?.name ?? '-'}`,
);
}
if (subjectHomework.payload?.data?.id) { if (subjectHomework.payload?.data?.id) {
await api(`/homeworks/${subjectHomework.payload.data.id}`, { await api(`/homeworks/${subjectHomework.payload.data.id}`, {
method: 'DELETE', method: 'DELETE',
@@ -1142,7 +1219,7 @@ async function main() {
token: teacher2Token, token: teacher2Token,
body: { body: {
classId: classId, classId: classId,
userId: targetStudent?.id, studentId: targetStudent?.id,
examName: '越权成绩', examName: '越权成绩',
score: 90, score: 90,
totalScore: 100, totalScore: 100,
@@ -1156,7 +1233,7 @@ async function main() {
token: teacherToken, token: teacherToken,
body: { body: {
classId: classId, classId: classId,
userId: targetStudent?.id, studentId: targetStudent?.id,
examName: '班主任本班成绩', examName: '班主任本班成绩',
score: 90, score: 90,
totalScore: 100, totalScore: 100,
@@ -1177,7 +1254,7 @@ async function main() {
token: teacherToken, token: teacherToken,
body: { body: {
classId: foreignClass.id, classId: foreignClass.id,
userId: targetStudent?.id, studentId: targetStudent?.id,
examName: '班主任跨班成绩', examName: '班主任跨班成绩',
score: 90, score: 90,
totalScore: 100, totalScore: 100,
@@ -1195,7 +1272,7 @@ async function main() {
token: adminToken, token: adminToken,
body: { body: {
classId: classId, classId: classId,
userId: targetStudent?.id, studentId: targetStudent?.id,
examName: '管理员录入成绩', examName: '管理员录入成绩',
score: 88, score: 88,
totalScore: 100, totalScore: 100,
@@ -1438,10 +1515,10 @@ async function main() {
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示) // 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [ const gradesCsv = [
'用户名,姓名,考试名称,分数,总分,课程', '学号,姓名,考试名称,分数,总分,科目',
'student01,,期中导入考试,92,100,', `${targetStudent?.studentNo ?? '202601'},,期中导入考试,92,100,`,
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`, `,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
'unknown-user,,期中导入考试,88,100,', 'UNKNOWN-USER,,期中导入考试,88,100,',
].join(NL); ].join(NL);
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64'); const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
@@ -1490,8 +1567,8 @@ async function main() {
const XLSX = xlsxNamespace.default ?? xlsxNamespace; const XLSX = xlsxNamespace.default ?? xlsxNamespace;
const xlsxSheet = XLSX.utils.aoa_to_sheet([ const xlsxSheet = XLSX.utils.aoa_to_sheet([
['学号', '姓名', '考试', '得分', '满分', '科目'], ['学号', '姓名', '考试', '得分', '满分', '科目'],
['student02', '', '期中导入考试', 85, 100, ''], [classmateTwoNo, '', '期中导入考试', 85, 100, ''],
['student01', '', '期中导入考试', 78, 100, ''], [targetStudent?.studentNo ?? '202601', '', '期中导入考试', 78, 100, ''],
]); ]);
const xlsxBook = XLSX.utils.book_new(); const xlsxBook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩'); XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
@@ -1506,13 +1583,13 @@ async function main() {
'XLSX 成绩表预览(同义词自动映射 学号/得分)', 'XLSX 成绩表预览(同义词自动映射 学号/得分)',
xlsxPreview.status === 200 && xlsxPreview.status === 200 &&
xlsxPreview.payload?.data?.totalRows === 2 && xlsxPreview.payload?.data?.totalRows === 2 &&
xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' && xlsxPreview.payload?.data?.suggestedMapping?.studentNo === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分', xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`, `status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
); );
const commitMapping = { const commitMapping = {
username: '学号', studentNo: '学号',
name: '姓名', name: '姓名',
examName: '考试', examName: '考试',
score: '得分', score: '得分',
@@ -1573,12 +1650,12 @@ async function main() {
fileName: 'grades.csv', fileName: 'grades.csv',
contentBase64: gradesCsvBase64, contentBase64: gradesCsvBase64,
mapping: { mapping: {
username: '用户名', studentNo: '学号',
name: '姓名', name: '姓名',
examName: '考试名称', examName: '考试名称',
score: '分数', score: '分数',
totalScore: '总分', totalScore: '总分',
courseName: '课程', courseName: '科目',
}, },
mode: 'append', mode: 'append',
}, },
@@ -1608,9 +1685,9 @@ async function main() {
); );
// 8.4 学生名单导入(仅管理员)+ 数据清理 // 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`; const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性) // 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL); const studentsCsv = ['学号,姓名', `${importedUsername},导入测试生`, 'bad@user,非法学号'].join(NL);
const studentsCommit = await api('/imports/table/commit', { const studentsCommit = await api('/imports/table/commit', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
@@ -1619,12 +1696,12 @@ async function main() {
classId, classId,
fileName: 'students.csv', fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'), contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名' }, mapping: { studentNo: '学号', name: '姓名' },
mode: 'append', mode: 'append',
}, },
}); });
record( record(
'学生名单导入(新增 1 / 非法用户名进错误行 1)', '学生名单导入(新增 1 / 非法学号进错误行 1)',
studentsCommit.status === 200 && studentsCommit.status === 200 &&
studentsCommit.payload?.data?.inserted === 1 && studentsCommit.payload?.data?.inserted === 1 &&
studentsCommit.payload?.data?.failed === 1, studentsCommit.payload?.data?.failed === 1,
@@ -1653,12 +1730,12 @@ async function main() {
fileName: 'grades.csv', fileName: 'grades.csv',
contentBase64: gradesCsvBase64, contentBase64: gradesCsvBase64,
mapping: { mapping: {
username: '用户名', studentNo: '学号',
name: '姓名', name: '姓名',
examName: '考试名称', examName: '考试名称',
score: '分数', score: '分数',
totalScore: '总分', totalScore: '总分',
courseName: '课程', courseName: '科目',
}, },
mode: 'upsert', mode: 'upsert',
}, },
@@ -1672,8 +1749,8 @@ async function main() {
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`, `status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
); );
// teacher2 是高二(3)班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景 // teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
const subjectOnlyClass = classes.find((item) => item.name?.includes('高二(3)')) ?? null; const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null;
const subjectGradesImport = await api('/imports/table/commit', { const subjectGradesImport = await api('/imports/table/commit', {
method: 'POST', method: 'POST',
token: teacher2Token, token: teacher2Token,
@@ -1683,12 +1760,12 @@ async function main() {
fileName: 'grades.csv', fileName: 'grades.csv',
contentBase64: gradesCsvBase64, contentBase64: gradesCsvBase64,
mapping: { mapping: {
username: '用户名', studentNo: '学号',
name: '姓名', name: '姓名',
examName: '考试名称', examName: '考试名称',
score: '分数', score: '分数',
totalScore: '总分', totalScore: '总分',
courseName: '课程', courseName: '科目',
}, },
mode: 'upsert', mode: 'upsert',
}, },
@@ -1696,7 +1773,7 @@ async function main() {
record( record(
'科任老师导入本班成绩被拒绝(403,需求 7)', '科任老师导入本班成绩被拒绝(403,需求 7)',
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403, Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
`班级=${subjectOnlyClass?.name ?? '未找到高二(3)班'} status=${subjectGradesImport.status}`, `班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
); );
if (foreignClass) { if (foreignClass) {
@@ -1709,12 +1786,12 @@ async function main() {
fileName: 'grades.csv', fileName: 'grades.csv',
contentBase64: gradesCsvBase64, contentBase64: gradesCsvBase64,
mapping: { mapping: {
username: '用户名', studentNo: '学号',
name: '姓名', name: '姓名',
examName: '考试名称', examName: '考试名称',
score: '分数', score: '分数',
totalScore: '总分', totalScore: '总分',
courseName: '课程', courseName: '科目',
}, },
mode: 'upsert', mode: 'upsert',
}, },
@@ -1749,7 +1826,7 @@ async function main() {
classId, classId,
fileName: 'students.csv', fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'), contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名' }, mapping: { studentNo: '学号', name: '姓名' },
mode: 'append', mode: 'append',
}, },
}); });
@@ -1774,7 +1851,7 @@ async function main() {
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`); record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
const roster = await api(`/students?classId=${classId}`, { token: adminToken }); const roster = await api(`/students?classId=${classId}`, { token: adminToken });
const importedStudent = (roster.payload?.data ?? []).find((item) => item.username === importedUsername); const importedStudent = (roster.payload?.data ?? []).find((item) => item.studentNo === importedUsername);
if (importedStudent) { if (importedStudent) {
const removedStudent = await api(`/students/${importedStudent.id}`, { const removedStudent = await api(`/students/${importedStudent.id}`, {
method: 'DELETE', method: 'DELETE',
@@ -1795,7 +1872,7 @@ async function main() {
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`); record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
} }
// ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级) // ---------------------------------------------------------------- 9. 班级账号(ClassHelper 班级端主体 = 班级)
// 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩) // 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩)
// 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。 // 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。
// 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。 // 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。
@@ -1887,7 +1964,7 @@ async function main() {
const classGradesWrite = await api('/grades', { const classGradesWrite = await api('/grades', {
method: 'POST', method: 'POST',
token: classToken, token: classToken,
body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 }, body: { classId, studentId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
}); });
record( record(
'班级账号录入成绩被拒绝(403)', '班级账号录入成绩被拒绝(403)',
@@ -2020,10 +2097,10 @@ async function main() {
const classNameGrade = await api('/grades', { const classNameGrade = await api('/grades', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { classId, userId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 }, body: { classId, studentId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
}); });
const classMyGrades = await api('/grades/my', { token: classToken }); const classMyGrades = await api('/grades/my', { token: classToken });
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId)); const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.studentId));
record( record(
'班级账号查看成绩 = 全班总览', '班级账号查看成绩 = 全班总览',
classMyGrades.status === 200 && classMyGrades.status === 200 &&
@@ -2180,7 +2257,7 @@ async function main() {
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略) // 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
const teacherCsv = const teacherCsv =
'\ufeff用户名,姓名,初始密码,角色\n' + '\ufeff工号,姓名,初始密码,角色\n' +
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`; `imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64'); const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? ''; const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
@@ -2202,7 +2279,7 @@ async function main() {
kind: 'teachers', kind: 'teachers',
fileName: 'teachers.csv', fileName: 'teachers.csv',
contentBase64: teacherCsvBase64, contentBase64: teacherCsvBase64,
mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' }, mapping: { username: '工号', name: '姓名', password: '初始密码', role: '角色' },
mode: 'upsert', mode: 'upsert',
}, },
}); });
@@ -2253,11 +2330,11 @@ async function main() {
}); });
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token; const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
// 1) 先让他成为该班科任(这样才有发布权限),发一条通知 // 1) 先让他成为该班某科的任课老师(这样才有发布通知的权限),发一条通知
await api(`/classes/${classId}/teachers`, { await api(`/classes/${classId}/subject-teachers`, {
method: 'POST', method: 'PUT',
token: adminToken, token: adminToken,
body: { teacherId: cascadeTeacherId }, body: { assignments: [{ subjectName: '美术', teacherId: cascadeTeacherId }] },
}); });
const cascadeNotification = await api('/notifications', { const cascadeNotification = await api('/notifications', {
method: 'POST', method: 'POST',
@@ -2265,10 +2342,11 @@ async function main() {
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' }, body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
}); });
const cascadeNotificationId = cascadeNotification.payload?.data?.id; const cascadeNotificationId = cascadeNotification.payload?.data?.id;
// 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容 // 2) 解除任课关系(把该科还回班主任):此时他"不在任何班级、名下无课程",但发布过内容
await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, { await api(`/classes/${classId}/subject-teachers`, {
method: 'DELETE', method: 'PUT',
token: adminToken, token: adminToken,
body: { assignments: [{ subjectName: '美术', teacherId: ownClass?.teacherId }] },
}); });
// 3) 删除账号应当被拒(409),且那条通知必须还在 // 3) 删除账号应当被拒(409),且那条通知必须还在
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, { const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
@@ -2303,7 +2381,7 @@ async function main() {
const teacherCreateClass = await api('/classes', { const teacherCreateClass = await api('/classes', {
method: 'POST', method: 'POST',
token: teacherToken, token: teacherToken,
body: { name: '越权班级', grade: '高一' }, body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 94 },
}); });
record( record(
'仅管理员可创建班级(教师 403)', '仅管理员可创建班级(教师 403)',
@@ -2316,8 +2394,9 @@ async function main() {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { body: {
name: `冒烟班级 ${Date.now()}`,
grade: '高一', grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 95,
code: tempClassCode, code: tempClassCode,
...(teacher2 ? { teacherId: teacher2.id } : {}), ...(teacher2 ? { teacherId: teacher2.id } : {}),
}, },
@@ -2357,11 +2436,11 @@ async function main() {
const dbStatus = await api('/database/status', { token: adminToken }); const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {}; const dbStatusData = dbStatus.payload?.data ?? {};
record( record(
'数据库状态(管理员):已连接且返回 14 张表行数', '数据库状态(管理员):已连接且返回 15 张表行数',
dbStatus.status === 200 && dbStatus.status === 200 &&
dbStatusData.connected === true && dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) && Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 && dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0), dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${ `provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length (dbStatusData.tables ?? []).length
@@ -2478,10 +2557,10 @@ async function main() {
snapshotParsed = null; snapshotParsed = null;
} }
record( record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)', '导出快照(JSON 格式正确、15 张表、账号数 > 0)',
dbExport.status === 200 && dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' && snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 && Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
(snapshotParsed?.counts?.User ?? 0) > 0, (snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`, `status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
); );
@@ -2665,6 +2744,169 @@ async function main() {
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`, `status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
); );
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
const termGlobal = await api('/term', { token: adminToken });
record(
'学期周次:读取全校默认口径',
termGlobal.status === 200 &&
typeof termGlobal.payload?.data?.currentWeek === 'number' &&
Array.isArray(termGlobal.payload?.data?.weeks),
`status=${termGlobal.status} 当前第 ${termGlobal.payload?.data?.currentWeek ?? '-'} 周 配置=${termGlobal.payload?.data?.configured}`,
);
const termForTeacher = await api('/term', { token: teacherToken });
record(
'学期周次:教师可读(课表页要显示"第几周")',
termForTeacher.status === 200,
`status=${termForTeacher.status}`,
);
// 用一段**覆盖今天**的区间,验证"当前周次按区间判定"而不是按开学日期线性推算
const today = new Date();
const pad = (value) => String(value).padStart(2, '0');
const dayKey = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const rangeStart = new Date(today.getTime() - 2 * 86400000);
const rangeEnd = new Date(today.getTime() + 2 * 86400000);
const beforeTerm = (await api('/term', { token: adminToken })).payload?.data;
const termSaved = await api('/term', {
method: 'PUT',
token: adminToken,
body: {
weeks: [
{ weekNumber: 7, startDate: dayKey(rangeStart), endDate: dayKey(rangeEnd), note: 'e2e 区间回归' },
],
},
});
record(
'学期周次:管理员保存逐周区间(PUT /term)',
termSaved.status === 200 &&
termSaved.payload?.data?.configured === true &&
termSaved.payload?.data?.currentWeek === 7,
`status=${termSaved.status} 当前第 ${termSaved.payload?.data?.currentWeek ?? '-'} 周(区间覆盖今天应为第 7 周)`,
);
const termOverlap = await api('/term', {
method: 'PUT',
token: adminToken,
body: {
weeks: [
{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-10' },
{ weekNumber: 2, startDate: '2026-09-05', endDate: '2026-09-14' },
],
},
});
record(
'学期周次:区间重叠被拒绝(400,避免"当前第几周"出现歧义)',
termOverlap.status === 400,
`status=${termOverlap.status} message=${termOverlap.payload?.message ?? ''}`,
);
const termDenied = await api('/term', {
method: 'PUT',
token: teacherToken,
body: { weeks: [{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' }] },
});
record(
'学期周次:教师写入被拒绝(403,周次会让全校课表错位)',
termDenied.status === 403,
`status=${termDenied.status}`,
);
const termAuto = await api('/term/auto', {
method: 'POST',
token: adminToken,
body: { termStartDate: '2026-09-07', maxWeek: 3 },
});
record(
'学期周次:按开学日期一键生成(POST /term/auto)',
termAuto.status === 200 &&
(termAuto.payload?.data?.weeks ?? []).length === 3 &&
termAuto.payload?.data?.weeks?.[0]?.startDate === '2026-09-07' &&
termAuto.payload?.data?.weeks?.[2]?.endDate === '2026-09-27',
`status=${termAuto.status} 首周=${termAuto.payload?.data?.weeks?.[0]?.startDate} 末周=${termAuto.payload?.data?.weeks?.[2]?.endDate}`,
);
const holidays = await api('/term/holidays', { token: adminToken });
record(
'学期周次:联网拉取放假安排(机房无外网时 ok=false 属正常结果,不能抛 500)',
holidays.status === 200 && typeof holidays.payload?.data?.ok === 'boolean',
`status=${holidays.status} ok=${holidays.payload?.data?.ok} 条数=${holidays.payload?.data?.holidays?.length ?? 0}`,
);
// 收尾:还原成纯线性推算,不污染演示数据
await api('/term', {
method: 'PUT',
token: adminToken,
body: { termStartDate: beforeTerm?.termStartDate ?? undefined, weeks: [] },
});
const termRestored = await api('/term', { token: adminToken });
record(
'学期周次用例收尾:清空逐周配置(回到按开学日期线性推算)',
termRestored.payload?.data?.configured === false,
`configured=${termRestored.payload?.data?.configured}`,
);
/* ---------------------------------------------------------------- 14. 课表同步方向反转 */
// 自建一台临时设备(收尾删掉):不依赖演示库里有没有接入过设备
const tempDevice = await api('/integrations/devices', {
method: 'POST',
token: adminToken,
body: { classId, name: 'E2E 课表方向校验设备' },
});
const tempDeviceId = tempDevice.payload?.data?.device?.id;
record(
'课表方向:新建设备默认「服务端自动下发、不自动回传」',
tempDevice.status === 201 &&
tempDevice.payload?.data?.device?.syncScheduleToServer === false &&
tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland === true,
`status=${tempDevice.status} syncScheduleToServer=${tempDevice.payload?.data?.device?.syncScheduleToServer} mirrorScheduleToClassIsland=${tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland}`,
);
if (tempDeviceId) {
const pullRequest = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
method: 'POST',
token: adminToken,
});
record(
'从教室机器获取课表:管理员可发起(POST /request-schedule)',
pullRequest.status === 200 && pullRequest.payload?.data?.requested === true,
`status=${pullRequest.status} ${pullRequest.payload?.message ?? ''}`,
);
// 置位后再读设备列表应当看到待办(插件下一次心跳看到就会立刻把课表推上来)
const afterRequest = await api(`/integrations/devices?classId=${classId}`, { token: adminToken });
const requestedDevice = (afterRequest.payload?.data ?? []).find((item) => item.id === tempDeviceId);
record(
'从教室机器获取课表:待办标记已落库(插件下次心跳会照做)',
requestedDevice?.requestScheduleReport === true,
`requestScheduleReport=${requestedDevice?.requestScheduleReport}`,
);
// 教师对**别人班**的设备不能发起
const deniedPull = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
method: 'POST',
token: teacher2Token,
});
record(
'从教室机器获取课表:非本班教师被拒绝(403)',
deniedPull.status === 403,
`status=${deniedPull.status}`,
);
// 收尾:删掉临时设备
const removedDevice = await api(`/integrations/devices/${tempDeviceId}`, {
method: 'DELETE',
token: adminToken,
});
record('课表方向用例收尾:临时设备已删除', removedDevice.status === 200, `status=${removedDevice.status}`);
}
socket.close(); socket.close();
// ---------------------------------------------------------------- 汇总 // ---------------------------------------------------------------- 汇总
+4 -4
View File
@@ -98,7 +98,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>班级小助手 · 后端服务</title> <title>ClassHelper · 后端服务</title>
<style> <style>
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; } body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; } .card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
@@ -116,7 +116,7 @@ function renderLandingPage(webAdminServed: boolean): string {
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1> <h1>ClassHelper · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p> <p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub"> <p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> · 健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
@@ -127,7 +127,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<ul>${moduleRows}</ul> <ul>${moduleRows}</ul>
<div class="hint"> <div class="hint">
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br /> WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。 本服务只提供 REST API 与 WebSocket;ClassHelper 班级端请使用「ClassHelper」桌面客户端。
</div> </div>
</div> </div>
</body> </body>
@@ -182,7 +182,7 @@ export function createApp(): Express {
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护) // 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
app.use(API_PREFIX, apiRateLimiter()); app.use(API_PREFIX, apiRateLimiter());
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端 // 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(ClassHelper 班级端
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载, // 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。 // 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter()); app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
+1 -1
View File
@@ -27,7 +27,7 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('7d'), JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('*'), CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10), BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */ /** 新建班级时的默认班级密码(ClassHelper 班级端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'), DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */ /** 新建/重置教师账号时的默认密码(管理员录入教师用) */
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'), DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
+1 -1
View File
@@ -79,7 +79,7 @@ httpServer.listen(env.port, env.host, () => {
writePidFile(); writePidFile();
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host; const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
const webDir = resolveWebDistDir(); const webDir = resolveWebDistDir();
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`); logger.info(`ClassHelper后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`); logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
if (webDir) { if (webDir) {
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`); logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
+160 -65
View File
@@ -1,10 +1,11 @@
import { import {
canManageGrades,
canManageSchedule,
canPublishContent,
canManageClasses,
canAssignTeachers, canAssignTeachers,
canManageClasses,
canManageGrades,
canManageRoster, canManageRoster,
canManageSchedule,
canManageSubjectContent,
canPublishNotification,
resolveClassRole, resolveClassRole,
type ClassRole, type ClassRole,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -13,11 +14,20 @@ import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
/** /**
* 权限(RBAC)规则: * 权限(RBAC)规则。
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表 *
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人 * | 角色 | 范围 | 能做什么 |
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人 * | ---- | ---- | -------- |
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据 * | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
*
* 三条铁律(都是这次重构的落点):
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
* 3. **所有权限都在后端判**,前端只做展示控制。
* *
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。 * 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/ */
@@ -30,14 +40,36 @@ export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER'; return user.role === 'TEACHER';
} }
export function isStudent(user: TokenPayload): boolean { /** 教师与管理员(staff):能以自己的身份操作班级内容 */
return user.role === 'STUDENT';
}
export function isStaff(user: TokenPayload): boolean { export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER'; return user.role === 'ADMIN' || user.role === 'TEACHER';
} }
/**
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
*/
export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
export function classDeviceClassId(user: TokenPayload): string | null {
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
}
/**
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
*
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/
function classMembershipWhere(teacherId: string) {
return {
OR: [{ teacherId }, { courses: { some: { teacherId } } }],
};
}
/** 当前用户可访问的全部班级 id */ /** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> { export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> {
if (isAdmin(user)) { if (isAdmin(user)) {
@@ -47,39 +79,23 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise<string[
if (isTeacher(user)) { if (isTeacher(user)) {
const classes = await prisma.class.findMany({ const classes = await prisma.class.findMany({
where: { OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }] }, where: classMembershipWhere(user.sub),
select: { id: true }, select: { id: true },
}); });
return classes.map((item) => item.id); return classes.map((item) => item.id);
} }
const ids = new Set<string>(); const classId = classDeviceClassId(user);
if (user.classId) ids.add(user.classId); return classId ? [classId] : [];
const enrollments = await prisma.enrollment.findMany({
where: { userId: user.sub },
select: { classId: true },
});
for (const item of enrollments) ids.add(item.classId);
return [...ids];
} }
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> { export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
if (isAdmin(user)) return true; if (isAdmin(user)) return true;
if (isTeacher(user)) { if (isTeacher(user)) {
const count = await prisma.class.count({ const count = await prisma.class.count({ where: { id: classId, ...classMembershipWhere(user.sub) } });
where: {
id: classId,
OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }],
},
});
return count > 0; return count > 0;
} }
if (user.classId === classId) return true; return classDeviceClassId(user) === classId;
const enrollment = await prisma.enrollment.findFirst({
where: { userId: user.sub, classId },
select: { id: true },
});
return enrollment !== null;
} }
/** 读权限断言 */ /** 读权限断言 */
@@ -89,31 +105,31 @@ export async function assertClassAccess(user: TokenPayload, classId: string): Pr
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据'); if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
} }
/** 写权限断言:学生一律拒绝 */ /** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> { export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user)) throw ApiError.forbidden('学生账号没有管理权限'); if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
await assertClassAccess(user, classId); await assertClassAccess(user, classId);
} }
/* ---------------------------------------------------------------- 班级内角色 */ /* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */ /** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> { export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN'; if (user.role === 'ADMIN') return 'ADMIN';
if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE')); if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
const record = await prisma.class.findUnique({ const record = await prisma.class.findUnique({
where: { id: classId }, where: { id: classId },
select: { select: {
teacherId: true, teacherId: true,
teachers: { where: { teacherId: user.sub }, select: { id: true } }, courses: { where: { teacherId: user.sub }, select: { id: true } },
}, },
}); });
if (!record) return 'NONE'; if (!record) return 'NONE';
return resolveClassRole({ return resolveClassRole({
role: user.role, role: user.role,
isHeadTeacher: record.teacherId === user.sub, isHeadTeacher: record.teacherId === user.sub,
isSubjectTeacher: record.teachers.length > 0, teachesSubjects: record.courses.length > 0,
}); });
} }
@@ -124,17 +140,30 @@ export function assertCanManageClasses(user: TokenPayload): void {
} }
} }
/** 班主任 / 科任老师分配:仅管理员 */ /** 班主任 / 各科科任老师的分配:仅管理员 */
export function assertCanAssignTeachers(user: TokenPayload): void { export function assertCanAssignTeachers(user: TokenPayload): void {
if (!canAssignTeachers(user.role)) { if (!canAssignTeachers(user.role)) {
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师'); throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
} }
} }
/** 学生名单管理:仅管理员 */ /**
export function assertCanManageRoster(user: TokenPayload): void { * 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
if (!canManageRoster(user.role)) { *
throw ApiError.forbidden('只有管理员可以管理学生名单与账号'); * @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT'
? '科任老师不能管理学生名单,请联系班主任或管理员'
: '只能管理自己担任班主任的班级的学生名单',
);
} }
} }
@@ -143,30 +172,32 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin
const classRole = await resolveUserClassRole(user, classId); const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) { if (!canManageSchedule(classRole)) {
throw ApiError.forbidden( throw ApiError.forbidden(
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表', classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
: '无权管理该班级的课表',
); );
} }
} }
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */ /** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> { export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId); const classRole = await resolveUserClassRole(user, classId);
if (!canPublishContent(classRole)) { if (!canPublishNotification(classRole)) {
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限'); throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
} }
} }
/** /**
* 成绩录入 / 修改 / 导入:管理员,或**本班班主任**(需求 6"老师端可用且不越权")。 * 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
* 科任老师仍然被拒(需求 7:科任只有作业 / 叫人 / 通知)。
* *
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载) * @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
*
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
*/ */
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> { export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) { if (!classId) {
// 没有班级上下文(例如下载空白模板):只有管理员可以执行 if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
if (user.role !== 'ADMIN') throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return; return;
} }
@@ -174,22 +205,86 @@ export async function assertCanManageGrades(user: TokenPayload, classId?: string
if (canManageGrades(classRole)) return; if (canManageGrades(classRole)) return;
throw ApiError.forbidden( throw ApiError.forbidden(
classRole === 'SUBJECT' classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师不能录入或导入成绩,请联系班主任或管理员' ? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
: '只能录入或导入自己担任班主任的班级的成绩', : '只能录入自己担任班主任的班级的成绩',
); );
} }
/** 学生的主班级,未分配班级时抛 403 */ /**
export function requireStudentClassId(user: TokenPayload): string { * **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任'); *
return user.classId; * | 情形 | 谁可以写 |
* | ---- | -------- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
* | 没挂科目(courseId 为空) | 本班**班主任** |
*
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
*
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
*/
export async function assertCanManageSubjectContent(
user: TokenPayload,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean } = {},
): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
const allowClassDevice = options.allowClassDevice === true;
// 管理员不受科目限制,提前返回省掉一次课程查询
if (classRole === 'ADMIN') return;
if (classRole === 'CLASS_DEVICE') {
if (allowClassDevice) return;
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
}
let isSubjectTeacherOfCourse = false;
let courseName = '';
if (courseId) {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { classId: true, teacherId: true, name: true },
});
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
courseName = course.name;
isSubjectTeacherOfCourse = course.teacherId === user.sub;
}
const allowed = canManageSubjectContent({
classRole,
hasSubject: Boolean(courseId),
isSubjectTeacherOfCourse,
allowClassDevice,
});
if (allowed) return;
throw ApiError.forbidden(
courseId
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
: `未指定科目的内容只有班主任或管理员可以${action}`,
);
}
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
export function requireClassDeviceClassId(user: TokenPayload): string {
const classId = classDeviceClassId(user);
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
return classId;
} }
/** /**
* 查询范围解析: * 查询范围解析:
* - 传入 classId:校验后只查该班级 * - 传入 classId:校验后只查该班级
* - 未传:教师/学生只能查自己可访问的班级;管理员不加限制 * - 未传:教师只能查自己可访问的班级;管理员不加限制
*/ */
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] }; export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
@@ -215,8 +310,8 @@ export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } }
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } }; return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
} }
/** 单班级场景:学生强制取自己班级,其他角色沿用请求参数 */ /** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined { export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isStudent(user)) return requireStudentClassId(user); if (isClassDevice(user)) return requireClassDeviceClassId(user);
return requestedClassId; return requestedClassId;
} }
+20 -6
View File
@@ -4,12 +4,12 @@ import { ApiError } from './http.js';
import { hashPassword, verifyPassword } from './password.js'; import { hashPassword, verifyPassword } from './password.js';
/** /**
* 班级账号(学生端「班级」主体): * ClassHelper 班级端(ClassHelper 班级端「班级」主体):
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt); * - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT; * - 教室机器用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT(角色 `CLASS_DEVICE`);
* - 管理员可在「班级管理」里修改班级码、重置班级密码。 * - 管理员可在「班级管理」里修改班级码、重置班级密码。
* *
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。 * **学生没有账号**:`Student` 只是名单记录,这里与它无关。
*/ */
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */ /** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
@@ -31,7 +31,7 @@ export async function generateClassCode(prefix = ''): Promise<string> {
const length = Math.max(4, 6 - prefix.length); const length = Math.max(4, 6 - prefix.length);
let body = ''; let body = '';
for (let index = 0; index < length; index += 1) { for (let index = 0; index < length; index += 1) {
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴, // 用 CSPRNG 而不是 Math.random():班级码会作为 ClassHelper 班级端登录的账号名打印/张贴,
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差) // 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)]; body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
} }
@@ -91,18 +91,27 @@ export async function updateClassAccount(
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) }; return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
} }
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */ /** ClassHelper 班级端登录:返回班级记录(密码校验在 service 层完成) */
export async function findClassForLogin(code: string): Promise<{ export async function findClassForLogin(code: string): Promise<{
id: string; id: string;
name: string; name: string;
grade: string; grade: string;
code: string; code: string;
passwordHash: string | null; passwordHash: string | null;
studentGradeQueryEnabled: boolean;
}> { }> {
const normalized = normalizeClassCode(code); const normalized = normalizeClassCode(code);
const record = await prisma.class.findUnique({ const record = await prisma.class.findUnique({
where: { code: normalized }, where: { code: normalized },
select: { id: true, name: true, grade: true, code: true, passwordHash: true }, select: {
id: true,
name: true,
grade: true,
code: true,
passwordHash: true,
studentGradeQueryEnabled: true,
archivedYearId: true,
},
}); });
if (!record) throw ApiError.unauthorized('班级码或班级密码错误'); if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
if (!record.passwordHash) { if (!record.passwordHash) {
@@ -112,6 +121,11 @@ export async function findClassForLogin(code: string): Promise<{
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置', '该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
); );
} }
// 已毕业归档的班级不再允许教室机器登录:继续登进去只会看到一个只读的历史壳子,
// 明确拒绝比"能进但没有东西可做"好排查。
if (record.archivedYearId) {
throw new ApiError(403, 'CLASS_ARCHIVED', '该班级已毕业归档,教室端已停止使用,请联系管理员');
}
return record; return record;
} }
+2
View File
@@ -30,6 +30,8 @@ export type ApiErrorCode =
| 'IMPORT_EMPTY' | 'IMPORT_EMPTY'
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */ /** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
| 'CLASS_PASSWORD_NOT_SET' | 'CLASS_PASSWORD_NOT_SET'
/** 班级账号:该班级已毕业归档,教室端已停止使用 */
| 'CLASS_ARCHIVED'
| 'INTERNAL_ERROR'; | 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */ /** 业务异常:中间件会把它转换成统一错误响应体 */
+4 -3
View File
@@ -7,13 +7,14 @@ import { ApiError } from './http.js';
/** JWT 载荷(同时作为 req.auth.user 的类型来源) */ /** JWT 载荷(同时作为 req.auth.user 的类型来源) */
export interface TokenPayload { export interface TokenPayload {
sub: string; sub: string;
/** 教师/管理员的工号,或 ClassHelper 班级端的班级码 */
username: string; username: string;
name: string; name: string;
role: UserRole; role: UserRole;
classId: string | null; classId: string | null;
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */ /** true 表示「ClassHelper 班级端」登录:sub 是班级 id,而非某个账号 */
classSession?: boolean; classSession?: boolean;
/** 班级账号的班级码(便于日志/审计) */ /** 班级端的班级码(便于日志/审计) */
classCode?: string; classCode?: string;
} }
@@ -31,7 +32,7 @@ export function verifyToken(token: string): TokenPayload {
const record = decoded as Record<string, unknown>; const record = decoded as Record<string, unknown>;
const role = record.role; const role = record.role;
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'STUDENT') { if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'CLASS_DEVICE') {
throw new Error('unexpected role in token'); throw new Error('unexpected role in token');
} }
+236 -29
View File
@@ -1,5 +1,7 @@
import { dayKeyLocal } from '@classhelper/shared'; import { dayKeyLocal } from '@classhelper/shared';
import type { import type {
ArchivedClassDto,
ArchivedYearDto,
ClassDto, ClassDto,
ClassIslandNotificationChannel, ClassIslandNotificationChannel,
ClassTeacherBrief, ClassTeacherBrief,
@@ -11,7 +13,10 @@ import type {
NotificationDto, NotificationDto,
NotificationPriority, NotificationPriority,
ScheduleDto, ScheduleDto,
SessionUser,
StudentBrief,
StudentDto, StudentDto,
StudentTransferDto,
UserDto, UserDto,
UserRole, UserRole,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -28,13 +33,13 @@ const toIso = (value: Date): string => value.toISOString();
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null); const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
/** /**
* "个人记录"归属范围解析: * "个人记录"归属范围解析(记录键是**学生 id**):
* - 传入 userIds(班级账号 = 全班学生)时优先使用; * - 传入 studentIds(ClassHelper 班级端 = 全班学生)时优先使用;
* - 否则退化为单个 userId(普通学生账号 = 自己)。 * - 否则退化为单个 studentId。
*/ */
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] { function resolvePool(options: { studentId?: string | null; studentIds?: string[] }): string[] {
if (options.userIds && options.userIds.length > 0) return options.userIds; if (options.studentIds && options.studentIds.length > 0) return options.studentIds;
return options.userId ? [options.userId] : []; return options.studentId ? [options.studentId] : [];
} }
export interface UserLike { export interface UserLike {
@@ -42,7 +47,8 @@ export interface UserLike {
username: string; username: string;
name: string; name: string;
role: string; role: string;
classId: string | null; /** 手机号;老库/老对象可能没有这个字段 */
phone?: string | null;
createdAt: Date; createdAt: Date;
} }
@@ -52,7 +58,32 @@ export interface TeacherBriefLike {
username: string; username: string;
} }
export interface StudentLike extends UserLike { /** 学生简要信息(id + 学号 + 姓名) */
export interface StudentBriefLike {
id: string;
studentNo: string;
name: string;
}
/**
* 学生记录(`Student` 表)。
*
* 学生不是账号 —— 这里没有 `role`、没有 `passwordHash`,
* 唯一的标识是 `studentNo`(学号)。
*/
export interface StudentLike {
id: string;
studentNo: string;
name: string;
classId: string | null;
gender: string;
guardianPhone: string;
status: string;
archivedYearId?: string | null;
archivedAt?: Date | null;
transferredAt?: Date | null;
transferNote?: string;
createdAt: Date;
class?: { name: string; grade: string } | null; class?: { name: string; grade: string } | null;
} }
@@ -67,19 +98,78 @@ export function toUserDto(user: UserLike): UserDto {
username: user.username, username: user.username,
name: user.name, name: user.name,
role: user.role as UserRole, role: user.role as UserRole,
classId: user.classId, phone: user.phone ?? '',
createdAt: toIso(user.createdAt), createdAt: toIso(user.createdAt),
}; };
} }
export function toStudentDto(user: StudentLike): StudentDto { /** 账号的会话主体(登录返回 / GET /auth/me) */
export function toUserSessionUser(user: UserLike): SessionUser {
return { return {
...toUserDto(user), id: user.id,
className: user.class?.name ?? null, username: user.username,
grade: user.class?.grade ?? null, name: user.name,
role: user.role as UserRole,
phone: user.phone ?? '',
classId: null,
createdAt: toIso(user.createdAt),
}; };
} }
/** ClassHelper 班级端登录时用到的班级字段 */
export interface ClassDeviceLike {
id: string;
code: string;
name: string;
grade: string;
studentGradeQueryEnabled: boolean;
createdAt: Date;
}
/**
* ClassHelper 班级端的会话主体:`id` 与 `classId` 都是**班级 id**,
* 角色是 `CLASS_DEVICE`(学生不是账号,因此这里不会出现学生身份)。
*/
export function toClassDeviceUser(record: ClassDeviceLike): SessionUser {
return {
id: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
createdAt: toIso(record.createdAt),
};
}
export function toStudentDto(student: StudentLike): StudentDto {
return {
id: student.id,
studentNo: student.studentNo,
name: student.name,
classId: student.classId,
className: student.class?.name ?? null,
grade: student.class?.grade ?? null,
gender: (student.gender ?? '') as StudentDto['gender'],
guardianPhone: student.guardianPhone ?? '',
status: (student.status ?? 'active') as StudentDto['status'],
archivedYearId: student.archivedYearId ?? null,
archivedAt: toIsoOrNull(student.archivedAt),
transferredAt: toIsoOrNull(student.transferredAt),
transferNote: student.transferNote ?? '',
createdAt: toIso(student.createdAt),
};
}
/** 学生简要信息(成绩、提交名单等引用位) */
export function toStudentBrief(student: StudentBriefLike): StudentBrief {
return { id: student.id, studentNo: student.studentNo, name: student.name };
}
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief { export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
return { id: user.id, name: user.name, username: user.username }; return { id: user.id, name: user.name, username: user.username };
} }
@@ -94,11 +184,20 @@ export interface ClassLike {
id: string; id: string;
name: string; name: string;
grade: string; grade: string;
/** 入学年份(届别);老数据可能为空 */
enrollmentYear?: number | null;
/** 班号;老数据可能为空 */
classIndex?: number | null;
teacherId: string; teacherId: string;
/** 本学期教学周数(班主任可调,默认 20) */ /** 本学期教学周数(班主任可调,默认 20) */
termWeeks?: number; termWeeks?: number;
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */ /** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
notificationChannel?: string | null; notificationChannel?: string | null;
/** 班级端按学号查成绩的开关 */
studentGradeQueryEnabled?: boolean | null;
/** 毕业归档所属届别;null = 在读 */
archivedYearId?: string | null;
archivedAt?: Date | null;
createdAt: Date; createdAt: Date;
teacher?: TeacherBriefLike | null; teacher?: TeacherBriefLike | null;
_count?: { _count?: {
@@ -120,9 +219,15 @@ export function toClassDto(item: ClassLike): ClassDto {
id: item.id, id: item.id,
name: item.name, name: item.name,
grade: item.grade, grade: item.grade,
enrollmentYear: item.enrollmentYear ?? null,
classIndex: item.classIndex ?? null,
teacherId: item.teacherId, teacherId: item.teacherId,
termWeeks: item.termWeeks ?? 20, termWeeks: item.termWeeks ?? 20,
notificationChannel: toNotificationChannel(item.notificationChannel), notificationChannel: toNotificationChannel(item.notificationChannel),
// 默认开启:老库补列时的默认值也是 true,两边保持一致
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
archivedYearId: item.archivedYearId ?? null,
archivedAt: toIsoOrNull(item.archivedAt),
createdAt: toIso(item.createdAt), createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null, teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
studentCount: item._count?.students ?? undefined, studentCount: item._count?.students ?? undefined,
@@ -193,7 +298,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
export interface HomeworkStatusLike { export interface HomeworkStatusLike {
id: string; id: string;
homeworkId: string; homeworkId: string;
userId: string; studentId: string;
completed: boolean; completed: boolean;
updatedAt: Date; updatedAt: Date;
} }
@@ -218,24 +323,24 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
return { return {
id: item.id, id: item.id,
homeworkId: item.homeworkId, homeworkId: item.homeworkId,
userId: item.userId, studentId: item.studentId,
completed: item.completed, completed: item.completed,
updatedAt: toIso(item.updatedAt), updatedAt: toIso(item.updatedAt),
}; };
} }
/** /**
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus * @param options.studentId 指定单个学生,用于填充 completed / homeworkStatus
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态 * @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找完成状态
* @param options.withStatus 是否统计完成人数(教师视角) * @param options.withStatus 是否统计完成人数(教师视角)
*/ */
export function toHomeworkDto( export function toHomeworkDto(
item: HomeworkLike, item: HomeworkLike,
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {}, options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
): HomeworkDto { ): HomeworkDto {
const statuses = item.statuses ?? []; const statuses = item.statuses ?? [];
const pool = resolvePool(options); const pool = resolvePool(options);
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined; const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.studentId)) : undefined;
return { return {
id: item.id, id: item.id,
@@ -261,7 +366,7 @@ export function toHomeworkDto(
export interface NotificationReadLike { export interface NotificationReadLike {
id: string; id: string;
notificationId: string; notificationId: string;
userId: string; studentId: string;
readAt: Date; readAt: Date;
} }
@@ -278,17 +383,17 @@ export interface NotificationLike {
} }
/** /**
* @param options.userId 指定当前用户(普通学生账号) * @param options.studentId 指定单个学生
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态 * @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找已读状态
* @param options.withStatus 是否统计已读人数(教师视角) * @param options.withStatus 是否统计已读人数(教师视角)
*/ */
export function toNotificationDto( export function toNotificationDto(
item: NotificationLike, item: NotificationLike,
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {}, options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
): NotificationDto { ): NotificationDto {
const reads = item.reads ?? []; const reads = item.reads ?? [];
const pool = resolvePool(options); const pool = resolvePool(options);
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined; const own = pool.length > 0 ? reads.find((read) => pool.includes(read.studentId)) : undefined;
return { return {
id: item.id, id: item.id,
@@ -311,13 +416,17 @@ export interface GradeLike {
id: string; id: string;
classId: string; classId: string;
courseId: string | null; courseId: string | null;
userId: string; studentId: string;
examName: string; examName: string;
score: number; score: number;
totalScore: number; totalScore: number;
/** 等级口径:percent / letter / custom(老库可能为空,按 percent 兜底) */
levelType?: string | null;
/** 等级文本(老库可能为空) */
level?: string | null;
publishedAt: Date; publishedAt: Date;
course?: CourseBriefLike | null; course?: CourseBriefLike | null;
student?: TeacherBriefLike | null; student?: StudentBriefLike | null;
class?: { name: string } | null; class?: { name: string } | null;
} }
@@ -326,13 +435,111 @@ export function toGradeDto(item: GradeLike): GradeDto {
id: item.id, id: item.id,
classId: item.classId, classId: item.classId,
courseId: item.courseId, courseId: item.courseId,
userId: item.userId, studentId: item.studentId,
examName: item.examName, examName: item.examName,
score: item.score, score: item.score,
totalScore: item.totalScore, totalScore: item.totalScore,
levelType: (item.levelType ?? 'percent') as GradeDto['levelType'],
level: item.level ?? '',
publishedAt: toIso(item.publishedAt), publishedAt: toIso(item.publishedAt),
course: item.course ? toCourseBrief(item.course) : null, course: item.course ? toCourseBrief(item.course) : null,
student: item.student ? toTeacherBrief(item.student) : null, student: item.student ? toStudentBrief(item.student) : null,
className: item.class?.name ?? null, className: item.class?.name ?? null,
}; };
} }
/* ------------------------------------------------------------------ 调班 / 归档 */
export interface StudentTransferLike {
id: string;
studentId: string;
studentNo: string;
studentName: string;
fromClassId: string | null;
fromClassName: string;
toClassId: string | null;
toClassName: string;
operatorId: string | null;
operatorName: string;
mode: string;
note?: string | null;
createdAt: Date;
}
export function toStudentTransferDto(item: StudentTransferLike): StudentTransferDto {
return {
id: item.id,
studentId: item.studentId,
studentNo: item.studentNo,
studentName: item.studentName,
fromClassId: item.fromClassId,
fromClassName: item.fromClassName,
toClassId: item.toClassId,
toClassName: item.toClassName,
operatorId: item.operatorId,
operatorName: item.operatorName,
mode: item.mode as StudentTransferDto['mode'],
note: item.note ?? '',
createdAt: toIso(item.createdAt),
};
}
export interface ArchivedYearLike {
id: string;
enrollmentYear: number;
graduationYear: number;
name: string;
note: string;
operatorId: string | null;
operatorName: string;
classCount: number;
studentCount: number;
transferredCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: Date;
}
export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
return {
id: item.id,
enrollmentYear: item.enrollmentYear,
graduationYear: item.graduationYear,
name: item.name,
note: item.note,
operatorId: item.operatorId,
operatorName: item.operatorName,
classCount: item.classCount,
studentCount: item.studentCount,
transferredCount: item.transferredCount,
homeworkCount: item.homeworkCount,
notificationCount: item.notificationCount,
archivedAt: toIso(item.archivedAt),
};
}
export interface ArchivedClassLike {
id: string;
name: string;
grade: string;
enrollmentYear: number | null;
classIndex: number | null;
archivedAt: Date | null;
teacher?: { name: string } | null;
_count?: { students?: number; homeworks?: number; notifications?: number };
}
export function toArchivedClassDto(item: ArchivedClassLike): ArchivedClassDto {
return {
id: item.id,
name: item.name,
grade: item.grade,
enrollmentYear: item.enrollmentYear,
classIndex: item.classIndex,
headTeacherName: item.teacher?.name ?? null,
studentCount: item._count?.students ?? 0,
homeworkCount: item._count?.homeworks ?? 0,
notificationCount: item._count?.notifications ?? 0,
archivedAt: toIsoOrNull(item.archivedAt),
};
}
+41
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { isValidStudentNo, normalizeStudentNo } from '@classhelper/shared';
/** 通用路径参数::id */ /** 通用路径参数::id */
export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') }); export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') });
@@ -52,3 +53,43 @@ export const attachmentUrlSchema = z
/** 周次:1..30 */ /** 周次:1..30 */
export const weekNumberSchema = z.coerce.number().int().min(1).max(30); export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
/**
* 学号:学生的唯一标识与查询键。
*
* 只做**格式**校验(1~32 位数字/字母/`._-`),归一化(去空白)后入库;
* 唯一性由 `Student.studentNo` 的唯一约束 + service 层的大小写无关查重共同保证。
*/
export const studentNoSchema = z
.string()
.trim()
.min(1, '请填写学号')
.max(32, '学号最多 32 位')
.transform((value) => normalizeStudentNo(value))
.refine((value) => isValidStudentNo(value), {
message: '学号只能包含字母、数字与 . _ -(1~32 位)',
});
/** 学生姓名 */
export const studentNameSchema = z.string().trim().min(1, '请输入姓名').max(32);
/** 学生性别:'' / MALE / FEMALE */
export const studentGenderSchema = z.enum(['', 'MALE', 'FEMALE']).optional();
/** 学生状态 */
export const studentStatusSchema = z.enum(['active', 'graduated', 'transferred', 'inactive']).optional();
/** 家长手机号(可空) */
export const guardianPhoneSchema = z.string().trim().max(32).optional();
/** 入学年份(届别) */
export const enrollmentYearSchema = z.coerce.number().int().min(2000).max(2100);
/** 班号 */
export const classIndexSchema = z.coerce.number().int().min(1).max(99);
/** 成绩等级口径 */
export const gradeLevelTypeSchema = z.enum(['percent', 'letter', 'custom']);
/** 成绩等级文本(自定义等级也要能填,所以只限长度) */
export const gradeLevelSchema = z.string().trim().max(16);
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more