diff --git a/AGENTS.md b/AGENTS.md
index f060e7b..f4dd407 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,7 +28,7 @@
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
-ClassIsland(教室机器)──► ClassHelper联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
+ClassIsland(教室机器)──► ClassHelper 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
```
@@ -105,23 +105,27 @@ website/ 产品官网(**纯静态、无构建步骤**,
docs/dev/testing.md 的「截图留档」;客户端那两张要配合冒烟的
ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
⚠ download.html 的版本清单是**当场**读 GitHub Releases 的
- (assets/download.js)—— 渲染出的卡片与文件行都是拉到数据之后才建的,
- 建完会派发一次 ch:content,由 main.js 补挂按压反馈(新增动态内容照这个来)。
+ (assets/download.js):版本分段控件 → 一行状态 → 「装什么」分段控件
+ (客户端 / 服务端 / 插件,**默认停在客户端**)→ 卡片。卡片是拉到数据之后
+ 才建的,建完会派发一次 ch:content,由 main.js 补挂按压反馈
+ (新增动态内容照这个来)。两个分段控件共用同一套指示器实现。
+ 卡片排版照 ClassIsland 官网那页:图标 + 名称 + 系统要求 + 主按钮 + 变体下拉
+ (,点别处或 Esc 收起);插件那张只有一个文件,不摆点不开的箭头。
读不到时退回 并按发布命名约定拼直链 ——
那个 meta 是 version.mjs 的落点之一,发版必须一起改。
命名约定与 README「开始使用」表里那三个名字要对齐
(ClassHelper-<版本>-x64-client-setup.exe / -client-portable.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/
- prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
- prisma/migrations/ 10 个迁移(init / time_layout / class_account / schedule_week_parity /
- class_term_weeks / classisland_integration / class_notification_channel /
- homework_assign_date / push_kind / schedule_source)
+ prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
+ prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
+ 2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
+ add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
- scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-05 实测 190 项)
- scripts/verify-classisland.mjs 联动链路验收(实测 42 项)
+ scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
+ scripts/verify-classisland.mjs 联动链路验收(实测 44 项)
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
@@ -130,8 +134,10 @@ packages/server/
password schemas session term version(读 package.json) web-static
src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
- src/modules/ 15 个功能模块 + registry.ts + module.types.ts
-packages/web-admin/src/ api stores router layouts views(12) components(5) composables styles config.ts
+ src/modules/ 17 个功能模块 + registry.ts + module.types.ts
+ (含 archives 毕业归档、term 学期周次)
+packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts
+ (含 ArchivesView 毕业归档、TermWeeksView 学期周次)
packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
@@ -140,7 +146,7 @@ packages/desktop-client/
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win
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)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs
@@ -148,7 +154,7 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
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`);
> `.env` 的 `DEFAULT_CLASS_PASSWORD` 只影响**新建**班级时的初始班级密码。
-> **学生没有个人账号**(2026-10-01 清理):`student01…15` 只是名单记录(成绩/未交/叫人/已读按名单),
-> `User.passwordHash` 为空串、不能登录,也没有任何密码接口;ClassHelper 班级端统一用班级码 + 班级密码。
-> 班级码 `G203` 是「高二(3)班」,不是 `G103`。
+>
+> **学生不是账号**(2026-10-06 起由表结构保证):`Student` 表里根本没有 `passwordHash` / `role`,
+> 学号(`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:studio` | Prisma Studio |
| `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:desktop` | 客户端冒烟(Electron,无人工点击) |
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
-| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) |
-| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
+| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) |
+| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) |
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) |
@@ -272,7 +284,7 @@ releases/
免安装/ electron-builder 的 win-unpacked 内容
构建中间/ builder-*.yml、*.nsis.7z
server/<版本>/
- 安装包/ ClassHelper服务端-<版本>-x64-setup.exe
+ 安装包/ ClassHelper 服务端-<版本>-x64-setup.exe
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/)
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
构建中间/ server-installer.generated.nsi
@@ -434,22 +446,28 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
## 5. 架构硬约定(改代码前必须知道)
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules//`(`*.module.ts` 路由、
- `*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 15 个:`auth / classes / courses / schedules /
-homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard /
-database / update`。
+ `*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 17 个:`auth / classes / courses / schedules /
+homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
+term / dashboard / database / update`。
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
- `@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`,
- 权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` / `canManageGrades` 等
- (人类可读矩阵是 `PERMISSION_MATRIX`,README 直接引用它),**不要在页面里另写一套规则**。
-4. **ClassHelper 班级端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」
- 代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。
-5. **实时推送只走房间。** 服务端 `src/realtime/`(`SOCKET_ROOMS`:`class:{classId}`、`user:{id}`、
- `teacher:{id}`、`role:{role}`、`students`、`teachers`;`bus.ts` 事件总线),客户端
- `renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
- 客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量 `SOCKET_EVENTS`,不要手写字符串。
+ `@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/
+ CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublishNotification` /
+ `canManageRoster` / `canManageGrades` / `canManageSubjectContent` 等
+ (人类可读矩阵是 `PERMISSION_MATRIX`),**不要在页面里另写一套规则**。
+4. **学生不是账号 —— 这是表结构保证的,别在代码里"兼容"学生账号。**
+ `Student` 表没有 `passwordHash` / `role`,学生不能登录、没有任何密码接口;
+ 学号(`Student.studentNo`)是学生的唯一标识与查询键(名单 / 成绩 / 作业提交 / 导入导出全用它)。
+ 曾经那套"`User.role='STUDENT'` + 空串密码占位 + 登录前判 role 再 403"的写法在 2026-10-06
+ 整体删除,**不要再加回来**:任何 `role === 'STUDENT'` 的分支都是回归。
+5. **任课关系的唯一来源是 `Course`(班级 + 科目 + 教师)。** 没有第二张任课表
+ (历史上那张无科目的 `ClassTeacher` 已删除)。作业与成绩的写权限必须走
+ **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**:
+ 挂了科目就只能由该科的任课老师改,没挂科目则归班主任 / 管理员;
+ 班级端只有**作业**可以写(成绩不给)。**新增"发布内容"类入口必须复用它**,否则又会出现
+ "某个页面能改别人的科目"。
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
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. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
@@ -1310,9 +1364,9 @@ pnpm lint # ESLint(0 error 为底线)
pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端
-pnpm verify:e2e # 后端端到端(2026-10-05 实测 190 项全过)
-pnpm verify:classisland # ClassIsland 联动链路(实测 42 项)
-pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项)
+pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过)
+pnpm verify:classisland # ClassIsland 联动链路(实测 44 项)
+pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 74 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
```
diff --git a/README.md b/README.md
index 9421309..9083959 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。
### Web 管理端(老师 / 管理员)
- [x] 仪表盘:本班人数、课程、课表、作业、通知、成绩一屏概览
-- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、添加协作教师
+- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、按科目配置各科任课老师
- [x] 学生 / 教师管理:单个录入或导入名单(xlsx / xls / csv / tsv)
- [x] 课表管理:按周排课、单双周,可直接从 ClassIsland 档案导入课表与节次时间
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
@@ -78,7 +78,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。
- [x] 教室机器上报课表与上课状态,Web 端能看到「现在在上什么」
- [x] 老师发的提醒在教室大屏上全屏弹出,支持语音朗读,播完回执不重复弹
-- [x] 可以把ClassHelper排好的课表镜像回 ClassIsland,不影响老师原有的课表
+- [x] 可以把 ClassHelper排好的课表镜像回 ClassIsland,不影响老师原有的课表
- [x] 提醒弹在客户端还是 ClassIsland,由教室那台机器自己选
### 部署与运维
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 400cce8..f92e3da 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -1,4 +1,4 @@
-# 班级小助手服务端 · 生产镜像
+# ClassHelper 服务端 · 生产镜像
#
# 两阶段构建:
# build —— 安装依赖并编译 shared / server / web-admin
diff --git a/deploy/classhelper b/deploy/classhelper
index 0ba0f08..8520370 100644
--- a/deploy/classhelper
+++ b/deploy/classhelper
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
-# 班级小助手 · 运维命令(Linux)
+# ClassHelper · 运维命令(Linux)
#
# 安装位置:<安装目录>/bin/classhelper,并在 /usr/local/bin/classhelper 建软链,
# 因此任何用户在任何目录都能直接敲 `classhelper`。
@@ -668,7 +668,7 @@ EOF
return 0
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' "健康探针" "$([ "$healthy" = "yes" ] && printf '%s通过%s' "$C_GREEN" "$C_RESET" || printf '%s无响应%s' "$C_RED" "$C_RESET")"
printf ' %-10s %s\n' "安装形态" "${MODE}"
@@ -1522,7 +1522,7 @@ EOF
cmd_help() {
cat < [参数]
@@ -1534,7 +1534,7 @@ ${C_BOLD}查看与诊断${C_RESET}
${C_BOLD}账号${C_RESET}
password [用户名] 修改密码(默认 admin);交互输入、不回显、不进历史
password --list 列出可登录账号与班级账号(含是否已设密码)
- password --class <班级码> 修改班级密码(学生端用「班级码 + 班级密码」登录)
+ password --class <班级码> 修改班级密码(ClassHelper 班级端用「班级码 + 班级密码」登录)
${C_BOLD}备份与恢复${C_RESET}
backup [--note 说明] [--keep N] 备份配置 + 数据库到 ${BACKUP_DIR}
diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml
index 8009354..4c5bd22 100644
--- a/deploy/docker-compose.sqlite.yml
+++ b/deploy/docker-compose.sqlite.yml
@@ -1,4 +1,4 @@
-# 班级小助手 · Docker 单容器部署(SQLite + 数据卷)
+# ClassHelper · Docker 单容器部署(SQLite + 数据卷)
#
# 适用:云服务器 / 与别的服务共用一台机器。相比 deploy/docker-compose.yml(MySQL 变体)
# 少跑一个数据库容器,内存占用小很多;数据落在 Docker 卷 classhelper-data 里。
@@ -23,7 +23,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
- image: classhelper-server:${APP_VERSION:-1.1.2}
+ image: classhelper-server:${APP_VERSION:-1.2.0}
restart: unless-stopped
environment:
NODE_ENV: production
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 6e9ac52..ab12bdf 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -1,4 +1,4 @@
-# 班级小助手 · 生产部署(MySQL + 服务端 + 可选 HTTPS 反代)
+# ClassHelper · 生产部署(MySQL + 服务端 + 可选 HTTPS 反代)
#
# 使用:
# 1) 复制环境变量模板并填写密钥:cp deploy/.env.example deploy/.env
@@ -60,7 +60,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
- image: classhelper-server:${APP_VERSION:-1.1.2}
+ image: classhelper-server:${APP_VERSION:-1.2.0}
restart: unless-stopped
environment:
NODE_ENV: production
diff --git a/deploy/install.sh b/deploy/install.sh
index ed4fa3b..5cc338d 100644
--- a/deploy/install.sh
+++ b/deploy/install.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
-# 班级小助手 · 服务端 Linux 一键安装器
+# ClassHelper · 服务端 Linux 一键安装器
#
# 支持:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+
# (自动识别 apt / yum / dnf;直接安装形态要求 systemd)
@@ -53,7 +53,7 @@ set -euo pipefail
# ---------------------------------------------------------------- 集中变量
-PRODUCT="班级小助手"
+PRODUCT="ClassHelper"
SERVICE_NAME="classhelper"
INSTALL_DIR="/opt/classhelper"
@@ -179,7 +179,7 @@ usage() {
return 0
fi
cat <<'USAGE'
-班级小助手 · 服务端 Linux 安装器
+ClassHelper · 服务端 Linux 安装器
(本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数;
完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/management/linux-deploy.md )
@@ -793,7 +793,7 @@ write_config() {
cat >"$CONFIG_FILE" <"$compose" <"$target" <
- 页面不存在 | 班级小助手文档
+ 页面不存在 | ClassHelper文档
@@ -94,7 +128,7 @@ onMounted(async () => {
通知模式
- :老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
+ :老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
{
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
- 提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
+ 提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
ClassIsland 上弹")。
+
+
+
+ 成绩查询
+
+ 打开后,可以在「成绩」页按学号查到本班学生的成绩明细(含等级)。
+ 学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。
+ 关闭后本机查成绩会被服务端拒绝,教师端不受影响。
+
+ applyGradeQuery(value === true)"
+ />
+
diff --git a/packages/server/package.json b/packages/server/package.json
index 518ed4c..c3a9916 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,8 +1,8 @@
{
"name": "@classhelper/server",
- "version": "1.1.2",
+ "version": "1.2.0",
"private": true,
- "description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
+ "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
"main": "dist/index.js",
"scripts": {
diff --git a/packages/server/prisma/migrations/20261006120000_add_student_table/migration.sql b/packages/server/prisma/migrations/20261006120000_add_student_table/migration.sql
new file mode 100644
index 0000000..6a87459
--- /dev/null
+++ b/packages/server/prisma/migrations/20261006120000_add_student_table/migration.sql
@@ -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");
diff --git a/packages/server/prisma/migrations/20261006120100_add_student_grade_query/migration.sql b/packages/server/prisma/migrations/20261006120100_add_student_grade_query/migration.sql
new file mode 100644
index 0000000..fc6df9d
--- /dev/null
+++ b/packages/server/prisma/migrations/20261006120100_add_student_grade_query/migration.sql
@@ -0,0 +1,8 @@
+-- ClassHelper 班级端成绩查询开关(按班级存,默认开启)
+--
+-- 需求:「ClassHelper 班级端可按学号查询本班学生成绩明细;是否允许学生通过班级端查看本人明细,
+-- 由教师或管理员开关控制」。
+--
+-- 默认 true 是刻意的:教室机器装好就能查(开箱可用),要收紧的班级由班主任/管理员关掉即可。
+-- 关闭后班级端调 `GET /api/grades/student/:studentNo` 一律 403,教师端不受影响。
+ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true;
diff --git a/packages/server/prisma/migrations/20261006120200_add_grade_level/migration.sql b/packages/server/prisma/migrations/20261006120200_add_grade_level/migration.sql
new file mode 100644
index 0000000..86a22a7
--- /dev/null
+++ b/packages/server/prisma/migrations/20261006120200_add_grade_level/migration.sql
@@ -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" = '';
diff --git a/packages/server/prisma/migrations/20261006120300_add_classhelper_heartbeat/migration.sql b/packages/server/prisma/migrations/20261006120300_add_classhelper_heartbeat/migration.sql
new file mode 100644
index 0000000..437ea37
--- /dev/null
+++ b/packages/server/prisma/migrations/20261006120300_add_classhelper_heartbeat/migration.sql
@@ -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;
diff --git a/packages/server/prisma/migrations/20261006130000_add_archives/migration.sql b/packages/server/prisma/migrations/20261006130000_add_archives/migration.sql
new file mode 100644
index 0000000..ac8c10e
--- /dev/null
+++ b/packages/server/prisma/migrations/20261006130000_add_archives/migration.sql
@@ -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");
diff --git a/packages/server/prisma/migrations/20261006140000_add_term_weeks/migration.sql b/packages/server/prisma/migrations/20261006140000_add_term_weeks/migration.sql
new file mode 100644
index 0000000..4863eb2
--- /dev/null
+++ b/packages/server/prisma/migrations/20261006140000_add_term_weeks/migration.sql
@@ -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");
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 3dd7823..b09de24 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -1,4 +1,4 @@
-// 班级小助手 - 数据模型
+// ClassHelper - 数据模型
//
// 设计说明:
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
@@ -6,10 +6,22 @@
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
-// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
+// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'CLASS_DEVICE'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
// 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 {
provider = "prisma-client"
@@ -20,101 +32,229 @@ datasource db {
provider = "sqlite"
}
-/// 用户:教师 / 管理员 / 学生统一存储
+/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
id String @id @default(cuid())
+ /// 登录名:教师即**工号**,班级端即班级码
username String @unique
passwordHash String
name String
- role String @default("STUDENT")
- classId String?
+ /// ADMIN | TEACHER | CLASS_DEVICE
+ role String @default("TEACHER")
+ /// 手机号(教师联系方式,可空)
+ phone String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- // 学生的主班级(一个学生属于一个主班级)
- class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull)
- // 教师创建(负责)的班级
- ownedClasses Class[] @relation("ClassOwner")
- // 教师被分配协作的班级
- classAssignments ClassTeacher[]
+ /// 教师担任班主任的班级(每班 1 人)
+ ownedClasses Class[] @relation("ClassOwner")
+ /// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
courses Course[]
- homeworkStatuses HomeworkStatus[]
- notificationReads NotificationRead[]
- grades Grade[]
- enrollments Enrollment[]
- createdHomeworks Homework[] @relation("HomeworkCreator")
- createdNotices Notification[] @relation("NotificationCreator")
+ createdHomeworks Homework[] @relation("HomeworkCreator")
+ createdNotices Notification[] @relation("NotificationCreator")
- @@index([classId])
@@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 {
id String @id @default(cuid())
+ /// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
+ /// 当前年级(高一 / 高二 / 高三),学年升级时更新
grade String
+ /// 入学年份(届别):2026 → 称呼「2026级…」、毕业年份 2029
+ enrollmentYear Int?
+ /// 班号(同一届内的序号)
+ classIndex Int?
+ /// 班主任(每班 1 人);科任老师按科目配在 Course 上
teacherId String
- /// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
+ /// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
code String @unique
- /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
+ /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
- /// 通知显示位置(由教室的班级客户端在设置页里选):
- /// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
+ /// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
+ /// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
+ termStartDate String?
+ /// 通知显示位置(由 ClassHelper 班级端在设置页里选):
+ /// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
+ /// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
+ /// 关闭后班级端查成绩一律 403;教师端不受影响。
+ studentGradeQueryEnabled Boolean @default(true)
+ /// 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的)
+ archivedYearId String?
+ archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
- students User[] @relation("ClassStudents")
- teachers ClassTeacher[]
+ archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
+ students Student[]
courses Course[]
schedules Schedule[]
homeworks Homework[]
notifications Notification[]
grades Grade[]
- enrollments Enrollment[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@index([teacherId])
+ @@index([enrollmentYear])
+ @@index([archivedYearId])
}
-/// 教师-班级 分配关系(一个教师可管理多个班级)
-model ClassTeacher {
- id String @id @default(cuid())
- classId String
- teacherId String
- 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])
-}
-
-/// 课程
+/// 课程:同时也是「班级 + 科目 + 教师」这条**任课关系**的载体。
+///
+/// `teacherId` 就是该班该科的任课老师(管理员在「班级管理 → 任课老师」里指定)。
+/// 作业与成绩都挂在 Course 上,因此"这位老师能不能动这一科的作业/成绩"
+/// 就是查 `Course.teacherId`—— 不需要第二张任课表。
model Course {
id String @id @default(cuid())
name String
+ /// 该班该科的任课老师
teacherId String
classId String
createdAt DateTime @default(now())
@@ -127,6 +267,7 @@ model Course {
@@unique([classId, name])
@@index([classId])
+ @@index([teacherId])
}
/// 课表条目
@@ -190,19 +331,19 @@ model Homework {
@@index([dueAt])
}
-/// 学生作业完成状态
+/// 学生作业完成状态(按**学生名单记录**记,学生没有账号)
model HomeworkStatus {
id String @id @default(cuid())
homeworkId String
- userId String
+ studentId String
completed Boolean @default(false)
updatedAt DateTime @updatedAt
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])
- @@index([userId])
+ @@unique([homeworkId, studentId])
+ @@index([studentId])
}
/// 通知
@@ -227,14 +368,14 @@ model Notification {
model NotificationRead {
id String @id @default(cuid())
notificationId String
- userId String
+ studentId String
readAt DateTime @default(now())
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])
- @@index([userId])
+ @@unique([notificationId, studentId])
+ @@index([studentId])
}
@@ -256,34 +397,44 @@ model TimeLayout {
}
/// 成绩
+///
+/// `classId` 是**发布时的归属班级**,调班不改变它 —— 历史成绩留在原班级,
+/// 后续新成绩按学生当前班级生成(需求「历史作业和成绩保留原归属」)。
model Grade {
id String @id @default(cuid())
classId String
courseId String?
- userId String
+ /// 学生(名单记录)id
+ studentId String
examName String
score Float
totalScore Float @default(100)
+ /// 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级
+ levelType String @default("percent")
+ /// 等级文本。percent 下由服务端按得分率生成初值,教师可手改覆盖
+ level String @default("")
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
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([courseId])
}
-/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
+/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
-/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
+/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
+/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
+/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
model IntegrationDevice {
id String @id @default(cuid())
classId String
@@ -300,12 +451,22 @@ model IntegrationDevice {
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务
- syncScheduleToServer Boolean @default(true)
- /// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
- mirrorScheduleToClassIsland Boolean @default(false)
+ ///
+ /// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
+ /// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
+ /// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
+ syncScheduleToServer Boolean @default(false)
+ /// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
+ mirrorScheduleToClassIsland Boolean @default(true)
+ /// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
+ /// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
+ requestScheduleReport Boolean @default(false)
classIslandVersion String?
pluginVersion String?
+ /// 最后一次**带运行状态**的上报时间
lastSeenAt DateTime?
+ /// 最后一次心跳时间(每次上报都刷新);「在线」看它
+ lastHeartbeatAt DateTime?
/// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false)
currentSubject String?
diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts
index cc58522..d3b13bb 100644
--- a/packages/server/prisma/seed.ts
+++ b/packages/server/prisma/seed.ts
@@ -2,15 +2,25 @@
* 种子数据:pnpm db:seed
*
* 会清空业务表后重建一套完整演示数据:
- * 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩
+ * 1 管理员 / 2 教师 / 3 个在读班级 / 1 个已毕业届别 / 15 名学生 /
+ * 课程(含各科任课老师)/ 课表 / 作业 / 通知 / 成绩 / 调班与转出历史
+ *
+ * 班级称呼一律是「XXXX级X班」(入学年份 + 班号)—— 见 shared 的 `formatClassName()`。
*
* 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员
- * teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任)
- * teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
- * student01..student15 / student123
+ * teacher1 / teacher123 - 张老师(2026级1班 班主任,兼 2026级1班 数学)
+ * teacher2 / teacher123 - 李老师(2026级2班 班主任,兼 2026级1班 语文)
+ * (学生只有名单、没有账号: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 { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
@@ -67,11 +77,13 @@ async function resetDatabase(): Promise {
await prisma.notification.deleteMany();
await prisma.schedule.deleteMany();
await prisma.course.deleteMany();
- await prisma.enrollment.deleteMany();
- await prisma.classTeacher.deleteMany();
- await prisma.user.updateMany({ data: { classId: null } });
+ await prisma.studentClassTransfer.deleteMany();
+ // 学生要排在调班历史之后、班级之前(Student.classId 是 SetNull,但历史是 Cascade)
+ await prisma.student.deleteMany();
await prisma.user.deleteMany();
+ // 班级归到届别上,所以先删班级再删届别
await prisma.class.deleteMany();
+ await prisma.archivedYear.deleteMany();
}
async function main(): Promise {
@@ -86,11 +98,11 @@ async function main(): Promise {
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
- // 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
+ // 学生没有密码:个人学生账号已清理(2026-10-01),ClassHelper 班级端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
- // 班级账号默认密码(学生端「班级登录」)
+ // 班级账号默认密码(ClassHelper 班级端「班级登录」)
hashPassword('123456'),
]);
@@ -105,19 +117,24 @@ async function main(): Promise {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
- // 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
+ // 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
+ //
+ // 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
+ // 毕业年份 = 入学年份 + 3,所以这一届 2026 级将在 2029 年归档。
const classSeeds = [
- { name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
- { name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
- { name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
+ { enrollmentYear: 2026, classIndex: 1, grade: '高一', teacherId: teacher1.id, code: 'G101' },
+ { enrollmentYear: 2026, classIndex: 2, grade: '高一', teacherId: teacher2.id, code: 'G102' },
+ { enrollmentYear: 2025, classIndex: 3, grade: '高二', teacherId: teacher1.id, code: 'G203' },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: {
- name: seed.name,
+ name: formatClassName(seed.enrollmentYear, seed.classIndex),
grade: seed.grade,
+ enrollmentYear: seed.enrollmentYear,
+ classIndex: seed.classIndex,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
@@ -126,37 +143,41 @@ async function main(): Promise {
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
}
- // 高二(3)班:李老师作为协作教师
- await prisma.classTeacher.create({
- data: { classId: classes[2]!.id, teacherId: teacher2.id },
- });
-
- // ---------------------------------------------------------------- 学生
+ // ---------------------------------------------------------------- 学生(只是名单,不是账号)
const students = [];
for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
- const classIndex = Math.floor(index / 5);
- const targetClass = classes[classIndex] ?? classes[0]!;
- const student = await prisma.user.create({
+ const classSlot = Math.floor(index / 5);
+ const targetClass = classes[classSlot] ?? classes[0]!;
+ // 学号:入学年份 + 两位序号(与班级称呼同一套「届别」口径)
+ const student = await prisma.student.create({
data: {
- username: `student${String(index + 1).padStart(2, '0')}`,
+ studentNo: `${targetClass.enrollmentYear}${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
- role: 'STUDENT',
classId: targetClass.id,
- // 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
- passwordHash: '',
+ gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
+ guardianPhone: `138${String(10000000 + index * 137).slice(0, 8)}`,
},
});
students.push(student);
- await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
}
- // ---------------------------------------------------------------- 课程
+ // ---------------------------------------------------------------- 课程(= 班级 + 科目 + 教师的任课关系)
+ //
+ // 每门课都要挂一位**任课老师**:作业与成绩能不能被某位老师改动,查的就是 `Course.teacherId`。
+ // 演示数据刻意让「语文」由另一位老师任教 —— 这样班主任在语文上就没有编辑权,
+ // 正好覆盖需求「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
+ const OTHER_TEACHER_SUBJECT = '语文';
const coursesByClass = new Map();
for (const targetClass of classes) {
+ const otherTeacher = targetClass.teacherId === teacher1.id ? teacher2.id : teacher1.id;
const created = [];
for (const courseName of COURSE_NAMES) {
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 });
}
@@ -280,7 +301,7 @@ async function main(): Promise {
const completed = (index + homework.title.length) % 3 !== 0;
if (!completed && index % 2 === 1) continue;
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 {
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
for (const student of classmates) {
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 {
}
// ---------------------------------------------------------------- 成绩
+ //
+ // 三种等级口径各演示一门,方便在界面上看出差别:
+ // 数学(percent)—— 按得分率自动换算 A~E
+ // 语文(custom) —— 自定义等级文本
+ // 英语(letter) —— A / B / C / D
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) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
const classmates = studentsByClass.get(targetClass.id) ?? [];
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) {
const base = 60 + random() * 38;
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({
data: {
classId: targetClass.id,
courseId: course.id,
- userId: student.id,
+ studentId: student.id,
examName,
score,
totalScore: 100,
+ levelType: plan.type,
+ level,
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
},
});
@@ -364,24 +408,146 @@ async function main(): Promise {
}
}
+ // ---------------------------------------------------------------- 调班 / 转出历史
+ //
+ // 调班只改 `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 = {
- 用户: await prisma.user.count(),
- 班级: await prisma.class.count(),
+ 账号: await prisma.user.count(),
+ 在读班级: await prisma.class.count({ where: { archivedYearId: null } }),
+ 学生: await prisma.student.count({ where: { status: 'active' } }),
课程: await prisma.course.count(),
课表: await prisma.schedule.count(),
作业: await prisma.homework.count(),
通知: await prisma.notification.count(),
成绩: await prisma.grade.count(),
+ 调班历史: await prisma.studentClassTransfer.count(),
+ 归档届别: await prisma.archivedYear.count(),
};
logger.info('种子数据写入完成', counts);
console.log('\n================ 演示账号 ================');
- console.log(`管理员 admin / admin123 (${admin.name})`);
- console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
- console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
- console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
- console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
+ console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
+ console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`);
+ console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`);
+ console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
+ console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
+ console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`);
+ console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`);
console.log('==========================================\n');
}
diff --git a/packages/server/scripts/apply-column-migrations.cjs b/packages/server/scripts/apply-column-migrations.cjs
index 185c849..dfdf409 100644
--- a/packages/server/scripts/apply-column-migrations.cjs
+++ b/packages/server/scripts/apply-column-migrations.cjs
@@ -38,8 +38,53 @@ const COLUMNS = [
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 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 () => {
let prisma;
try {
diff --git a/packages/server/scripts/verify-classisland.mjs b/packages/server/scripts/verify-classisland.mjs
index 3d0e5d7..1b5afba 100644
--- a/packages/server/scripts/verify-classisland.mjs
+++ b/packages/server/scripts/verify-classisland.mjs
@@ -444,11 +444,34 @@ async function main() {
});
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 });
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'}`,
);
@@ -458,7 +481,7 @@ async function main() {
body: { mirrorScheduleToClassIsland: true },
});
record(
- '打开「镜像课表到 ClassIsland」',
+ '重新打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`,
);
@@ -562,7 +585,7 @@ async function main() {
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
- // 学生端与 Web 端会一直显示一节实际上已经不上的课。
+ // ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs
index e038571..c399759 100644
--- a/packages/server/scripts/verify-e2e.mjs
+++ b/packages/server/scripts/verify-e2e.mjs
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
- * 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
- * 2. 教师发布作业 -> 学生端实时收到,并可标记完成
- * 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
+ * 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
+ * 2. 教师发布作业 -> ClassHelper 班级端实时收到,并可标记完成
+ * 3. 教师录入成绩 -> ClassHelper 班级端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用
*
@@ -52,7 +52,7 @@ function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
}
async function main() {
- console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
+ console.log(`\n=== ClassHelper 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health');
@@ -97,12 +97,12 @@ async function main() {
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
if (!adminToken) process.exit(1);
- // 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
+ // ClassHelper 班级端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
const classesForLogin = await api('/classes', { token: teacherToken });
const loginClass = classesForLogin.payload?.data?.[0];
if (!loginClass?.code) {
record(
- '学生端班级登录(班级码 + 班级密码 → classSession)',
+ 'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
false,
'没有可用班级码,请先执行 pnpm db:seed',
);
@@ -127,15 +127,19 @@ async function main() {
const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user;
record(
- '学生端班级登录(班级码 + 班级密码 → classSession)',
+ 'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
Boolean(studentToken) && studentUser?.classSession === true,
`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(
- '个人学生账号登录已停用(403)',
- (await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } }))
- .status === 403,
- 'student01 → 403',
+ '学生没有账号:拿学号当账号登录被拒(401)',
+ studentAsAccount.status === 401,
+ `status=${studentAsAccount.status}`,
);
const wrongPassword = await api('/auth/login', {
@@ -149,7 +153,7 @@ async function main() {
const classes = myClasses.payload?.data ?? [];
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;
if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
@@ -169,14 +173,17 @@ async function main() {
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
+ /** 权限矩阵用例里新建的作业(收尾统一删除) */
+ const tempHomeworkIds = [];
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
if (!foreignClass) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
- name: `权限校验班 ${Date.now()}`,
grade: '高一',
+ enrollmentYear: new Date().getFullYear(),
+ classIndex: 93,
code: `PV${Date.now().toString().slice(-6)}`,
...(teacher2Id ? { teacherId: teacher2Id } : {}),
},
@@ -194,6 +201,8 @@ async function main() {
const classmates = studentList.payload?.data ?? [];
// 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例)
const targetStudent = classmates[0];
+ /** 同班第二位学生的学号(导入用例里要造「两条不同的学号」) */
+ const classmateTwoNo = classmates[1]?.studentNo ?? '202602';
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
@@ -420,7 +429,7 @@ async function main() {
});
});
record(
- '学生端 Socket.IO 连接与鉴权',
+ 'ClassHelper 班级端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
);
@@ -441,9 +450,9 @@ async function main() {
try {
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) {
- record('学生端 5 秒内收到 notification:new', false, error.message);
+ record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
}
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
@@ -499,9 +508,9 @@ async function main() {
try {
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) {
- record('学生端 5 秒内收到 homework:new', false, error.message);
+ record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
}
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
@@ -517,7 +526,7 @@ async function main() {
/* ---------------------------------------------------------------- 5.1 未交名单 */
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(
'教师可读取作业提交名单(全班学生 + 完成状态)',
submissions.status === 200 &&
@@ -531,13 +540,13 @@ async function main() {
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH',
token: teacherToken,
- body: { notSubmittedUserIds: [firstStudentId] },
+ body: { notSubmittedStudentIds: [firstStudentId] },
});
record(
'保存未交名单:勾选者标记未交、其余学生自动标记已交',
savedSubmissions.status === 200 &&
(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,
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
@@ -584,7 +593,7 @@ async function main() {
body: {
classId,
courseId,
- userId: targetStudent?.id,
+ studentId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88,
totalScore: 100,
@@ -596,16 +605,16 @@ async function main() {
try {
const { payload, elapsed } = await gradeWait;
record(
- '学生端 5 秒内收到 grade:updated',
+ 'ClassHelper 班级端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
);
} catch (error) {
- record('学生端 5 秒内收到 grade:updated', false, error.message);
+ record('ClassHelper 班级端 5 秒内收到 grade:updated', false, error.message);
}
} else {
gradeWait.catch(() => {});
- record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
+ record('ClassHelper 班级端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
}
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}`);
}
- const assigned = await api(`/classes/${classId}/teachers`, {
- method: 'POST',
+ // 任课关系 = 班级 + 科目 + 教师,唯一写入口是 PUT /classes/:id/subject-teachers。
+ // 用一个演示数据里没用来录成绩的科目,验证完就还回班主任,避免影响后面的成绩权限用例。
+ const probeSubject = '通用技术';
+ const adminAssignSubject = await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
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}`);
- const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, {
- method: 'DELETE',
+ record(
+ '管理员设置某班某科的任课老师(PUT /subject-teachers)',
+ 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,
+ 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`, {
method: 'POST',
token: adminToken,
- body: { username: tempUsername, name: '联调学生' },
+ body: { studentNo: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
@@ -682,8 +710,10 @@ async function main() {
if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
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} 人`,
);
@@ -728,7 +758,7 @@ async function main() {
courseId,
examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`,
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(
@@ -739,9 +769,13 @@ async function main() {
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record(
+ // 等级分布不再固定 A~E 五档:等级制(A-D)与自定义等级(优/良/合格)都在用,
+ // 因此断言"有分布且每个档位都有人",而不是数档位个数。
'成绩统计 /grades/stats',
- gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
- `平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
+ gradeStats.status === 200 &&
+ (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 });
@@ -931,7 +965,7 @@ async function main() {
`status=${callByTeacher.status} title="${callTitle}"`,
);
record(
- '普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队',
+ '普通叫人(默认)不是紧急级别:priority=HIGH,ClassHelper 班级端按普通通知排队',
callByTeacher.payload?.data?.priority === 'HIGH',
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
);
@@ -947,7 +981,7 @@ async function main() {
},
});
record(
- '紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)',
+ '紧急叫人(urgent=true → priority=URGENT,ClassHelper 班级端无视上课时段立即展开)',
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
);
@@ -1013,27 +1047,28 @@ async function main() {
{
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
- // 科任老师:teacher2 是某班的协作(科任)老师。
- // 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个:
- // teacher1 当班主任 + teacher2 协作,收尾删除。
+ // 科任老师:teacher2 在某班**有任课关系**(Course.teacherId === 他)。
+ // 同样不依赖演示数据:推断出来的班级不可用(被人工改过 / 与外班同一个)时现建一个,
+ // 用 teacher1 当班主任 + 给 teacher2 配一门课,收尾删除。
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
if (!subjectClass || subjectClass.id === foreignClass?.id) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
- name: `科任校验班 ${Date.now()}`,
grade: '高一',
+ enrollmentYear: new Date().getFullYear(),
+ classIndex: 90,
code: `SJ${Date.now().toString().slice(-6)}`,
teacherId: teacher1Id,
},
});
const createdId = created.payload?.data?.id;
if (createdId) {
- await api(`/classes/${createdId}/teachers`, {
- method: 'POST',
+ await api(`/classes/${createdId}/subject-teachers`, {
+ method: 'PUT',
token: adminToken,
- body: { teacherId: teacher2Id },
+ body: { assignments: [{ subjectName: '数学', teacherId: teacher2Id }] },
});
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
probeClassIds.push(createdId);
@@ -1050,53 +1085,52 @@ async function main() {
const headCreate = await api('/classes', {
method: 'POST',
token: teacherToken,
- body: { name: '班主任越权班', grade: '高一' },
+ body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 91 },
});
record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`);
const subjectCreate = await api('/classes', {
method: 'POST',
token: teacher2Token,
- body: { name: '科任越权班', grade: '高一' },
+ body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 92 },
});
record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`);
const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken });
record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`);
- const headAssign = await api(`/classes/${classId}/teachers`, {
- method: 'POST',
+ const headAssign = await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
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`, {
- method: 'POST',
+ const subjectAssign = await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
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}`);
// 管理员可以分配(用"先加后删"验证)
- const adminAssign = await api(`/classes/${classId}/teachers`, {
- method: 'POST',
+ const adminAssign = await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
token: adminToken,
- body: { teacherId: teacher2Login.payload?.data?.user?.id },
+ body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record(
- '管理员分配科任老师成功(201/200)',
- [200, 201].includes(adminAssign.status),
+ '管理员分配任课老师成功(200)',
+ adminAssign.status === 200,
`status=${adminAssign.status}`,
);
- const adminUnassign = await api(
- `/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`,
- {
- method: 'DELETE',
- token: adminToken,
- },
- );
- record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
+ // 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
+ const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
+ token: adminToken,
+ body: { assignments: [{ subjectName: '音乐', teacherId: ownClass?.teacherId }] },
+ });
+ record('管理员解除任课关系成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 课表:班主任可管理本班,科任老师不可
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', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
body: {
classId: subjectClassId,
+ courseId: subjectCourse?.id,
title: '科任老师布置的作业',
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) {
await api(`/homeworks/${subjectHomework.payload.data.id}`, {
method: 'DELETE',
@@ -1142,7 +1219,7 @@ async function main() {
token: teacher2Token,
body: {
classId: classId,
- userId: targetStudent?.id,
+ studentId: targetStudent?.id,
examName: '越权成绩',
score: 90,
totalScore: 100,
@@ -1156,7 +1233,7 @@ async function main() {
token: teacherToken,
body: {
classId: classId,
- userId: targetStudent?.id,
+ studentId: targetStudent?.id,
examName: '班主任本班成绩',
score: 90,
totalScore: 100,
@@ -1177,7 +1254,7 @@ async function main() {
token: teacherToken,
body: {
classId: foreignClass.id,
- userId: targetStudent?.id,
+ studentId: targetStudent?.id,
examName: '班主任跨班成绩',
score: 90,
totalScore: 100,
@@ -1195,7 +1272,7 @@ async function main() {
token: adminToken,
body: {
classId: classId,
- userId: targetStudent?.id,
+ studentId: targetStudent?.id,
examName: '管理员录入成绩',
score: 88,
totalScore: 100,
@@ -1438,10 +1515,10 @@ async function main() {
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [
- '用户名,姓名,考试名称,分数,总分,课程',
- 'student01,,期中导入考试,92,100,',
+ '学号,姓名,考试名称,分数,总分,科目',
+ `${targetStudent?.studentNo ?? '202601'},,期中导入考试,92,100,`,
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
- 'unknown-user,,期中导入考试,88,100,',
+ 'UNKNOWN-USER,,期中导入考试,88,100,',
].join(NL);
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
@@ -1490,8 +1567,8 @@ async function main() {
const XLSX = xlsxNamespace.default ?? xlsxNamespace;
const xlsxSheet = XLSX.utils.aoa_to_sheet([
['学号', '姓名', '考试', '得分', '满分', '科目'],
- ['student02', '', '期中导入考试', 85, 100, ''],
- ['student01', '', '期中导入考试', 78, 100, ''],
+ [classmateTwoNo, '', '期中导入考试', 85, 100, ''],
+ [targetStudent?.studentNo ?? '202601', '', '期中导入考试', 78, 100, ''],
]);
const xlsxBook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
@@ -1506,13 +1583,13 @@ async function main() {
'XLSX 成绩表预览(同义词自动映射 学号/得分)',
xlsxPreview.status === 200 &&
xlsxPreview.payload?.data?.totalRows === 2 &&
- xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' &&
+ xlsxPreview.payload?.data?.suggestedMapping?.studentNo === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
);
const commitMapping = {
- username: '学号',
+ studentNo: '学号',
name: '姓名',
examName: '考试',
score: '得分',
@@ -1573,12 +1650,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
- username: '用户名',
+ studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
- courseName: '课程',
+ courseName: '科目',
},
mode: 'append',
},
@@ -1608,9 +1685,9 @@ async function main() {
);
// 8.4 学生名单导入(仅管理员)+ 数据清理
- const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
- // 学生导入模板已无"初始密码"列(学生没有账号属性)
- const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL);
+ const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
+ // 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
+ const studentsCsv = ['学号,姓名', `${importedUsername},导入测试生`, 'bad@user,非法学号'].join(NL);
const studentsCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
@@ -1619,12 +1696,12 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
- mapping: { username: '用户名', name: '姓名' },
+ mapping: { studentNo: '学号', name: '姓名' },
mode: 'append',
},
});
record(
- '学生名单导入(新增 1 / 非法用户名进错误行 1)',
+ '学生名单导入(新增 1 / 非法学号进错误行 1)',
studentsCommit.status === 200 &&
studentsCommit.payload?.data?.inserted === 1 &&
studentsCommit.payload?.data?.failed === 1,
@@ -1653,12 +1730,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
- username: '用户名',
+ studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
- courseName: '课程',
+ courseName: '科目',
},
mode: 'upsert',
},
@@ -1672,8 +1749,8 @@ async function main() {
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
);
- // teacher2 是高二(3)班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
- const subjectOnlyClass = classes.find((item) => item.name?.includes('高二(3)')) ?? null;
+ // teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
+ const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null;
const subjectGradesImport = await api('/imports/table/commit', {
method: 'POST',
token: teacher2Token,
@@ -1683,12 +1760,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
- username: '用户名',
+ studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
- courseName: '课程',
+ courseName: '科目',
},
mode: 'upsert',
},
@@ -1696,7 +1773,7 @@ async function main() {
record(
'科任老师导入本班成绩被拒绝(403,需求 7)',
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
- `班级=${subjectOnlyClass?.name ?? '未找到高二(3)班'} status=${subjectGradesImport.status}`,
+ `班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
);
if (foreignClass) {
@@ -1709,12 +1786,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
- username: '用户名',
+ studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
- courseName: '课程',
+ courseName: '科目',
},
mode: 'upsert',
},
@@ -1749,7 +1826,7 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
- mapping: { username: '用户名', name: '姓名' },
+ mapping: { studentNo: '学号', name: '姓名' },
mode: 'append',
},
});
@@ -1774,7 +1851,7 @@ async function main() {
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
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) {
const removedStudent = await api(`/students/${importedStudent.id}`, {
method: 'DELETE',
@@ -1795,7 +1872,7 @@ async function main() {
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
}
- // ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级)
+ // ---------------------------------------------------------------- 9. 班级账号(ClassHelper 班级端主体 = 班级)
// 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩)
// 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。
// 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。
@@ -1887,7 +1964,7 @@ async function main() {
const classGradesWrite = await api('/grades', {
method: 'POST',
token: classToken,
- body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
+ body: { classId, studentId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
});
record(
'班级账号录入成绩被拒绝(403)',
@@ -2020,10 +2097,10 @@ async function main() {
const classNameGrade = await api('/grades', {
method: 'POST',
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 gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
+ const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.studentId));
record(
'班级账号查看成绩 = 全班总览',
classMyGrades.status === 200 &&
@@ -2180,7 +2257,7 @@ async function main() {
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
const teacherCsv =
- '\ufeff用户名,姓名,初始密码,角色\n' +
+ '\ufeff工号,姓名,初始密码,角色\n' +
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
@@ -2202,7 +2279,7 @@ async function main() {
kind: 'teachers',
fileName: 'teachers.csv',
contentBase64: teacherCsvBase64,
- mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' },
+ mapping: { username: '工号', name: '姓名', password: '初始密码', role: '角色' },
mode: 'upsert',
},
});
@@ -2253,11 +2330,11 @@ async function main() {
});
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
- // 1) 先让他成为该班科任(这样才有发布权限),发一条通知
- await api(`/classes/${classId}/teachers`, {
- method: 'POST',
+ // 1) 先让他成为该班某科的任课老师(这样才有发布通知的权限),发一条通知
+ await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
token: adminToken,
- body: { teacherId: cascadeTeacherId },
+ body: { assignments: [{ subjectName: '美术', teacherId: cascadeTeacherId }] },
});
const cascadeNotification = await api('/notifications', {
method: 'POST',
@@ -2265,10 +2342,11 @@ async function main() {
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
});
const cascadeNotificationId = cascadeNotification.payload?.data?.id;
- // 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容
- await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, {
- method: 'DELETE',
+ // 2) 解除任课关系(把该科还回班主任):此时他"不在任何班级、名下无课程",但发布过内容
+ await api(`/classes/${classId}/subject-teachers`, {
+ method: 'PUT',
token: adminToken,
+ body: { assignments: [{ subjectName: '美术', teacherId: ownClass?.teacherId }] },
});
// 3) 删除账号应当被拒(409),且那条通知必须还在
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
@@ -2303,7 +2381,7 @@ async function main() {
const teacherCreateClass = await api('/classes', {
method: 'POST',
token: teacherToken,
- body: { name: '越权班级', grade: '高一' },
+ body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 94 },
});
record(
'仅管理员可创建班级(教师 403)',
@@ -2316,8 +2394,9 @@ async function main() {
method: 'POST',
token: adminToken,
body: {
- name: `冒烟班级 ${Date.now()}`,
grade: '高一',
+ enrollmentYear: new Date().getFullYear(),
+ classIndex: 95,
code: tempClassCode,
...(teacher2 ? { teacherId: teacher2.id } : {}),
},
@@ -2357,11 +2436,11 @@ async function main() {
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
- '数据库状态(管理员):已连接且返回 14 张表行数',
+ '数据库状态(管理员):已连接且返回 15 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
- dbStatusData.tables.length === 14 &&
+ dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
@@ -2478,10 +2557,10 @@ async function main() {
snapshotParsed = null;
}
record(
- '导出快照(JSON 格式正确、14 张表、用户数 > 0)',
+ '导出快照(JSON 格式正确、15 张表、账号数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
- Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
+ Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
@@ -2665,6 +2744,169 @@ async function main() {
`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();
// ---------------------------------------------------------------- 汇总
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 0a085fa..b28c264 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -98,7 +98,7 @@ function renderLandingPage(webAdminServed: boolean): string {
- 班级小助手 · 后端服务
+ ClassHelper · 后端服务
diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue
index bb5e9b8..7c80199 100644
--- a/packages/web-admin/src/views/ClassesView.vue
+++ b/packages/web-admin/src/views/ClassesView.vue
@@ -4,11 +4,17 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
import {
SOCKET_EVENTS,
MAX_TERM_WEEK,
+ SUBJECT_CATALOG,
+ currentAcademicYearStart,
+ formatClassName,
+ STUDENT_STATUS_LABELS,
formatDate,
type ClassDetailDto,
type ClassDto,
type CourseDto,
type StudentDto,
+ type StudentStatus,
+ type SubjectTeacherDto,
type UpdateClassAccountRequest,
type UserDto,
} from '@classhelper/shared';
@@ -45,11 +51,25 @@ async function loadClasses(): Promise {
const formVisible = ref(false);
const formRef = ref();
const editingId = ref(null);
-const form = reactive({ name: '', grade: '', code: '', termWeeks: 20, headTeacherId: '' });
+/**
+ * 班级称呼是「XXXX级X班」(入学年份 + 班号),不是「高一(1)班」:
+ * 后者每年都要改一遍,前者一路跟着这届学生到毕业(升级只改年级、称呼不变)。
+ */
+const form = reactive({
+ grade: '',
+ enrollmentYear: currentAcademicYearStart(),
+ classIndex: 1,
+ code: '',
+ termWeeks: 20,
+ headTeacherId: '',
+});
const rules: FormRules = {
- name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
+ enrollmentYear: [{ required: true, message: '请输入入学年份', trigger: 'blur' }],
+ classIndex: [{ required: true, message: '请输入班号', trigger: 'blur' }],
};
+/** 表单里实时预览班级称呼 */
+const previewName = computed(() => formatClassName(form.enrollmentYear, form.classIndex));
/** 管理员可选班主任(教师 + 管理员账号);「新建班级」才设置,「编辑」走单独的"更换班主任" */
const staffOptions = ref([]);
@@ -62,8 +82,9 @@ async function loadStaffOptions(): Promise {
function openCreate(): void {
editingId.value = null;
- form.name = '';
form.grade = '';
+ form.enrollmentYear = currentAcademicYearStart();
+ form.classIndex = classes.value.length + 1;
form.code = '';
form.termWeeks = 20;
form.headTeacherId = auth.user?.id ?? '';
@@ -73,8 +94,10 @@ function openCreate(): void {
function openEdit(row: ClassDto): void {
editingId.value = row.id;
- form.name = row.name;
form.grade = row.grade;
+ // 老数据可能没有届别:回填当前学年起始年份,管理员可在表单里改正
+ form.enrollmentYear = row.enrollmentYear ?? currentAcademicYearStart();
+ form.classIndex = row.classIndex ?? 1;
form.code = '';
form.termWeeks = row.termWeeks ?? 20;
// 必须回填当前班主任:编辑弹窗里的下拉与"新建"共用同一个 form.headTeacherId,
@@ -92,8 +115,9 @@ async function submitForm(): Promise {
if (editingId.value) {
await classApi.update(editingId.value, {
- name: form.name,
grade: form.grade,
+ enrollmentYear: form.enrollmentYear,
+ classIndex: form.classIndex,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
@@ -106,15 +130,16 @@ async function submitForm(): Promise {
ElMessage.success('班级已更新');
} else {
await classApi.create({
- name: form.name,
grade: form.grade,
+ enrollmentYear: form.enrollmentYear,
+ classIndex: form.classIndex,
// 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃,
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
termWeeks: form.termWeeks,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
});
- ElMessage.success('班级创建成功');
+ ElMessage.success(`班级 ${previewName.value} 创建成功`);
}
formVisible.value = false;
await loadClasses();
@@ -144,7 +169,7 @@ const accountSaving = ref(false);
const accountTarget = ref(null);
const accountForm = reactive({ code: '', password: '' });
-/** 打开班级账号设置:班级码即学生端「班级登录」的账号 */
+/** 打开班级账号设置:班级码即 ClassHelper 班级端「班级登录」的账号 */
function openAccount(row: ClassDto): void {
accountTarget.value = row;
accountForm.code = row.code ?? '';
@@ -231,28 +256,36 @@ async function refreshDetail(): Promise {
/* ------------------------------------------------------------ 学生 */
const studentFormVisible = ref(false);
-const studentForm = reactive({ username: '', name: '', password: '' });
+/** 学生只有名单:学号 + 姓名(没有密码、没有账号) */
+const studentForm = reactive({
+ studentNo: '',
+ name: '',
+ gender: '' as '' | 'MALE' | 'FEMALE',
+ guardianPhone: '',
+});
const studentRules: FormRules = {
- username: [{ required: true, message: '请输入学号/用户名', trigger: 'blur' }],
+ studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
};
async function addStudent(): Promise {
if (!detail.value) return;
- if (!studentForm.username.trim() || !studentForm.name.trim()) {
- ElMessage.warning('请填写用户名和姓名');
+ if (!studentForm.studentNo.trim() || !studentForm.name.trim()) {
+ ElMessage.warning('请填写学号和姓名');
return;
}
const created = await classApi.addStudent(detail.value.id, {
- username: studentForm.username.trim(),
+ studentNo: studentForm.studentNo.trim(),
name: studentForm.name.trim(),
- ...(studentForm.password ? { password: studentForm.password } : {}),
+ ...(studentForm.gender ? { gender: studentForm.gender } : {}),
+ ...(studentForm.guardianPhone.trim() ? { guardianPhone: studentForm.guardianPhone.trim() } : {}),
});
- ElMessage.success(`已加入学生:${created.name}`);
+ ElMessage.success(`已加入学生:${created.name}(${created.studentNo})`);
studentFormVisible.value = false;
- studentForm.username = '';
+ studentForm.studentNo = '';
studentForm.name = '';
- studentForm.password = '';
+ studentForm.gender = '';
+ studentForm.guardianPhone = '';
await refreshDetail();
await loadClasses();
}
@@ -290,33 +323,114 @@ async function removeCourse(row: CourseDto): Promise {
await refreshDetail();
}
-/* ------------------------------------------------------------ 协作教师 */
+/* ------------------------------------------------------------ 任课老师(班级 + 科目 + 教师) */
+/**
+ * **这是「科任老师」这条关系的唯一写入口**。
+ *
+ * 作业与成绩能不能被某位老师改动,查的就是这里写下的 `Course.teacherId`:
+ * 科任老师只能动自己任教的科目;班主任若不教这一科,同样改不了这一科。
+ * 同一位老师可以既是班主任又是某科科任 —— 两条记录指向同一个账号,
+ * 不重复建号,权限在服务端合并。
+ */
const teachers = ref([]);
-const selectedTeacherId = ref('');
+const subjectDraft = reactive({ subjectName: '', teacherId: '' });
async function loadTeachers(): Promise {
teachers.value = await teacherApi.list();
}
-async function assignTeacher(): Promise {
- if (!detail.value || !selectedTeacherId.value) {
- ElMessage.warning('请选择教师');
+/** 该班还没有的科目(下拉里列出来,选中即自动建课) */
+const assignableSubjects = computed(() => {
+ const existing = new Set((detail.value?.subjectTeachers ?? []).map((item) => item.subjectName));
+ return SUBJECT_CATALOG.filter((name) => !existing.has(name));
+});
+
+const subjectSaving = ref(false);
+
+async function saveSubjectTeacher(): Promise {
+ if (!detail.value || !subjectDraft.subjectName.trim() || !subjectDraft.teacherId) {
+ ElMessage.warning('请选择科目与任课老师');
return;
}
- await classApi.assignTeacher(detail.value.id, selectedTeacherId.value);
- ElMessage.success('教师已分配');
- selectedTeacherId.value = '';
+ subjectSaving.value = true;
+ try {
+ await classApi.assignSubjectTeachers(detail.value.id, [
+ { subjectName: subjectDraft.subjectName.trim(), teacherId: subjectDraft.teacherId },
+ ]);
+ ElMessage.success('任课老师已设置');
+ subjectDraft.subjectName = '';
+ subjectDraft.teacherId = '';
+ await refreshDetail();
+ } finally {
+ subjectSaving.value = false;
+ }
+}
+
+/** 改某一科的任课老师(就地改,不新建科目) */
+async function changeSubjectTeacher(row: SubjectTeacherDto, teacherId: string): Promise {
+ if (!detail.value || !teacherId) return;
+ await classApi.assignSubjectTeachers(detail.value.id, [
+ { courseId: row.courseId, subjectName: row.subjectName, teacherId },
+ ]);
+ ElMessage.success(`「${row.subjectName}」的任课老师已更新`);
await refreshDetail();
}
-async function removeTeacher(teacherId: string): Promise {
+/** 解除某科的任课老师(课程保留,作业与成绩仍挂在它上面) */
+async function clearSubjectTeacher(row: SubjectTeacherDto): Promise {
if (!detail.value) return;
- await classApi.removeTeacher(detail.value.id, teacherId);
- ElMessage.success('已取消分配');
+ await classApi.assignSubjectTeachers(detail.value.id, [
+ { courseId: row.courseId, subjectName: row.subjectName, teacherId: null },
+ ]);
+ ElMessage.success(`已解除「${row.subjectName}」的任课老师`);
await refreshDetail();
}
+/* ------------------------------------------------------------ 班级设置:成绩查询开关 / 学年升级 */
+
+const gradeQuerySaving = ref(false);
+
+/** 是否允许教室的 ClassHelper 班级端按学号查询本班学生的成绩明细 */
+async function toggleGradeQuery(enabled: boolean): Promise {
+ if (!detail.value) return;
+ gradeQuerySaving.value = true;
+ try {
+ await classApi.setGradeQuery(detail.value.id, enabled);
+ detail.value.studentGradeQueryEnabled = enabled;
+ ElMessage.success(enabled ? '已允许班级端按学号查成绩' : '已关闭班级端成绩查询');
+ } finally {
+ gradeQuerySaving.value = false;
+ }
+}
+
+const promoteForm = reactive({ grade: '', saving: false });
+
+/** 学年升级:只改年级,**不归档**,班级记录/学生名单/学号/作业成绩全部沿用 */
+async function promote(): Promise {
+ if (!detail.value || !promoteForm.grade.trim()) {
+ ElMessage.warning('请填写升级后的年级,例如「高二」');
+ return;
+ }
+ const target = promoteForm.grade.trim();
+ await ElMessageBox.confirm(
+ `把「${detail.value.name}」的年级改为「${target}」?升级只改年级,不归档:` +
+ '班级记录、学生名单、学号、作业与成绩全部沿用。',
+ '学年升级',
+ { type: 'info', confirmButtonText: '确认升级' },
+ );
+ promoteForm.saving = true;
+ try {
+ await classApi.promote([detail.value.id], target);
+ ElMessage.success('已升级,数据继续沿用');
+ promoteForm.grade = '';
+ await refreshDetail();
+ await loadClasses();
+ } finally {
+ promoteForm.saving = false;
+ }
+}
+
/* ------------------------------------------------------------ 实时刷新 */
function onClassChanged(): void {
@@ -361,8 +475,16 @@ onUnmounted(() => {
-
-
+
+
+
+
+ {{ row.enrollmentYear }} 级 · {{ row.classIndex }} 班
+
+ 未设置
+
+
+
{{ row.teacher?.name ?? '-' }}
@@ -378,7 +500,7 @@ onUnmounted(() => {
{{ row.notificationCount ?? 0 }}
-
+
{{ row.code }}
@@ -403,12 +525,12 @@ onUnmounted(() => {
-
-
+
+
- 学生端以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
- 作业完成、通知已读都会按全班记录。
+ 教室机器以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
+ 作业完成、通知已读都会按全班记录。学生没有个人账号,不能登录。
@@ -436,10 +558,20 @@ onUnmounted(() => {
-
-
-
-
+
+
+
+
+ 这一届学生入学的年份(届别)
+
+
+
+
+
+ {{ previewName }}
+
+ 称呼由「入学年份 + 班号」生成;学年升级只改年级,称呼一路跟着这届学生到毕业
+
@@ -477,9 +609,17 @@ onUnmounted(() => {
+
+ {{ detail.enrollmentYear ? `${detail.enrollmentYear} 级` : '未设置' }}
+
{{ detail.grade }}
{{ detail.teacher?.name ?? '-' }}
{{ detail.students.length }}
+
+
+ {{ detail.studentGradeQueryEnabled ? '已开放' : '已关闭' }}
+
+
@@ -500,7 +640,7 @@ onUnmounted(() => {
:value="item.id"
/>
- 切换后立即生效:原班主任仍保留在下方「协作教师」名单里
+ 切换班主任不影响任课关系:各科科任老师在下方「任课老师」里单独配
@@ -512,8 +652,13 @@ onUnmounted(() => {
共 {{ detail?.students.length ?? 0 }} 人
-
+
+
+
+ {{ STUDENT_STATUS_LABELS[row.status as StudentStatus] }}
+
+
{{ formatDate(row.createdAt, true) }}
@@ -545,18 +690,52 @@ onUnmounted(() => {
/>
-
-
-
-
-
+
+
+ 这是「班级 + 科目 + 教师」的任课关系:科任老师只能发布 / 修改 / 删除自己任教科目的作业和成绩;
+ 班主任若不教这一科,同样改不了这一科。同一位老师既可以是班主任、又可以教某一科 ——
+ 系统里只有他一个账号,权限自动合并,不会重复建号。
+
+
+
+
- 取消分配
+ changeSubjectTeacher(row, value)"
+ >
+
+
+ {{ row.teacherName || '-' }}
+
+
+
+
+ 解除
-
-
-
-
-
-
+
+
+
+ 学生只是班级名单里的记录:没有账号、没有密码、不能登录(教室机器统一用班级码 + 班级密码)。
+ 学号已存在时会直接转入本班,并留下一條调班历史。
+
+
+
+
-
-
+
+
+
+
+
+
+
+
@@ -602,4 +825,16 @@ onUnmounted(() => {
.ml-8 {
margin-left: 8px;
}
+
+.setting-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.setting-title {
+ font-weight: 600;
+ margin-bottom: 4px;
+}
diff --git a/packages/web-admin/src/views/GradesView.vue b/packages/web-admin/src/views/GradesView.vue
index 75b5c81..e487753 100644
--- a/packages/web-admin/src/views/GradesView.vue
+++ b/packages/web-admin/src/views/GradesView.vue
@@ -2,6 +2,10 @@
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
+ DEFAULT_LETTER_LEVELS,
+ GRADE_LEVEL_TYPES,
+ GRADE_LEVEL_TYPE_HINTS,
+ GRADE_LEVEL_TYPE_LABELS,
SOCKET_EVENTS,
SUBJECT_CATALOG,
formatDate,
@@ -10,8 +14,10 @@ import {
type ClassDto,
type CourseDto,
type GradeDto,
+ type GradeLevelType,
type GradeStats,
type StudentDto,
+ type StudentGradeDetailDto,
} from '@classhelper/shared';
import { classApi, courseApi, gradeApi } from '@/api';
import ScoreBarChart from '@/components/ScoreBarChart.vue';
@@ -23,15 +29,13 @@ import { useRealtimeStore } from '@/stores/realtime';
const auth = useAuthStore();
/**
- * 成绩写入/导入权限:管理员,或"所选班级的班主任"(需求 6:老师端可用且不越权)。
- * 科任老师只能查看列表与统计,写入会被后端 403(需求 7:科任仅作业/叫人/通知)。
+ * 是否显示成绩的写入入口(**纯展示控制**)。
+ *
+ * 真正的权限在后端:管理员可以写全部;班主任与科任老师**只能写自己任教科目**的成绩
+ * (查 `Course.teacherId`),越权一律 403。因此这里只要是教师就放行按钮,
+ * 让老师能看到入口并在被拒时得到明确的后端提示,而不是"按钮凭空消失、不知道为什么"。
*/
-const canManageGrades = computed(() => {
- if (auth.role === 'ADMIN') return true;
- if (auth.role !== 'TEACHER') return false;
- const current = classes.value.find((item) => item.id === filter.classId);
- return Boolean(current && current.teacherId === auth.user?.id);
-});
+const canManageGrades = computed(() => auth.role === 'ADMIN' || auth.role === 'TEACHER');
const realtime = useRealtimeStore();
const loading = ref(false);
@@ -136,35 +140,48 @@ async function resolveCourseId(value: string): Promise {
/* ------------------------------------------------------------ 单条录入 */
const singleVisible = ref(false);
-const singleForm = reactive({ userId: '', courseId: '', examName: '', score: 90, totalScore: 100 });
+const singleForm = reactive({
+ studentId: '',
+ courseId: '',
+ examName: '',
+ score: 90,
+ totalScore: 100,
+ levelType: 'percent' as GradeLevelType,
+ level: '',
+});
function openSingle(): void {
if (!filter.classId) {
ElMessage.warning('请先选择班级');
return;
}
- singleForm.userId = students.value[0]?.id ?? '';
+ singleForm.studentId = students.value[0]?.id ?? '';
singleForm.courseId = filter.courseId || courses.value[0]?.id || '';
singleForm.examName = filter.examName || '期中考试';
singleForm.score = 90;
singleForm.totalScore = 100;
+ singleForm.levelType = 'percent';
+ singleForm.level = '';
singleVisible.value = true;
}
async function submitSingle(): Promise {
- if (!singleForm.userId || !singleForm.examName.trim()) {
+ if (!singleForm.studentId || !singleForm.examName.trim()) {
ElMessage.warning('请选择学生并填写考试名称');
return;
}
await gradeApi.create({
classId: filter.classId,
courseId: await resolveCourseId(singleForm.courseId),
- userId: singleForm.userId,
+ studentId: singleForm.studentId,
examName: singleForm.examName.trim(),
score: singleForm.score,
totalScore: singleForm.totalScore,
+ levelType: singleForm.levelType,
+ // percent 口径留空时由服务端按得分率换算,所以这里只传用户真的填了的值
+ ...(singleForm.level.trim() ? { level: singleForm.level.trim() } : {}),
});
- ElMessage.success('成绩已录入,已实时推送给该学生');
+ ElMessage.success('成绩已录入,已实时推送给教室端');
singleVisible.value = false;
await loadGrades();
}
@@ -172,8 +189,13 @@ async function submitSingle(): Promise {
/* ------------------------------------------------------------ 批量录入 */
const bulkVisible = ref(false);
-const bulkForm = reactive({ courseId: '', examName: '期中考试', totalScore: 100 });
-const bulkRows = ref>([]);
+const bulkForm = reactive({
+ courseId: '',
+ examName: '期中考试',
+ totalScore: 100,
+ levelType: 'percent' as GradeLevelType,
+});
+const bulkRows = ref>([]);
function openBulk(): void {
if (students.value.length === 0) {
@@ -183,11 +205,13 @@ function openBulk(): void {
bulkForm.courseId = filter.courseId || courses.value[0]?.id || '';
bulkForm.examName = filter.examName || '期中考试';
bulkForm.totalScore = 100;
+ bulkForm.levelType = 'percent';
bulkRows.value = students.value.map((item) => ({
- userId: item.id,
+ studentId: item.id,
name: item.name,
- username: item.username,
+ studentNo: item.studentNo,
score: null,
+ level: '',
}));
bulkVisible.value = true;
}
@@ -195,7 +219,11 @@ function openBulk(): void {
async function submitBulk(): Promise {
const items = bulkRows.value
.filter((row) => row.score !== null && row.score !== undefined)
- .map((row) => ({ userId: row.userId, score: Number(row.score) }));
+ .map((row) => ({
+ studentId: row.studentId,
+ score: Number(row.score),
+ ...(row.level.trim() ? { level: row.level.trim() } : {}),
+ }));
if (items.length === 0) {
ElMessage.warning('至少录入一位学生的成绩');
@@ -211,6 +239,7 @@ async function submitBulk(): Promise {
courseId: await resolveCourseId(bulkForm.courseId),
examName: bulkForm.examName.trim(),
totalScore: bulkForm.totalScore,
+ levelType: bulkForm.levelType,
items,
});
ElMessage.success(`已录入 ${result.count} 条成绩并广播到班级`);
@@ -218,6 +247,87 @@ async function submitBulk(): Promise {
await loadGrades();
}
+/* ------------------------------------------------------------ 等级:单个 / 批量编辑 */
+
+/** 表格里直接改一条等级(percent 口径下相当于"手动覆盖自动换算的结果") */
+async function saveLevel(row: GradeDto, level: string): Promise {
+ await gradeApi.update(row.id, { level });
+ ElMessage.success('等级已更新');
+ await loadGrades();
+}
+
+const levelBatchVisible = ref(false);
+const levelBatchSaving = ref(false);
+const levelBatchForm = reactive({
+ courseId: '',
+ examName: '',
+ levelType: 'letter' as GradeLevelType,
+});
+
+function openLevelBatch(): void {
+ if (!filter.classId) {
+ ElMessage.warning('请先选择班级');
+ return;
+ }
+ levelBatchForm.courseId = filter.courseId || courses.value[0]?.id || '';
+ levelBatchForm.examName = filter.examName || examOptions.value[0] || '';
+ levelBatchForm.levelType = 'letter';
+ levelBatchVisible.value = true;
+}
+
+/** 把某个考试某门课的成绩整批改成另一个等级口径(例如百分制 → A/B/C/D) */
+async function submitLevelBatch(): Promise {
+ if (!levelBatchForm.examName.trim()) {
+ ElMessage.warning('请选择要调整的考试');
+ return;
+ }
+ levelBatchSaving.value = true;
+ try {
+ const result = await gradeApi.updateLevels({
+ classId: filter.classId,
+ examName: levelBatchForm.examName.trim(),
+ courseId: await resolveCourseId(levelBatchForm.courseId),
+ levelType: levelBatchForm.levelType,
+ });
+ ElMessage.success(`已把 ${result.updated} 条成绩改成「${GRADE_LEVEL_TYPE_LABELS[levelBatchForm.levelType]}」`);
+ levelBatchVisible.value = false;
+ await loadGrades();
+ } finally {
+ levelBatchSaving.value = false;
+ }
+}
+
+/* ------------------------------------------------------------ 按学号查成绩明细 */
+
+const detailVisible = ref(false);
+const detailLoading = ref(false);
+const detailNo = ref('');
+const detail = ref(null);
+
+function openDetail(): void {
+ if (!filter.classId) {
+ ElMessage.warning('请先选择班级');
+ return;
+ }
+ detailNo.value = '';
+ detail.value = null;
+ detailVisible.value = true;
+}
+
+async function queryDetail(): Promise {
+ const studentNo = detailNo.value.trim();
+ if (!studentNo) {
+ ElMessage.warning('请输入学号');
+ return;
+ }
+ detailLoading.value = true;
+ try {
+ detail.value = await gradeApi.studentDetail(studentNo, filter.classId);
+ } finally {
+ detailLoading.value = false;
+ }
+}
+
/* ------------------------------------------------------------ 表格导入 */
const importVisible = ref(false);
@@ -303,9 +413,16 @@ onUnmounted(() => {
导入表格
+ 批量改等级
+ 按学号查明细
+
+ 成绩按科目授权:科任老师只能录入 / 修改 / 删除自己任教科目的成绩;
+ 班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
+
+
@@ -335,27 +452,49 @@ onUnmounted(() => {
-
+
{{ row.student?.name ?? '-' }}
-
- {{ row.student?.username ?? '-' }}
+
+ {{ row.student?.studentNo ?? '-' }}
-
+
{{ row.course?.name ?? '-' }}
-
+
{{ row.score }} / {{ row.totalScore }}
-
+
- {{ gradeLevel(gradePercent(row.score, row.totalScore)) }} 等
+
+
+
+
+
+ {{ GRADE_LEVEL_TYPE_LABELS[row.levelType as GradeLevelType] }}
+
+ saveLevel(row, value)"
+ >
+
+
+ {{ row.level || `${gradeLevel(gradePercent(row.score, row.totalScore))} 等` }}
+
@@ -370,21 +509,21 @@ onUnmounted(() => {
-
+
-
+
-
+
{
+
+
+
+
+
/
+
+
+
+
+
+
+ {{ GRADE_LEVEL_TYPE_HINTS[singleForm.levelType] }}
取消
录入并推送
@@ -411,7 +574,7 @@ onUnmounted(() => {
-
+
@@ -437,16 +600,34 @@ onUnmounted(() => {
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
@@ -455,6 +636,89 @@ onUnmounted(() => {
+
+
+
+ 把某个考试某门课的成绩整批换成另一种等级口径。百分制会按得分率重新换算 A~E,
+ 等级制 / 自定义则直接套用所选口径(原有等级文本保留,可再到表格里逐条微调)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认修改
+
+
+
+
+
+
+
+ 查询
+
+
+
+
+ {{ detail.student.name }}
+ {{ detail.student.studentNo }}
+ {{ detail.className ?? '未分班' }}
+ {{ detail.averagePercent }}%
+
+
+
+
+
+ {{ row.course?.name ?? '-' }}
+
+
+ {{ row.score }} / {{ row.totalScore }}
+
+
+
+ {{ row.level || '-' }}
+
+
+
+ {{ formatDate(row.publishedAt, true) }}
+
+
+
+
+
+
{
/>
+
+
diff --git a/packages/web-admin/src/views/HomeworksView.vue b/packages/web-admin/src/views/HomeworksView.vue
index ba525a2..e92339d 100644
--- a/packages/web-admin/src/views/HomeworksView.vue
+++ b/packages/web-admin/src/views/HomeworksView.vue
@@ -71,7 +71,7 @@ const submissionsHomeworkId = ref('');
let submissionsRequestId = 0;
const notSubmittedNames = computed(() =>
- submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
+ submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.studentId)),
);
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
@@ -83,7 +83,7 @@ async function loadSubmissions(homeworkId: string): Promise {
// 迟到的响应:用户已经切到别的作业了,直接丢弃
if (requestId !== submissionsRequestId) return false;
submissionStudents.value = result.students;
- notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
+ notSubmittedIds.value = result.notSubmitted.map((item) => item.studentId);
submissionsHomeworkId.value = homeworkId;
return true;
} catch {
@@ -115,7 +115,7 @@ async function saveSubmissions(): Promise {
try {
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
submissionStudents.value = result.students;
- notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
+ notSubmittedIds.value = result.notSubmitted.map((item) => item.studentId);
if (current.value) current.value.completedCount = result.completedCount;
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
submissionsVisible.value = false;
@@ -131,7 +131,7 @@ function markAllSubmitted(): void {
}
function markAllNotSubmitted(): void {
- notSubmittedIds.value = submissionStudents.value.map((item) => item.userId);
+ notSubmittedIds.value = submissionStudents.value.map((item) => item.studentId);
}
async function loadClasses(): Promise {
@@ -488,7 +488,7 @@ onUnmounted(() => {
:cell-class-name="dayCellClass"
style="width: 200px"
/>
- 作业会出现在这一天(按天查看与学生端都按它归类)
+ 作业会出现在这一天(按天查看与 ClassHelper 班级端都按它归类)
@@ -527,7 +527,7 @@ onUnmounted(() => {
{
class="mt-16"
type="info"
:closable="false"
- title="学生端实时同步"
+ title="ClassHelper 班级端实时同步"
:description="`学生客户端在收到 homework:new / homework:status 事件后会自动刷新列表,并在离线时使用本地缓存(摘要:${truncate(current.content, 30)})`"
/>
@@ -575,11 +575,11 @@ onUnmounted(() => {
-
- {{ student.name }}({{ student.username }})
+
+ {{ student.name }}(学号 {{ student.studentNo }})
diff --git a/packages/web-admin/src/views/IntegrationsView.vue b/packages/web-admin/src/views/IntegrationsView.vue
index 526c1f6..c197e41 100644
--- a/packages/web-admin/src/views/IntegrationsView.vue
+++ b/packages/web-admin/src/views/IntegrationsView.vue
@@ -5,6 +5,7 @@ import {
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
CLASSISLAND_NOTIFICATION_MAX_DURATION,
+ CLASS_HELPER_STATUS_REFRESH_SECONDS,
CLASSISLAND_TIME_STATE_LABELS,
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
@@ -12,6 +13,7 @@ import {
formatDate,
relativeTime,
type ClassDto,
+ type ClassHelperStatusDto,
type ClassIslandNotificationChannel,
type ClassIslandStateEvent,
type IntegrationDeviceDto,
@@ -27,7 +29,7 @@ import { useRealtimeStore } from '@/stores/realtime';
* 两块内容:
* 1. **设备接入**:为每个班签发设备令牌(`chci_...`),老师把令牌填进教室机器的
* ClassIsland 插件设置,插件就会把该机课表/上课状态上报上来,并接收本页下发的提醒;
- * 2. **下发提醒**:把一条提醒推到该班的 ClassIsland 上全屏弹出(学生端通知中心同时留档)。
+ * 2. **下发提醒**:把一条提醒推到该班的 ClassIsland 上全屏弹出(ClassHelper 班级端通知中心同时留档)。
*
* 权限与后端对齐:本页只对 ADMIN / TEACHER 开放(路由 meta.roles),
* 具体到"能不能管这个班"由后端 assertCanManageSchedule / assertCanPublishContent 判定。
@@ -41,6 +43,45 @@ const classes = ref([]);
const devices = ref([]);
const filter = reactive({ classId: '' });
+/* ------------------------------------------------------------ ClassHelper 班级端在线状态
+ *
+ * 需求「教师网页支持查看班级 ClassHelper 在线状态」的落点:
+ * 管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课的班级(后端按 classScope 过滤)。
+ * 在线判定统一是"最近 60 秒内有心跳"(共享常量 CLASS_HELPER_ONLINE_WINDOW_MS),
+ * 与设备列表里那个 3 分钟容差的展示窗口是两回事 —— 这里给的是准确口径。
+ */
+const statusList = ref([]);
+const statusLoading = ref(false);
+const statusServerTime = ref('');
+const statusWindowMs = ref(60_000);
+let statusTimer: ReturnType | null = null;
+
+async function loadStatus(): Promise {
+ statusLoading.value = true;
+ try {
+ const result = await integrationApi.classHelperStatus();
+ statusList.value = result.classes;
+ statusServerTime.value = result.serverTime;
+ statusWindowMs.value = result.onlineWindowMs;
+ } finally {
+ statusLoading.value = false;
+ }
+}
+
+/** 在线班级数 / 总数(卡片标题上的概览) */
+const onlineClassCount = computed(() => statusList.value.filter((item) => item.online).length);
+
+/**
+ * 距今多久(按**服务器时间**算,不用本机时钟)。
+ * 教室机器常年不校时,本机与服务器可能差几分钟,用它算"距今"会得出负数或虚高的值。
+ */
+function sinceServer(value: string | null): string {
+ if (!value) return '从未';
+ const base = statusServerTime.value ? new Date(statusServerTime.value).getTime() : Date.now();
+ const diff = base - new Date(value).getTime();
+ return relativeTime(new Date(Date.now() - diff).toISOString());
+}
+
/** deviceId → 最近一次 socket 推送的实时状态(页面停留期间最新) */
const liveStates = ref>({});
@@ -217,6 +258,30 @@ async function toggleMirror(device: IntegrationDeviceDto): Promise {
await loadDevices();
}
+/**
+ * 「从教室机器获取课表」——**现在取教室课表的唯一入口**。
+ *
+ * 自动回传已在 2026-10-06 起默认关闭:它会在老师手排课之后被教室的旧课表悄悄覆盖。
+ * 因此这里做成一次显式的人工动作,并且**点之前必须弹警告**说明后果。
+ */
+async function pullScheduleFromDevice(device: IntegrationDeviceDto): Promise {
+ const confirmed = await ElMessageBox.confirm(
+ [
+ `将从「${device.name}」读取它当前的课表,并覆盖 / 合并到本班的课表。`,
+ '',
+ '本班在 ClassHelper 上手工排过的课可能被改动;教室里删掉或挪动的课会同步过来。',
+ '确认要从教室机器取一次课表吗?',
+ ].join('\n'),
+ '从教室机器获取课表',
+ { type: 'warning', confirmButtonText: '确认获取', cancelButtonText: '取消' },
+ ).catch(() => null);
+ if (!confirmed) return;
+
+ const result = await integrationApi.requestSchedule(device.id);
+ ElMessage.success(result.nextHeartbeatHint);
+ await loadDevices();
+}
+
async function resetToken(device: IntegrationDeviceDto): Promise {
const confirmed = await ElMessageBox.confirm(
`重置后「${device.name}」的旧令牌立即失效,教室机器需要重新填写新令牌。确定继续?`,
@@ -330,18 +395,22 @@ function handleState(payload: unknown): void {
}
onMounted(async () => {
- await reload().catch(() => undefined);
+ await Promise.all([reload().catch(() => undefined), loadStatus().catch(() => undefined)]);
realtime.on(SOCKET_EVENTS.classislandState, handleState);
// 在线状态是"距今多久"的判断,需要定时重算,否则页面开着时间不会变
tickTimer = setInterval(() => {
nowTick.value = Date.now();
}, 30_000);
+ // ClassHelper 班级端在线状态另有自动刷新(需求:支持手动刷新和自动刷新)
+ statusTimer = setInterval(() => void loadStatus(), CLASS_HELPER_STATUS_REFRESH_SECONDS * 1000);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classislandState, handleState);
if (tickTimer) clearInterval(tickTimer);
tickTimer = null;
+ if (statusTimer) clearInterval(statusTimer);
+ statusTimer = null;
});
@@ -349,9 +418,9 @@ onUnmounted(() => {
+
+
+
+
+
+
+
+
+
+
+
+ {{ device.deviceName }}
+ {{ device.deviceKey }}
+
+
+ 未接入
+
+
+
+
+
+ {{ row.online ? '在线' : '离线' }}
+
+
+
+
+
+ {{ sinceServer(row.devices[0]?.lastSeenAt ?? null) }}
+
+
+
+
+ {{ sinceServer(row.devices[0]?.lastHeartbeatAt ?? null) }}
+
+
+
+
+
+ 插件 {{ row.devices[0].pluginVersion ?? '-' }} · ClassIsland
+ {{ row.devices[0].classIslandVersion ?? '-' }}
+
+ -
+
+
+
+
+
+ {{ row.studentGradeQueryEnabled ? '已开放' : '已关闭' }}
+
+
+
+
+
+
@@ -462,7 +601,7 @@ onUnmounted(() => {
{{ formatDate(row.createdAt) }}
-
+
{{ row.enabled ? '停用' : '启用' }}
@@ -470,6 +609,7 @@ onUnmounted(() => {
{{ row.mirrorScheduleToClassIsland ? '关镜像' : '开镜像' }}
+ 获取课表
重置令牌
删除
@@ -598,7 +738,7 @@ onUnmounted(() => {
- 同时在班级通知中心留一条(学生端也能事后查看)
+ 同时在班级通知中心留一条(ClassHelper 班级端也能事后查看)
@@ -610,6 +750,24 @@ onUnmounted(() => {
diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs
index 7365d67..0598f09 100644
--- a/scripts/dist-server.mjs
+++ b/scripts/dist-server.mjs
@@ -5,7 +5,7 @@
*
* 产出(Windows):
* releases/server/<版本>/免安装/ 可直接运行的免安装目录
- * releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
+ * releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
* releases/server/<版本>/构建中间/server-installer.generated.nsi NSIS 生成脚本(可重新生成)
*
* 产出(Linux,加 --platform linux,即 pnpm dist:server:linux):
@@ -38,7 +38,7 @@ const webDir = path.join(root, 'packages', 'web-admin');
const deployDir = path.join(root, 'deploy');
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
-const PRODUCT = '班级小助手服务端';
+const PRODUCT = 'ClassHelper 服务端';
const PORT = '4000';
const LINUX_ARCH = 'x64';
@@ -164,7 +164,7 @@ function writeRuntimeManifest() {
version: VERSION,
private: true,
type: 'module',
- description: '班级小助手服务端运行时(安装包 / 容器内置)',
+ description: 'ClassHelper 服务端运行时(安装包 / 容器内置)',
dependencies: serverProdDependencies(),
};
fs.writeFileSync(path.join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
@@ -446,8 +446,8 @@ function pruneRuntime() {
function writeEnvironmentFile() {
const jwtSecret = crypto.randomBytes(48).toString('hex');
- const content = `# 班级小助手服务端(安装程序生成,请妥善保管)
-# 修改后需重启服务生效(开始菜单 → 班级小助手服务端 → 重启服务)
+ const content = `# ClassHelper 服务端(安装程序生成,请妥善保管)
+# 修改后需重启服务生效(开始菜单 → ClassHelper 服务端 → 重启服务)
NODE_ENV=production
HOST=0.0.0.0
@@ -476,7 +476,7 @@ LOG_LEVEL=info
# 可选项
BCRYPT_ROUNDS=10
-# 新建班级的初始密码(学生端用「班级码 + 班级密码」登录,没有个人学生账号)
+# 新建班级的初始密码(ClassHelper 班级端用「班级码 + 班级密码」登录,没有个人学生账号)
DEFAULT_CLASS_PASSWORD=123456
TERM_START_DATE=
`;
@@ -619,16 +619,16 @@ function writeReadme() {
========================================
一、启动与访问
- 1. 双击「start.cmd」(或开始菜单 → 班级小助手服务端 → 启动服务)
+ 1. 双击「start.cmd」(或开始菜单 → ClassHelper 服务端 → 启动服务)
2. 浏览器打开 http://127.0.0.1:${PORT}/ 即可进入 Web 管理端
3. 安装程序已把本服务加入当前用户的开机自启项,重启电脑后无需手动启动
二、默认账号(首次安装后请立即修改密码)
管理员 admin / admin123
教师 teacher1 / teacher123、teacher2 / teacher123
- 班级 班级码 G101/G102/G203 + 班级密码 123456(学生端桌面客户端使用)
+ 班级 班级码 G101/G102/G203 + 班级密码 123456(ClassHelper 班级端桌面客户端使用)
说明 学生没有个人账号:学生只是名单记录(成绩/未交/叫人/已读都按名单走),
- 学生端统一用「班级码 + 班级密码」登录。
+ ClassHelper 班级端统一用「班级码 + 班级密码」登录。
三、目录说明
server\\ 后端程序(server\\dist\\index.js 为入口)
@@ -761,7 +761,7 @@ function writeLinuxReadme() {
三、安装后的默认账号(首次启动自动创建)
管理员 admin / admin123(安装器里设置初始密码则用它)
- 学生端 班级码 + 班级密码(学生没有个人账号)
+ ClassHelper 班级端 班级码 + 班级密码(学生没有个人账号)
四、常用运维命令(安装后)
classhelper status / doctor / password admin / backup / upgrade / rollback / logs -f
diff --git a/scripts/nsis/server-installer.nsi b/scripts/nsis/server-installer.nsi
index 20b3b7f..f14ab21 100644
--- a/scripts/nsis/server-installer.nsi
+++ b/scripts/nsis/server-installer.nsi
@@ -1,4 +1,4 @@
-; 班级小助手服务端 + Web 管理端 Windows 安装程序
+; ClassHelper 服务端 + Web 管理端 Windows 安装程序
; 由 scripts/dist-server.mjs 生成(占位符在打包时替换)
;
; 设计要点:
@@ -33,7 +33,7 @@ ShowUninstDetails show
!define MUI_UNICON "${ICON_FILE}"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_TITLE "安装完成"
-!define MUI_FINISHPAGE_TEXT "班级小助手服务端已安装完成。$\r$\n$\r$\n安装程序已将服务加入当前用户的开机自启项,浏览器打开 http://127.0.0.1:${APP_PORT}/ 即可使用管理端。$\r$\n$\r$\n默认账号:admin / admin123(首次登录后请立即修改密码)"
+!define MUI_FINISHPAGE_TEXT "ClassHelper 服务端已安装完成。$\r$\n$\r$\n安装程序已将服务加入当前用户的开机自启项,浏览器打开 http://127.0.0.1:${APP_PORT}/ 即可使用管理端。$\r$\n$\r$\n默认账号:admin / admin123(首次登录后请立即修改密码)"
!define MUI_FINISHPAGE_RUN
!define MUI_FINISHPAGE_RUN_TEXT "立即启动服务并打开管理端"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchService
diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs
index b16b9d0..2e85d6d 100644
--- a/scripts/ui-smoke/main.cjs
+++ b/scripts/ui-smoke/main.cjs
@@ -357,7 +357,7 @@ async function main() {
// 1. 首页
const title = await win.webContents.executeJavaScript('document.title');
- record('Web 管理端首页加载', typeof title === 'string' && title.includes('班级小助手'), `title=${title}`);
+ record('Web 管理端首页加载', typeof title === 'string' && title.includes('ClassHelper'), `title=${title}`);
// 2. 登录页
const loginReady = await waitFor(win, `document.querySelectorAll('input').length >= 2`, 15000);
@@ -951,6 +951,44 @@ async function main() {
// 7.8 授课科目统一:新增课表的科目下拉直接列出"全校统一科目"(不必按班级先录入课程),
// 选中后自动建课并写入课表;用例结束把这条课表删掉,保证不污染演示数据。
+ //
+ // 前置:种子数据给每个班建满了全部科目,"统一科目(自动建课)"那组就永远是空的。
+ // 这里先删掉本班一门**没有任何课表/作业/成绩引用**的科目(通用技术,演示课表只用前 11 门),
+ // 让下拉里真的出现"可自动建课"的分组;用例跑完会自动把它建回来(净效果为零)。
+ const sparePrep = await win.webContents.executeJavaScript(`(async () => {
+ const token = localStorage.getItem('classhelper.token') ?? '';
+ const headers = { authorization: 'Bearer ' + token };
+ const classLabel = (document.querySelector('.toolbar .el-select')?.textContent ?? '').trim();
+ const classes = (await (await fetch('/api/classes', { headers })).json()).data ?? [];
+ const target = classes.find((item) => item.name === classLabel) ?? classes[0];
+ if (!target) return { ok: false, classLabel, classCount: classes.length };
+ const courses = (await (await fetch('/api/courses?classId=' + target.id, { headers })).json()).data ?? [];
+ const spare = courses.find((item) => item.name === '通用技术');
+ if (!spare) return { ok: true, className: target.name, removed: false };
+ const status = (await fetch('/api/courses/' + spare.id, { method: 'DELETE', headers })).status;
+ // 关键:页面上的课程列表是**进入课表页时拉一次**的,删完必须重新加载,
+ // 否则「统一科目」分组仍按旧的课程表算,依旧是空的。
+ if (status === 200) {
+ location.reload();
+ return { ok: true, className: target.name, removed: true, reloading: true };
+ }
+ return { ok: true, className: target.name, removed: false, status };
+ })()`);
+ console.log('[ui-smoke] 统一科目用例前置:', JSON.stringify(sparePrep));
+
+ if (sparePrep?.reloading) {
+ // 等页面重新加载并回到课表页(新增课表按钮是这条用例的入口)
+ const reloadDeadline = Date.now() + 15000;
+ let ready = false;
+ while (Date.now() < reloadDeadline && !ready) {
+ await sleep(200);
+ ready = await win.webContents.executeJavaScript(
+ `Array.from(document.querySelectorAll('button')).some((node) => (node.textContent ?? '').trim() === '新增课表')`,
+ );
+ }
+ if (!ready) console.log('[ui-smoke] 警告:删课科目后页面重载超时');
+ }
+
const unifiedSubject = await win.webContents.executeJavaScript(`(async () => {
const openButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '新增课表',
@@ -1191,9 +1229,9 @@ async function main() {
};
})()`);
record(
- 'ClassIsland 联动页(教师为本班签发设备令牌 + 下发提醒表单)',
+ 'ClassHelper 联动页(教师为本班签发设备令牌 + 下发提醒表单)',
Boolean(integrationUi?.ok) &&
- integrationUi?.title === 'ClassIsland 联动' &&
+ integrationUi?.title === 'ClassHelper 联动' &&
integrationUi?.hasNotify === true &&
integrationUi?.hasCreate === true &&
integrationUi?.submit === true &&
diff --git a/scripts/verify-packaged.mjs b/scripts/verify-packaged.mjs
index 563e236..5c8c9d0 100644
--- a/scripts/verify-packaged.mjs
+++ b/scripts/verify-packaged.mjs
@@ -6,7 +6,7 @@
*
* 用法:
* node scripts/verify-packaged.mjs
- * node scripts/verify-packaged.mjs --exe "D:\class\@classhelperdesktop-client\班级小助手.exe"
+ * node scripts/verify-packaged.mjs --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
* node scripts/verify-packaged.mjs --offline
* node scripts/verify-packaged.mjs --code G101 --password 123456
*
@@ -28,7 +28,7 @@ const argOf = (name) => {
// 默认取 releases/client/<版本>/免安装/(dist-win 的产物落点,见 AGENTS.md §4)
const clientVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
-const defaultExe = path.join(repoRoot, 'releases', 'client', clientVersion, '免安装', '班级小助手.exe');
+const defaultExe = path.join(repoRoot, 'releases', 'client', clientVersion, '免安装', 'ClassHelper.exe');
const exe = path.resolve(argOf('--exe') ?? defaultExe);
const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101';
diff --git a/website/download.html b/website/download.html
index b3bc416..a59d7a1 100644
--- a/website/download.html
+++ b/website/download.html
@@ -18,7 +18,7 @@
-
+
diff --git a/website/index.html b/website/index.html
index e9fb57f..570b812 100644
--- a/website/index.html
+++ b/website/index.html
@@ -44,7 +44,7 @@
ClassHelper
- 1.1.2
+ 1.2.0