From 9d5ac7adf9455696999298d6819590b1fadc4ee8 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Tue, 6 Oct 2026 19:41:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared,server,web,client,plugin)!:=20?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E4=B8=8E=E6=9D=83=E9=99=90=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20+=20=E5=AD=A6=E6=9C=9F=E5=91=A8=E6=AC=A1=20+=20=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8=E6=96=B9=E5=90=91=E5=8F=8D=E8=BD=AC=EF=BC=881.2.0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。 学生不是账号 - 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段 - 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支 - 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher - 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失 任课关系唯一来源 = Course(班级 + 科目 + 教师) - 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号 - 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员 - 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关) 成绩与名单 - 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级 - 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制 - 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属 - 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业) - 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」 ClassHelper 班级端在线状态 - Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳 - lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开 学期周次 - 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置 - 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常) - 改周次后随课表下发给教室的 ClassHelper 班级端 课表方向反转 - 主方向改为「服务端 → 教室」自动下发;教室不再自动回传 - 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报 改名 - 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、 安装目录、systemd 描述、插件清单与设置页、官网、文档站、README BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe), 安装目录随之改变,不支持覆盖安装,需先卸载旧版。 服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢), 升级前请先备份。 --- AGENTS.md | 126 ++-- README.md | 4 +- deploy/Dockerfile | 2 +- deploy/classhelper | 8 +- deploy/docker-compose.sqlite.yml | 4 +- deploy/docker-compose.yml | 4 +- deploy/install.sh | 16 +- deploy/logrotate.d/classhelper | 2 +- deploy/package.runtime.json | 4 +- deploy/profile.d/classhelper.sh | 2 +- deploy/tools/admin-cli.mjs | 8 +- deploy/verify-linux.sh | 4 +- docs/404.html | 2 +- docs/app/calls.md | 2 +- docs/app/classes.md | 12 +- docs/app/grades.md | 4 +- docs/app/imports.md | 8 +- docs/app/index.md | 4 +- docs/app/notifications.md | 4 +- docs/dev/api.md | 10 +- docs/dev/data-model.md | 103 +++- docs/dev/index.md | 8 +- docs/dev/permissions.md | 92 +-- docs/dev/realtime.md | 2 +- docs/dev/winisland-design-tokens.md | 2 +- docs/docfx.json | 6 +- docs/faq.md | 4 +- docs/get-started/classisland.md | 16 +- docs/get-started/client.md | 10 +- docs/get-started/first-content.md | 12 +- docs/get-started/index.md | 6 +- docs/get-started/requirements.md | 4 +- docs/get-started/server.md | 8 +- docs/get-started/setup-class.md | 10 +- docs/get-started/toc.yml | 2 +- docs/get-started/web-admin.md | 2 +- docs/management/database.md | 4 +- docs/management/linux-deploy.md | 6 +- docs/management/production.md | 10 +- docs/management/upgrade.md | 2 +- docs/reference.md | 174 ++++-- docs/templates/classhelper/public/main.css | 2 +- package.json | 4 +- packages/classisland-plugin/README.md | 46 +- packages/classisland-plugin/manifest.yml | 16 +- .../classisland-plugin/scripts/verify.mjs | 34 +- .../src/Interop/ClassHelperClient.cs | 42 +- .../src/Models/PluginSettings.cs | 28 +- packages/classisland-plugin/src/Plugin.cs | 14 +- .../src/Services/BridgeService.cs | 74 ++- .../ClassHelperNotificationProvider.cs | 26 +- .../src/Services/ClassPlanWriter.cs | 16 +- .../src/Services/ScheduleMapper.cs | 6 +- .../src/Views/BridgeSettingsPage.axaml | 20 +- .../src/Views/BridgeSettingsPage.axaml.cs | 4 +- packages/desktop-client/electron-builder.yml | 6 +- packages/desktop-client/index.html | 2 +- packages/desktop-client/island.html | 2 +- packages/desktop-client/package.json | 4 +- packages/desktop-client/scripts/dist-win.mjs | 2 +- packages/desktop-client/scripts/smoke.mjs | 2 +- .../desktop-client/src/island/IslandApp.vue | 2 +- packages/desktop-client/src/main/index.ts | 2 +- packages/desktop-client/src/main/smoke.ts | 2 +- packages/desktop-client/src/main/tray.ts | 4 +- packages/desktop-client/src/renderer/App.vue | 2 +- .../desktop-client/src/renderer/api/index.ts | 27 +- .../src/renderer/cache/self-test.ts | 2 +- .../renderer/components/OnboardingWelcome.vue | 6 +- packages/desktop-client/src/renderer/icons.ts | 2 + .../src/renderer/layouts/ClientLayout.vue | 2 +- .../src/renderer/stores/auth.ts | 4 +- .../src/renderer/stores/realtime.ts | 2 +- .../src/renderer/styles/index.css | 2 +- .../src/renderer/views/GradeView.vue | 88 ++- .../src/renderer/views/HomeworkView.vue | 8 +- .../src/renderer/views/LoginView.vue | 2 +- .../src/renderer/views/settings/AboutView.vue | 4 +- .../views/settings/ReminderSettingsView.vue | 57 +- packages/server/package.json | 4 +- .../migration.sql | 186 ++++++ .../migration.sql | 8 + .../migration.sql | 26 + .../migration.sql | 15 + .../20261006130000_add_archives/migration.sql | 145 +++++ .../migration.sql | 79 +++ packages/server/prisma/schema.prisma | 303 +++++++--- packages/server/prisma/seed.ts | 250 ++++++-- .../scripts/apply-column-migrations.cjs | 45 ++ .../server/scripts/verify-classisland.mjs | 33 +- packages/server/scripts/verify-e2e.mjs | 480 +++++++++++---- packages/server/src/app.ts | 8 +- packages/server/src/config/env.ts | 2 +- packages/server/src/index.ts | 2 +- packages/server/src/lib/access.ts | 225 +++++-- packages/server/src/lib/class-account.ts | 26 +- packages/server/src/lib/http.ts | 2 + packages/server/src/lib/jwt.ts | 7 +- packages/server/src/lib/mappers.ts | 265 ++++++++- packages/server/src/lib/schemas.ts | 41 ++ packages/server/src/lib/session.ts | 54 +- packages/server/src/lib/snapshot.ts | 18 +- packages/server/src/lib/term.ts | 77 ++- packages/server/src/middleware/auth.ts | 11 +- .../src/modules/archives/archives.module.ts | 88 +++ .../src/modules/archives/archives.schemas.ts | 40 ++ .../src/modules/archives/archives.service.ts | 281 +++++++++ .../server/src/modules/auth/auth.module.ts | 2 +- .../server/src/modules/auth/auth.schemas.ts | 2 +- .../server/src/modules/auth/auth.service.ts | 97 ++- .../server/src/modules/calls/calls.module.ts | 2 +- .../server/src/modules/calls/calls.schemas.ts | 2 +- .../server/src/modules/calls/calls.service.ts | 37 +- .../src/modules/classes/classes.module.ts | 145 +++-- .../src/modules/classes/classes.schemas.ts | 88 ++- .../src/modules/classes/classes.service.ts | 433 ++++++++++---- .../modules/dashboard/dashboard.service.ts | 36 +- .../src/modules/grades/grades.module.ts | 64 +- .../src/modules/grades/grades.schemas.ts | 54 +- .../src/modules/grades/grades.service.ts | 326 ++++++++-- .../modules/homeworks/homeworks.schemas.ts | 11 +- .../modules/homeworks/homeworks.service.ts | 176 +++--- .../src/modules/imports/imports.schemas.ts | 13 +- .../modules/imports/table-import.service.ts | 560 +++++++++++++++--- .../integrations/integrations.module.ts | 65 +- .../integrations/integrations.schemas.ts | 7 +- .../integrations/integrations.service.ts | 196 +++++- .../notifications/notifications.service.ts | 30 +- packages/server/src/modules/registry.ts | 4 + .../src/modules/schedules/schedules.module.ts | 5 +- .../modules/schedules/schedules.service.ts | 15 +- .../src/modules/students/students.module.ts | 132 ++++- .../src/modules/students/students.schemas.ts | 70 ++- .../src/modules/students/students.service.ts | 381 +++++++++--- .../src/modules/teachers/teachers.module.ts | 2 +- .../src/modules/teachers/teachers.schemas.ts | 11 +- .../src/modules/teachers/teachers.service.ts | 7 +- .../server/src/modules/term/term.module.ts | 86 +++ .../server/src/modules/term/term.schemas.ts | 37 ++ .../server/src/modules/term/term.service.ts | 239 ++++++++ packages/server/src/realtime/bus.ts | 11 +- packages/server/src/realtime/socket.ts | 14 +- packages/shared/package.json | 4 +- packages/shared/src/constants.ts | 150 ++++- packages/shared/src/permissions.ts | 132 +++-- packages/shared/src/types.ts | 549 +++++++++++++++-- packages/shared/src/utils.ts | 109 +++- packages/web-admin/index.html | 6 +- packages/web-admin/package.json | 4 +- packages/web-admin/src/api/index.ts | 184 +++++- .../src/components/TableImportDialog.vue | 81 ++- packages/web-admin/src/config.ts | 2 +- packages/web-admin/src/icons.ts | 10 + .../web-admin/src/layouts/AdminLayout.vue | 8 +- packages/web-admin/src/router/index.ts | 20 +- packages/web-admin/src/stores/auth.ts | 25 +- packages/web-admin/src/views/ArchivesView.vue | 355 +++++++++++ packages/web-admin/src/views/ClassesView.vue | 349 +++++++++-- packages/web-admin/src/views/GradesView.vue | 349 ++++++++++- .../web-admin/src/views/HomeworksView.vue | 20 +- .../web-admin/src/views/IntegrationsView.vue | 172 +++++- packages/web-admin/src/views/LoginView.vue | 2 +- .../web-admin/src/views/NotificationsView.vue | 10 +- packages/web-admin/src/views/StudentsView.vue | 342 ++++++++--- packages/web-admin/src/views/TeachersView.vue | 38 +- .../web-admin/src/views/TermWeeksView.vue | 294 +++++++++ scripts/dist-server.mjs | 20 +- scripts/nsis/server-installer.nsi | 4 +- scripts/ui-smoke/main.cjs | 44 +- scripts/verify-packaged.mjs | 4 +- website/download.html | 2 +- website/index.html | 6 +- 172 files changed, 8761 insertions(+), 1896 deletions(-) create mode 100644 packages/server/prisma/migrations/20261006120000_add_student_table/migration.sql create mode 100644 packages/server/prisma/migrations/20261006120100_add_student_grade_query/migration.sql create mode 100644 packages/server/prisma/migrations/20261006120200_add_grade_level/migration.sql create mode 100644 packages/server/prisma/migrations/20261006120300_add_classhelper_heartbeat/migration.sql create mode 100644 packages/server/prisma/migrations/20261006130000_add_archives/migration.sql create mode 100644 packages/server/prisma/migrations/20261006140000_add_term_weeks/migration.sql create mode 100644 packages/server/src/modules/archives/archives.module.ts create mode 100644 packages/server/src/modules/archives/archives.schemas.ts create mode 100644 packages/server/src/modules/archives/archives.service.ts create mode 100644 packages/server/src/modules/term/term.module.ts create mode 100644 packages/server/src/modules/term/term.schemas.ts create mode 100644 packages/server/src/modules/term/term.service.ts create mode 100644 packages/web-admin/src/views/ArchivesView.vue create mode 100644 packages/web-admin/src/views/TermWeeksView.vue 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] }}

+ + + + +

+ 打开后,可以在「成绩」页按学号查到本班学生的成绩明细(含等级)。 + 学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。 + 关闭后本机查成绩会被服务端拒绝,教师端不受影响。 +

+ +
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(() => { - - + + + + + @@ -378,7 +500,7 @@ onUnmounted(() => { - +