feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。 学生不是账号 - 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段 - 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支 - 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher - 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失 任课关系唯一来源 = Course(班级 + 科目 + 教师) - 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号 - 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员 - 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关) 成绩与名单 - 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级 - 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制 - 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属 - 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业) - 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」 ClassHelper 班级端在线状态 - Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳 - lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开 学期周次 - 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置 - 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常) - 改周次后随课表下发给教室的 ClassHelper 班级端 课表方向反转 - 主方向改为「服务端 → 教室」自动下发;教室不再自动回传 - 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报 改名 - 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、 安装目录、systemd 描述、插件清单与设置页、官网、文档站、README BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe), 安装目录随之改变,不支持覆盖安装,需先卸载旧版。 服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢), 升级前请先备份。
This commit is contained in:
1 parent
17f55864bf
commit
9d5ac7adf9
172 files changed
+8711
-1846
No files matched your search
@@ -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 官网那页:图标 + 名称 + 系统要求 + 主按钮 + 变体下拉
|
||||
(<details>,点别处或 Esc 收起);插件那张只有一个文件,不摆点不开的箭头。
|
||||
读不到时退回 <meta name="ch-version"> 并按发布命名约定拼直链 ——
|
||||
那个 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 / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) |
|
||||
@@ -434,22 +446,28 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
|
||||
## 5. 架构硬约定(改代码前必须知道)
|
||||
|
||||
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.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 端真实点击回归(同上)
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
||||
### Web 管理端(老师 / 管理员)
|
||||
|
||||
- [x] 仪表盘:本班人数、课程、课表、作业、通知、成绩一屏概览
|
||||
- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、添加协作教师
|
||||
- [x] 班级管理:建班自动生成班级码与班级密码,随时更换班主任、按科目配置各科任课老师
|
||||
- [x] 学生 / 教师管理:单个录入或导入名单(xlsx / xls / csv / tsv)
|
||||
- [x] 课表管理:按周排课、单双周,可直接从 ClassIsland 档案导入课表与节次时间
|
||||
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# 班级小助手服务端 · 生产镜像
|
||||
# ClassHelper 服务端 · 生产镜像
|
||||
#
|
||||
# 两阶段构建:
|
||||
# build —— 安装依赖并编译 shared / server / web-admin
|
||||
|
||||
+4
-4
@@ -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 <<EOF
|
||||
${C_BOLD}班级小助手 · 运维命令${C_RESET}
|
||||
${C_BOLD}ClassHelper · 运维命令${C_RESET}
|
||||
|
||||
用法:classhelper <子命令> [参数]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-8
@@ -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" <<EOF
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手服务端配置(由 install.sh 生成于 $(date '+%F %T'))
|
||||
# ClassHelper 服务端配置(由 install.sh 生成于 $(date '+%F %T'))
|
||||
#
|
||||
# 这是**唯一的配置真身**:安装目录里的 .env 是指向本文件的软链,
|
||||
# systemd 通过 EnvironmentFile 读本文件;改完执行 classhelper restart 生效。
|
||||
@@ -830,7 +830,7 @@ BCRYPT_ROUNDS=10
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
${admin_line}
|
||||
|
||||
# 新建班级 / 重置教师账号时的默认密码(学生端用「班级码 + 班级密码」登录)
|
||||
# 新建班级 / 重置教师账号时的默认密码(ClassHelper 班级端用「班级码 + 班级密码」登录)
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
DEFAULT_TEACHER_PASSWORD=123456
|
||||
|
||||
@@ -973,7 +973,7 @@ install_docker_engine() {
|
||||
write_compose() {
|
||||
local compose="${CONFIG_DIR}/compose.yml"
|
||||
cat >"$compose" <<EOF
|
||||
# 班级小助手 · 由 install.sh 生成的 compose 文件(${MODE} 形态)
|
||||
# ClassHelper · 由 install.sh 生成的 compose 文件(${MODE} 形态)
|
||||
# 配置见 ${CONFIG_FILE}(改完执行 classhelper restart 重建容器)
|
||||
# 数据在 Docker 卷 classhelper-data 里;备份用 classhelper backup
|
||||
#
|
||||
@@ -1025,7 +1025,7 @@ setup_nginx() {
|
||||
install -m 600 "${INSTALL_DIR}/nginx.conf" "$target"
|
||||
else
|
||||
cat >"$target" <<EOF
|
||||
# 班级小助手 · Nginx 反向代理(替换 server_name 与证书路径后启用)
|
||||
# ClassHelper · Nginx 反向代理(替换 server_name 与证书路径后启用)
|
||||
server {
|
||||
listen 80;
|
||||
server_name class.example.com;
|
||||
@@ -1148,7 +1148,7 @@ print_result() {
|
||||
printf ' 1. 改掉默认密码:%sclasshelper password admin%s\n' "$C_CYAN" "$C_RESET"
|
||||
printf ' 2. 公网访问请上 HTTPS:改好 %s/nginx.conf 的域名与证书,并把 CORS_ORIGIN\n' "$CONFIG_DIR"
|
||||
printf ' 收敛到你的域名(classhelper config set CORS_ORIGIN https://your.domain)\n'
|
||||
printf ' 3. 学生端「服务器地址」填 http://%s:%s 或你的域名\n' "$ip" "$PORT"
|
||||
printf ' 3. ClassHelper 班级端「服务器地址」填 http://%s:%s 或你的域名\n' "$ip" "$PORT"
|
||||
printf '%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 日志轮转
|
||||
# ClassHelper · 日志轮转
|
||||
# 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper
|
||||
#
|
||||
# copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "classhelper-server-runtime",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
|
||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@prisma/adapter-libsql": "7.10.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 登录 shell 环境变量
|
||||
# ClassHelper · 登录 shell 环境变量
|
||||
#
|
||||
# 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。
|
||||
# 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
|
||||
* ClassHelper · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
|
||||
*
|
||||
* 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急,
|
||||
* 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。
|
||||
@@ -88,7 +88,7 @@ async function loadServerModules() {
|
||||
return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password };
|
||||
}
|
||||
|
||||
/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */
|
||||
/** 目标账号:普通用户按 username,班级账号按 Class.code(ClassHelper 班级端登录用的是班级码) */
|
||||
async function resolveTarget(prisma, key) {
|
||||
if (key.startsWith('class:')) {
|
||||
const code = key.slice('class:'.length).toUpperCase();
|
||||
@@ -100,7 +100,7 @@ async function resolveTarget(prisma, key) {
|
||||
if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND };
|
||||
if (user.role === 'STUDENT') {
|
||||
return {
|
||||
error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
|
||||
error: `学生没有个人账号(${key} 只是名单记录),ClassHelper 班级端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
|
||||
code: EXIT.NOT_FOUND,
|
||||
};
|
||||
}
|
||||
@@ -174,7 +174,7 @@ async function cmdList(modules, asJson) {
|
||||
for (const user of users) {
|
||||
process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`);
|
||||
}
|
||||
process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n');
|
||||
process.stdout.write('\n班级账号(ClassHelper 班级端用「班级码 + 班级密码」登录):\n');
|
||||
for (const item of classes) {
|
||||
const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)';
|
||||
process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · Linux 安装与运维链路验收(在**已安装的服务器上**运行)
|
||||
# ClassHelper · Linux 安装与运维链路验收(在**已安装的服务器上**运行)
|
||||
#
|
||||
# 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 →
|
||||
# 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载
|
||||
@@ -74,7 +74,7 @@ CURL=(curl -fsS --noproxy '*')
|
||||
|
||||
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi
|
||||
|
||||
printf '%s班级小助手 · Linux 安装验收%s\n' "$B" "$N"
|
||||
printf '%sClassHelper · Linux 安装验收%s\n' "$B" "$N"
|
||||
note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}"
|
||||
|
||||
# ---------------------------------------------------------------- 1. 安装结果
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
<base href="{{BASE}}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>页面不存在 | 班级小助手文档</title>
|
||||
<title>页面不存在 | ClassHelper文档</title>
|
||||
<script>
|
||||
// 与 docfx 主题选择同一套 key(theme 存 light / dark / auto),避免两边主题不一致
|
||||
const theme = localStorage.getItem('theme') || 'auto';
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ description: 老师点名让学生过来:普通与紧急两档、快捷短语
|
||||
|
||||
## 两档
|
||||
|
||||
| 级别 | 落库级别 | 学生端行为 |
|
||||
| 级别 | 落库级别 | ClassHelper 班级端行为 |
|
||||
| ------------ | -------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) |
|
||||
| **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
|
||||
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: 班级、学生与教师
|
||||
description: 班级账号、学生名单、班主任与协作教师,以及"谁能看什么、谁能改什么"。
|
||||
description: 班级账号、学生名单、班主任与各科任课老师,以及"谁能看什么、谁能改什么"。
|
||||
---
|
||||
|
||||
# 班级、学生与教师
|
||||
|
||||
## 班级账号:学生端的身份
|
||||
## 班级账号:ClassHelper 班级端的身份
|
||||
|
||||
学生**没有个人账号**。一台教室机器用一个**班级账号**登录:
|
||||
|
||||
@@ -36,7 +36,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
|
||||
| 密码 | **没有密码概念**。学生的 `passwordHash` 是空串占位,不能登录,也没有任何重置密码接口 |
|
||||
| 所属班级 | 一个学生属于一个主班级 |
|
||||
|
||||
> 用学生用户名去登录会被拒。就算拿教师 / 管理员账号去试,服务端也会在**密码校验之前**先判角色 ——
|
||||
> 学生**不是账号**(`Student` 表里没有密码、没有角色),拿学号去登录只会得到 401 ——
|
||||
> 否则"密码错(401)"和"密码对但学生账号已停用(403)"的差异会把学生账号密码变成可离线验证的预言机。
|
||||
|
||||
## 教师
|
||||
@@ -46,7 +46,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
|
||||
| 新建 / 编辑 / 删除 | 「教师管理」 | **仅管理员** |
|
||||
| 导入名单(xlsx/csv) | 「教师管理 → 导入名单」 | **仅管理员** |
|
||||
| 重置密码 | 「教师管理」(可指定新密码,留空用默认初始密码) | **仅管理员** |
|
||||
| 加进某个班 | 班级详情 → 协作教师 | 管理员 / 教师 |
|
||||
| 加进某个班 | 班级详情 → 任课老师(班级 + 科目 + 教师) | 管理员 |
|
||||
| 换班主任 | 班级详情顶部「更换班主任」 | **仅管理员** |
|
||||
|
||||
> **教师录入只有管理员能做**:`/api/teachers` 整组接口都是 `requireRole('ADMIN')`,教师调用返回 403。
|
||||
@@ -58,7 +58,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
|
||||
|
||||
1. 他还是某个班的**班主任**;
|
||||
2. 名下有**课程**;
|
||||
3. 有**班级分配**(协作教师);
|
||||
3. 有**任课关系**(`Course.teacherId === 他`);
|
||||
4. **发布过作业或通知**。
|
||||
|
||||
(后两项曾经漏检,会静默删掉该教师发过的作业与通知。)
|
||||
@@ -71,7 +71,7 @@ description: 班级账号、学生名单、班主任与协作教师,以及"谁
|
||||
| ------------------ | ------------------------------ | -------------------------- |
|
||||
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
|
||||
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
|
||||
| 科任老师 `SUBJECT` | `ClassTeacher` 里有当前用户 | 被分配的班级 |
|
||||
| 科任老师 `SUBJECT` | 该班 `Course.teacherId` 有当前用户 | 被任课的班级 |
|
||||
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) |
|
||||
|
||||
### 权限矩阵(摘要)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 成绩
|
||||
description: 成绩录入与导入、等级与得分率、学生端看到的全班总览。
|
||||
description: 成绩录入与导入、等级与得分率、ClassHelper 班级端看到的全班总览。
|
||||
---
|
||||
|
||||
# 成绩
|
||||
@@ -31,7 +31,7 @@ Web 管理端「成绩录入」页:
|
||||
|
||||
## 学生看到的是什么
|
||||
|
||||
学生端(教室机器)用的是**班级账号**,所以它看到的不是"我的成绩",而是**全班成绩总览**:
|
||||
ClassHelper 班级端(教室机器)用的是**班级账号**,所以它看到的不是"我的成绩",而是**全班成绩总览**:
|
||||
|
||||
- 表格:分数 / **得分率** / **等级**;
|
||||
- 柱状图;
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@ description: 成绩、学生名单、教师名单的表格导入,以及从 Cla
|
||||
1. **下载模板** —— `GET /imports/template?kind=grades&format=xlsx|csv`。
|
||||
CSV 带 BOM,Excel 直接双击打开不乱码。
|
||||
2. **上传预览** —— 服务端返回列名、前 20 行、总行数与**建议的字段映射**,以及校验问题。
|
||||
列名按同义词自动映射(「学号→学生用户名」「得分→分数」「满分→总分」……),也可以手动改。
|
||||
列名按同义词自动映射(「用户名→学号」「得分→分数」「满分→总分」「课程→科目」……),也可以手动改。
|
||||
**缺必填列不会静默通过**,会直接给出「缺少必填列「…」」。
|
||||
3. **确认导入** —— 按映射与写入模式提交:
|
||||
|
||||
@@ -34,7 +34,7 @@ description: 成绩、学生名单、教师名单的表格导入,以及从 Cla
|
||||
## 1. 成绩表格导入
|
||||
|
||||
- 入口:成绩录入 → 「导入表格」
|
||||
- 模板列:`学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程`
|
||||
- 模板列:`学生学号 / 学生姓名 / 考试名称 / 分数 / 总分 / 科目 / 等级`
|
||||
- 支持 `.xlsx / .xls / .csv / .tsv`(自动识别分隔符)
|
||||
- **重复判定**:同班级 + 同学生 + 同考试 + 同课程
|
||||
- 学生按「用户名」优先、「姓名」兜底匹配
|
||||
@@ -42,14 +42,14 @@ description: 成绩、学生名单、教师名单的表格导入,以及从 Cla
|
||||
## 2. 学生名单导入
|
||||
|
||||
- 入口:学生管理 → 「导入名单」(仅管理员)
|
||||
- 模板列:`用户名 / 姓名` —— 学生没有账号属性,**没有密码列**
|
||||
- 模板列:`学号 / 姓名 / 班级 / 性别 / 家长手机号` —— 学生**不是账号**,没有密码列
|
||||
- 用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$`
|
||||
- 用户名已经被某个教师 / 管理员账号占用时,**这一行进错误列表**,不会覆盖他人账号
|
||||
|
||||
## 3. 教师名单导入
|
||||
|
||||
- 入口:教师管理 → 「导入名单」(仅管理员)
|
||||
- 模板列:`用户名 / 姓名 / 初始密码 / 角色`
|
||||
- 模板列:`工号 / 姓名 / 手机号 / 初始密码 / 角色`
|
||||
- 角色取 `ADMIN` / `TEACHER`,留空按 `TEACHER`
|
||||
- 与新建教师同一套校验,导入完可以直接登录
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 应用帮助
|
||||
description: 班级小助手每一项功能怎么用:课表、作业、通知、叫人、成绩、班级、灵动岛、外观与导入。
|
||||
description: ClassHelper每一项功能怎么用:课表、作业、通知、叫人、成绩、班级、灵动岛、外观与导入。
|
||||
---
|
||||
|
||||
# 应用帮助
|
||||
@@ -15,7 +15,7 @@ description: 班级小助手每一项功能怎么用:课表、作业、通知
|
||||
| [通知与提醒](notifications.md) | 三档优先级、上课时段策略、二次确认、已读与红点 |
|
||||
| [叫人](calls.md) | 普通叫人与紧急叫人的差别、快捷短语、谁能叫谁 |
|
||||
| [成绩](grades.md) | 录入方式、导入、等级分布、学生看到的是什么 |
|
||||
| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与协作教师、谁看得见什么 |
|
||||
| [班级、学生与教师](classes.md) | 班级账号、名单、班主任与各科任课老师、谁看得见什么 |
|
||||
| [灵动岛](island.md) | 胶囊与卡片、多条通知的竖排列表、停靠与外观、触摸屏 |
|
||||
| [外观、主题与动效](appearance.md) | 深浅色、设计令牌、动效语言、降低动态效果 |
|
||||
| [表格与课表导入](imports.md) | 成绩 / 名单 / 教师表格导入与 ClassIsland 档案导入 |
|
||||
|
||||
@@ -10,7 +10,7 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
|
||||
|
||||
## 三档优先级
|
||||
|
||||
| 优先级 | 学生端表现 |
|
||||
| 优先级 | ClassHelper 班级端表现 |
|
||||
| ------ | -------------------------------------------------------------------------------------------- |
|
||||
| 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 |
|
||||
| 重要 | 同上,卡片上有优先级标签 |
|
||||
@@ -22,7 +22,7 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
|
||||
|
||||
「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后:
|
||||
|
||||
| 学生端行为 | 上课时段 | 课间 / 放学 |
|
||||
| ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 |
|
||||
| ---------- | ---------------------------------------- | ----------- |
|
||||
| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 |
|
||||
| 紧急通知 | **立刻弹** | 立刻弹 |
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: REST API
|
||||
description: 班级小助手后端全部接口一览:方法、路径、权限与说明。
|
||||
description: ClassHelper后端全部接口一览:方法、路径、权限与说明。
|
||||
---
|
||||
|
||||
# REST API
|
||||
@@ -17,13 +17,13 @@ description: 班级小助手后端全部接口一览:方法、路径、权限
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
|
||||
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) |
|
||||
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
|
||||
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 |
|
||||
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
|
||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
|
||||
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
||||
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
||||
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 协作教师) |
|
||||
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
|
||||
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
|
||||
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
|
||||
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
|
||||
@@ -33,7 +33,7 @@ description: 班级小助手后端全部接口一览:方法、路径、权限
|
||||
| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 |
|
||||
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 |
|
||||
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
|
||||
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 |
|
||||
| PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) |
|
||||
| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 |
|
||||
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) |
|
||||
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
|
||||
@@ -72,7 +72,7 @@ description: 班级小助手后端全部接口一览:方法、路径、权限
|
||||
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
|
||||
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
|
||||
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 14 张表行数 / 备份列表 / 定时配置) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
|
||||
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
|
||||
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
|
||||
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
|
||||
|
||||
+67
-24
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 数据模型
|
||||
description: 14 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
|
||||
description: 16 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
|
||||
---
|
||||
|
||||
# 数据模型
|
||||
@@ -15,45 +15,83 @@ description: 14 张表的职责、跨库通用约定、迁移历史与启动时
|
||||
- 因此切库只需改 `provider` 与连接串(`pnpm db:switch:mysql` 就地改写它,没有第二份 schema);
|
||||
- 主键统一用 cuid 字符串,便于多端离线生成与合并。
|
||||
|
||||
## 14 张表
|
||||
## 两条核心不变量
|
||||
|
||||
这两条是 2026-10-06 那次重构立下的,后面所有表都围着它们转:
|
||||
|
||||
**① 学生不是账号。** `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号 /
|
||||
状态,**没有 `passwordHash`、没有 `role`、没有登录字段** —— 「学生不能登录」由表结构保证,
|
||||
不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
|
||||
`User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
|
||||
|
||||
**② 任课关系的唯一来源是 `Course`。** `Course(classId, name, teacherId)` 就是
|
||||
「班级 + 科目 + 教师」这条关系本身,作业与成绩都挂在它上面;班主任另由 `Class.teacherId` 表示
|
||||
(每班 1 人)。历史上那张**没有科目**的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
|
||||
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
|
||||
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
|
||||
|
||||
## 16 张表
|
||||
|
||||
| 表 | 职责 | 值得注意的字段 |
|
||||
| ------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `User` | 教师 / 管理员 / 学生统一存储 | `role`、`classId`;学生 `passwordHash` 是空串占位,不能登录 |
|
||||
| `Class` | 班级 | `code` + `passwordHash`(班级账号)、`termWeeks`、`notificationChannel` |
|
||||
| `ClassTeacher` | 教师-班级 分配关系(协作教师) | `@@unique([classId, teacherId])` |
|
||||
| `Enrollment` | 学生-班级 归属记录 | 与 `User.classId` 同步,为将来多班预留 |
|
||||
| `Course` | 课程(班级维度) | `@@unique([classId, name])` |
|
||||
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`;**学生不在这里** |
|
||||
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
|
||||
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
|
||||
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
|
||||
| `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`notificationChannel`、`studentGradeQueryEnabled`、`archivedYearId` |
|
||||
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
|
||||
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
|
||||
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) |
|
||||
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
|
||||
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, userId])` |
|
||||
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
|
||||
| `Notification` | 通知 | `priority` |
|
||||
| `NotificationRead` | 已读记录 | `@@unique([notificationId, userId])` |
|
||||
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
|
||||
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
|
||||
| `Grade` | 成绩 | `score` / `totalScore` / `examName` |
|
||||
| `IntegrationDevice` | ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`tokenHint`、两个开关、最后一次上报的快照字段 |
|
||||
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
|
||||
| `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 |
|
||||
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` |
|
||||
|
||||
### 三个容易忽略的设计
|
||||
### 几个容易忽略的设计
|
||||
|
||||
**`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。
|
||||
设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。
|
||||
`expiresAt` 防止学生几天后突然弹出旧提醒。
|
||||
|
||||
**`Schedule.source`** —— 决定插件全量上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。
|
||||
**在线判定看 `lastHeartbeatAt`,不看 `lastSeenAt`** —— 同一台设备的上报其实有两种:
|
||||
心跳(每 60 秒一次,只表明"我还在线")与带状态的上报(课表 / 上课状态)。
|
||||
合成一个字段后无法区分"在线但一直没新状态"和"整个失联",所以拆开:
|
||||
`lastHeartbeatAt` 每次上报都刷(60 秒窗口判在线),`lastSeenAt` 只在带运行状态时刷。
|
||||
|
||||
**课表同步方向:服务端 → 教室是主方向。**
|
||||
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端自动下发),
|
||||
`syncScheduleToServer` 默认 **false**(教室不再自动回传)。要取教室的课表,走
|
||||
`POST /integrations/devices/:id/request-schedule` 这个**人工动作**(前端点击前会弹警告)——
|
||||
自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
|
||||
|
||||
**`Schedule.source`** —— 决定插件上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。
|
||||
**`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。
|
||||
|
||||
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
|
||||
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
|
||||
|
||||
## 迁移历史(10 个)
|
||||
**`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
|
||||
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
|
||||
|
||||
## 迁移历史(16 个)
|
||||
|
||||
```
|
||||
init → add_time_layout → add_class_account → add_schedule_week_parity →
|
||||
add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
|
||||
add_homework_assign_date → add_push_kind → add_schedule_source
|
||||
add_homework_assign_date → add_push_kind → add_schedule_source →
|
||||
add_student_table → add_student_grade_query → add_grade_level →
|
||||
add_classhelper_heartbeat → add_archives → add_term_weeks
|
||||
```
|
||||
|
||||
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
|
||||
把 `User` 里 `role='STUDENT'` 的行**按 id 原样搬过去**、把 `Grade` / `HomeworkStatus` /
|
||||
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
|
||||
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
|
||||
|
||||
### 启动时自动迁移
|
||||
|
||||
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
|
||||
@@ -69,19 +107,24 @@ add_homework_assign_date → add_push_kind → add_schedule_source
|
||||
|
||||
## 备份格式就是这份模型的拓扑序导出
|
||||
|
||||
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:14 张表按拓扑序导出,
|
||||
恢复时**临时关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
|
||||
**互相引用**,任何排序都会被其中一侧卡死。见[数据库管理](../management/database.md)。
|
||||
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:16 张表按拓扑序导出
|
||||
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`),恢复时**临时关闭外键检查** ——
|
||||
因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
|
||||
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
|
||||
见[数据库管理](../management/database.md)。
|
||||
|
||||
## 开发时的模型改动
|
||||
|
||||
1. 改 `schema.prisma`;
|
||||
2. 在 `prisma/migrations/` 下加一个迁移目录(本机用
|
||||
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
|
||||
它在 Windows 上不可靠);
|
||||
3. 改 schema 时先看**哪些外键指向 `User` 且是 Cascade**,再回来核对
|
||||
`deleteTeacher` 的护栏清单(它必须覆盖 `Class.teacherId` / `Course.teacherId` /
|
||||
`ClassTeacher.teacherId` / `Homework.createdBy` / `Notification.createdBy` 五项)。
|
||||
它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
|
||||
`pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
|
||||
生成骨架,再手工补数据搬运语句;
|
||||
3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单
|
||||
(它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` /
|
||||
`Notification.createdBy` 四项;`ClassIslandPush.createdBy` 是裸列、无外键、不级联,不用加);
|
||||
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
|
||||
|
||||
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
|
||||
本文里的数字标注的是当前版本(1.1.2)的状态。
|
||||
本文里的数字标注的是当前版本(1.2.0)的状态。
|
||||
+4
-4
@@ -1,17 +1,17 @@
|
||||
---
|
||||
title: 开发文档
|
||||
description: 班级小助手的架构、接口、数据模型、权限、动效层,以及验收与发版流程。
|
||||
description: ClassHelper的架构、接口、数据模型、权限、动效层,以及验收与发版流程。
|
||||
---
|
||||
|
||||
# 开发文档
|
||||
|
||||
班级小助手是一个 **pnpm monorepo**,一份代码产出四个交付物:
|
||||
ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
|
||||
|
||||
| 交付物 | 位置 | 形态 |
|
||||
| -------------------- | ----------------------------- | --------------------------------------------------------------- |
|
||||
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
|
||||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
|
||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生端 / 教室机器,含灵动岛浮窗) |
|
||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
|
||||
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) |
|
||||
|
||||
@@ -38,7 +38,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
||||
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
|
||||
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
|
||||
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
|
||||
| [数据模型](data-model.md) | 14 张表、迁移历史、跨库约定 |
|
||||
| [数据模型](data-model.md) | 16 张表、迁移历史、跨库约定 |
|
||||
| [角色与权限](permissions.md) | 权限矩阵与实现位置 |
|
||||
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
|
||||
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
|
||||
|
||||
+54
-28
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 角色与权限
|
||||
description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条纪律。
|
||||
description: 权限矩阵、科目维度的收口规则,以及"前端隐藏 ≠ 权限"这条纪律。
|
||||
---
|
||||
|
||||
# 角色与权限
|
||||
@@ -17,33 +17,55 @@ description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条
|
||||
|
||||
## 角色判定
|
||||
|
||||
角色只有三种,**没有 `STUDENT`** —— 学生不是账号(见[数据模型](data-model.md))。
|
||||
班级内的角色由「账号角色 + 与该班级的关系」共同决定:
|
||||
|
||||
| 角色 | 判定方式 | 可见范围 |
|
||||
| ------------------ | ------------------------------ | -------------------------- |
|
||||
| --------------------------- | ------------------------------------------ | ------------------------------------ |
|
||||
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
|
||||
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
|
||||
| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 |
|
||||
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) |
|
||||
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
|
||||
| `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录(`classSession`) | 本班(代全班读写个人数据) |
|
||||
| `NONE` | 与这个班没有任何关系 | 什么都看不到 |
|
||||
|
||||
判定函数是 `resolveClassRole()`。
|
||||
判定函数是 `resolveClassRole()`。「科任」的判定源是 **`Course`**(班级 + 科目 + 教师),
|
||||
不是那张已经没有的 `ClassTeacher` 协作表。
|
||||
|
||||
## 权限矩阵
|
||||
|
||||
| 操作 | 管理员 | 班主任 | 科任老师 |
|
||||
| -------------------------------------------------- | :----: | :----------: | :------: |
|
||||
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ |
|
||||
| 分配班主任与科任老师 | ✅ | ❌ | ❌ |
|
||||
| 学生名单管理(增删 / 导入) | ✅ | ❌ | ❌ |
|
||||
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ |
|
||||
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ |
|
||||
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ |
|
||||
| 布置作业 | ✅ | ✅ | ✅ |
|
||||
| 叫人 | ✅ | ✅ | ✅ |
|
||||
| 发布通知 | ✅ | ✅ | ✅ |
|
||||
| 成绩录入 / 修改 / 导入(仅本班) | ✅ | ✅(仅本班) | ❌ |
|
||||
| 班级密码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ |
|
||||
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
|
||||
| -------------------------------------------------------- | :----: | :----------: | :------: | :----: |
|
||||
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
|
||||
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
|
||||
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
|
||||
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ |
|
||||
| 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ |
|
||||
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ |
|
||||
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ |
|
||||
| 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ |
|
||||
| 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ |
|
||||
| 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ |
|
||||
| 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ |
|
||||
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 |
|
||||
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ |
|
||||
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
## 科目维度的收口(本次重构的核心规则)
|
||||
|
||||
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
|
||||
|
||||
| 情形 | 谁可以写 |
|
||||
| --------------------------------------- | ------------------------------------------ |
|
||||
| 管理员 | 全部 |
|
||||
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
|
||||
| 没挂科目(`courseId` 为空) | 本班**班主任** |
|
||||
| ClassHelper 班级端 | 只有**作业**可以(老师在教室随手录一条);**成绩不给** |
|
||||
|
||||
于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
|
||||
「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
|
||||
|
||||
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
|
||||
作业 / 成绩 / 叫人三处写入统一走它。
|
||||
|
||||
## 实现要点
|
||||
|
||||
@@ -53,22 +75,27 @@ description: 权限矩阵、实现位置,以及"前端隐藏 ≠ 权限"这条
|
||||
|
||||
```
|
||||
assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster
|
||||
assertCanManageSchedule / assertCanPublishContent / assertCanManageGrades
|
||||
assertCanManageSchedule / assertCanPublishNotification
|
||||
assertCanManageGrades / assertCanManageSubjectContent
|
||||
```
|
||||
|
||||
**列表数据用 `resolveClassScope()` 收敛**(`classId in [...]`),单条接口直接断言;
|
||||
越权一律 403。
|
||||
|
||||
**鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次用户与班级关系,
|
||||
所以改了班级 / 角色会**立即生效**,不用等令牌过期。班级会话则回查 `Class` 表。
|
||||
**鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次账号,
|
||||
所以改了角色 / 班级关系会**立即生效**,不用等令牌过期。班级端会话则回查 `Class` 表。
|
||||
|
||||
**班级端的成绩查询开关**:`Class.studentGradeQueryEnabled`(默认开启)。
|
||||
关闭后班级端调 `GET /grades/student/:studentNo` 一律 403,教师端不受影响 ——
|
||||
这就是需求「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
|
||||
|
||||
## 前端隐藏 ≠ 权限
|
||||
|
||||
前端的隐藏只是"别让人看见入口":
|
||||
|
||||
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 数据库管理」;
|
||||
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理」;
|
||||
- 课表页的增删改按钮只对「管理员或本班班主任」显示;
|
||||
- 成绩页的录入 / 删除按钮同理。
|
||||
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
|
||||
|
||||
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
|
||||
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
|
||||
@@ -76,10 +103,9 @@ assertCanManageSchedule / assertCanPublishContent / assertCanManageGrades
|
||||
> 新增视图时别忘了两处:路由的 `meta.roles` + 服务层对应 service 里的 `assertCanXxx`。
|
||||
> 只做其中一个都等于没做。
|
||||
|
||||
## 学生账号的两个反向约束
|
||||
## 学生没有账号的两个反向约束
|
||||
|
||||
1. **学生不能登录**。`/auth/login` 遇到 `role=STUDENT` 直接 403;
|
||||
且**角色判定在密码校验之前** —— 否则"密码错(401) / 密码对但学生账号已停用(403)"的差异,
|
||||
会把学生账号密码变成可离线验证的预言机。
|
||||
1. **学生不能登录,而且根本不存在可以登录的学生账号**。`User` 表里没有学生行,
|
||||
拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
|
||||
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
|
||||
新建学生也不设密码(`passwordHash` 是空串占位)。
|
||||
新建学生也不设密码 —— `Student` 表里压根没有那一列。
|
||||
@@ -44,7 +44,7 @@ io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权
|
||||
| `user:{userId}` | 某个用户自己的连接 |
|
||||
| `teacher:{teacherId}` | 某个教师 |
|
||||
| `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) |
|
||||
| `students` | 全部学生端连接 |
|
||||
| `students` | 全部 ClassHelper 班级端连接 |
|
||||
| `teachers` | 全部教师端连接 |
|
||||
|
||||
服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 ——
|
||||
|
||||
@@ -575,7 +575,7 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
|
||||
|
||||
---
|
||||
|
||||
## 附:班级小助手实际采纳了哪些(本次重写对照表)
|
||||
## 附:ClassHelper实际采纳了哪些(本次重写对照表)
|
||||
|
||||
| WinIsland 做法 | 出处 | 我们的落地 | 验证 |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
|
||||
+3
-3
@@ -16,8 +16,8 @@
|
||||
"output": "_site",
|
||||
"template": ["default", "modern", "templates/classhelper"],
|
||||
"globalMetadata": {
|
||||
"_appName": "班级小助手",
|
||||
"_appTitle": "班级小助手文档",
|
||||
"_appName": "ClassHelper",
|
||||
"_appTitle": "ClassHelper文档",
|
||||
"_appLogoPath": "logo.png",
|
||||
"_appLogoUrl": "",
|
||||
"_appFaviconPath": "favicon.png",
|
||||
@@ -27,7 +27,7 @@
|
||||
"repo": "https://github.com/LoveChengke/classhelper",
|
||||
"branch": "master"
|
||||
},
|
||||
"_appFooter": "<div class=\"ch-footer\"><span>班级小助手(Class Helper)· 文档</span><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper\">GitHub 仓库</a><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper/releases\">下载与更新日志</a><span class=\"ch-footer-sep\">·</span><span>构建于 docfx</span></div>"
|
||||
"_appFooter": "<div class=\"ch-footer\"><span>ClassHelper(Class Helper)· 文档</span><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper\">GitHub 仓库</a><span class=\"ch-footer-sep\">·</span><a href=\"https://github.com/LoveChengke/classhelper/releases\">下载与更新日志</a><span class=\"ch-footer-sep\">·</span><span>构建于 docfx</span></div>"
|
||||
},
|
||||
"sitemap": {
|
||||
"baseUrl": "https://lovechengke.github.io/ClassHelper/docs/",
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
|
||||
|
||||
### 安装包装不上,说应用正在运行
|
||||
|
||||
同上。托盘 → 退出班级小助手。
|
||||
同上。托盘 → 退出 ClassHelper。
|
||||
|
||||
## 连接与登录
|
||||
|
||||
@@ -104,7 +104,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
|
||||
|
||||
### 学生登录不了
|
||||
|
||||
**学生没有个人账号。** 学生端统一用**班级码 + 班级密码**登录(见[装学生端](get-started/client.md))。
|
||||
**学生没有个人账号。** ClassHelper 班级端统一用**班级码 + 班级密码**登录(见[装 ClassHelper 班级端](get-started/client.md))。
|
||||
用学生用户名去登录一定会失败,这是设计。
|
||||
|
||||
### 客户端里找不到"修改密码"
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
title: 接入 ClassIsland
|
||||
description: 把教室里的 ClassIsland 接上班级小助手:下课表上报、大屏提醒下发、课表镜像三个方向。
|
||||
description: 把教室里的 ClassIsland 接上 ClassHelper:下课表上报、大屏提醒下发、课表镜像三个方向。
|
||||
---
|
||||
|
||||
# 接入 ClassIsland
|
||||
|
||||
课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。
|
||||
它跟班级小助手管的事情不重叠,接起来之后两边各拿各的好处:
|
||||
它跟 ClassHelper管的事情不重叠,接起来之后两边各拿各的好处:
|
||||
|
||||
| 方向 | 做什么 | 老师在哪看 |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 |
|
||||
| 班级小助手 → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 |
|
||||
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 |
|
||||
| ClassHelper → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 |
|
||||
| (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 |
|
||||
|
||||
> 没接 ClassIsland 也能用班级小助手,只是教室里少一个大屏播报的出口。**这一步是可选的。**
|
||||
> 没接 ClassIsland 也能用 ClassHelper,只是教室里少一个大屏播报的出口。**这一步是可选的。**
|
||||
|
||||
## 前置:ClassIsland 装好了
|
||||
|
||||
@@ -46,7 +46,7 @@ description: 把教室里的 ClassIsland 接上班级小助手:下课表上报
|
||||
|
||||
### 填令牌
|
||||
|
||||
ClassIsland 的**设置 → 班级小助手联动**里填:
|
||||
ClassIsland 的**设置 → ClassHelper 联动**里填:
|
||||
|
||||
| 设置项 | 说明 |
|
||||
| ---------------------------- | --------------------------------------------------------------- |
|
||||
@@ -85,7 +85,7 @@ ClassIsland 的**设置 → 班级小助手联动**里填:
|
||||
| 要同步的内容 | 需要打开 |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 |
|
||||
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到班级小助手」 |
|
||||
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到 ClassHelper」 |
|
||||
| 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 |
|
||||
|
||||
## 上课时段:两边同一套规则
|
||||
@@ -110,7 +110,7 @@ ClassIsland 侧的暂存由插件实现:它订阅课程服务,下课后把
|
||||
|
||||
## 镜像:把小助手的课表写回 ClassIsland
|
||||
|
||||
开启后,插件会新建一份名为 `班级小助手-<班级名>` 的档案课表,**不动老师原有的课表**。
|
||||
开启后,插件会新建一份名为 `ClassHelper-<班级名>` 的档案课表,**不动老师原有的课表**。
|
||||
|
||||
镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。
|
||||
这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: 装学生端
|
||||
description: 在教室机器上安装班级小助手桌面客户端,用班级码登录,并第一次跑通实时通知。
|
||||
title: 装 ClassHelper 班级端
|
||||
description: 在教室机器上安装 ClassHelper桌面客户端,用班级码登录,并第一次跑通实时通知。
|
||||
---
|
||||
|
||||
# 装学生端
|
||||
# 装 ClassHelper 班级端
|
||||
|
||||
桌面客户端装在**教室的那台机器**上(教室大屏、讲台电脑)。它是学生看课表作业的地方,
|
||||
也是「消息到达的第一现场」—— 灵动岛就长在这一端。
|
||||
@@ -14,8 +14,8 @@ description: 在教室机器上安装班级小助手桌面客户端,用班级
|
||||
|
||||
| 产物 | 用途 |
|
||||
| ------------------------------------ | ------------------------------------------ |
|
||||
| `班级小助手-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
|
||||
| `班级小助手-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
|
||||
| `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
|
||||
| `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
|
||||
|
||||
要自己打:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 发布第一条内容
|
||||
description: 发通知、布置作业、叫人、录成绩——把班级小助手真正用起来的第一轮操作。
|
||||
description: 发通知、布置作业、叫人、录成绩——把 ClassHelper真正用起来的第一轮操作。
|
||||
---
|
||||
|
||||
# 发布第一条内容
|
||||
@@ -11,7 +11,7 @@ description: 发通知、布置作业、叫人、录成绩——把班级小助
|
||||
|
||||
**通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。
|
||||
|
||||
| 优先级 | 学生端表现 |
|
||||
| 优先级 | ClassHelper 班级端表现 |
|
||||
| ------ | -------------------------------------------------------------------------- |
|
||||
| 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 |
|
||||
| 重要 | 同上,卡片上带优先级标签 |
|
||||
@@ -32,14 +32,14 @@ description: 发通知、布置作业、叫人、录成绩——把班级小助
|
||||
|
||||
附件支持站内相对路径或 `http(s)://` 链接(其他 scheme 服务端会拒,避免被当成脚本执行)。
|
||||
|
||||
学生端那边默认是**看板模式**:按科目分卡片、每条作业完整显示要求、整个看板缩放到刚好铺满屏幕;
|
||||
ClassHelper 班级端那边默认是**看板模式**:按科目分卡片、每条作业完整显示要求、整个看板缩放到刚好铺满屏幕;
|
||||
老师也可以在教室机器上直接**录入作业**、勾选**未交名单**(教室机器用的是班级账号)。
|
||||
|
||||
## 叫一位同学
|
||||
|
||||
**通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。
|
||||
|
||||
| 级别 | 学生端 |
|
||||
| 级别 | ClassHelper 班级端 |
|
||||
| -------- | ------------------------------------------------------------------ |
|
||||
| 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 |
|
||||
| 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
|
||||
@@ -53,11 +53,11 @@ description: 发通知、布置作业、叫人、录成绩——把班级小助
|
||||
**成绩录入** → 选班级与考试名称 → 单条录入 / 批量录入 / 导入表格。
|
||||
|
||||
- **科目**下拉同样是全校统一目录,选一个该班还没有的科目会自动建课。
|
||||
- 导入表格支持 `.xlsx / .xls / .csv / .tsv`,列名按同义词自动映射(「学号→学生用户名」「得分→分数」),
|
||||
- 导入表格支持 `.xlsx / .xls / .csv / .tsv`,列名按同义词自动映射(「用户名→学号」「得分→分数」),
|
||||
也可以手动改。**文件上限 8MB**,超了会返回明确的 413 而不是一句英文的内部错误。
|
||||
- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。
|
||||
|
||||
学生端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
|
||||
ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
|
||||
|
||||
## 在教室大屏上播一条提醒(需要 ClassIsland)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: 快速上手
|
||||
description: 从零把班级小助手跑起来:装服务端、认管理端、建班级排课、装学生端、接 ClassIsland、发第一条内容。
|
||||
description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、接 ClassIsland、发第一条内容。
|
||||
---
|
||||
|
||||
# 快速上手
|
||||
|
||||
班级小助手不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**:
|
||||
ClassHelper不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**:
|
||||
|
||||
| 组成部分 | 装在哪 | 谁用 | 必须先装? |
|
||||
| ------------------------ | --------------------------------------- | -------------------------- | ---------------- |
|
||||
@@ -20,7 +20,7 @@ description: 从零把班级小助手跑起来:装服务端、认管理端、
|
||||
2. [安装服务端](server.md) —— Windows 安装包 / Linux 一键脚本 / Docker / 源码跑
|
||||
3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面
|
||||
4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提
|
||||
5. [装学生端](client.md) —— 教室机器用班级码登录
|
||||
5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录
|
||||
6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选)
|
||||
7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 系统要求
|
||||
description: 班级小助手服务端、Web 管理端、桌面客户端的运行环境与网络要求。
|
||||
description: ClassHelper 服务端、Web 管理端、桌面客户端的运行环境与网络要求。
|
||||
---
|
||||
|
||||
# 系统要求
|
||||
@@ -38,7 +38,7 @@ ldd --version | head -1
|
||||
| 屏幕 | 桌面 1280×720 以上;手机(≤768px)有一套独立的抽屉式布局,同样可用 |
|
||||
| 安装 PWA | 手机 / 桌面 Chrome 里「添加到主屏幕」可当 App 用(需要 HTTPS 或 localhost) |
|
||||
|
||||
## 桌面客户端(学生端 / 教室机器)
|
||||
## 桌面客户端(ClassHelper 班级端 / 教室机器)
|
||||
|
||||
| 项目 | 要求 |
|
||||
| -------- | ---------------------------------------------------------------------------- |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 安装服务端
|
||||
description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把班级小助手服务端跑起来。
|
||||
description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把 ClassHelper 服务端跑起来。
|
||||
---
|
||||
|
||||
# 安装服务端
|
||||
@@ -21,7 +21,7 @@ description: 用 Windows 安装包、Linux 一键脚本、Docker 或源码把班
|
||||
### 生成安装包
|
||||
|
||||
安装包不入库(体积大),从 [Releases](https://github.com/LoveChengke/classhelper/releases) 直接下载
|
||||
`班级小助手服务端-<版本>-x64-setup.exe` 即可。要自己打:
|
||||
`ClassHelper 服务端-<版本>-x64-setup.exe` 即可。要自己打:
|
||||
|
||||
```bash
|
||||
pnpm dist:server # 首次会执行一次 npm install(约 2 分钟)
|
||||
@@ -32,7 +32,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
| `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 |
|
||||
|
||||
包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env`
|
||||
@@ -43,7 +43,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
|
||||
双击安装程序即可,**不需要管理员权限**(当前用户级安装)。
|
||||
|
||||
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
||||
- 静默安装:`"班级小助手服务端-<版本>-x64-setup.exe" /S`
|
||||
- 静默安装:`"ClassHelper 服务端-<版本>-x64-setup.exe" /S`
|
||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||
- 自动加入当前用户开机自启
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Web 管理端 → **班级管理** → 「新建班级」:
|
||||
| 班级码 | 保存时自动生成(字母数字 4–16 位,存大写),可以改成好记的,例如 `G203` |
|
||||
| 班级密码 | 新建班级时的初始密码就是它 —— 学生在教室机器上要用这个登录 |
|
||||
|
||||
> **班级码 + 班级密码 = 学生端的登录凭据。** 学生端没有个人账号,一台教室机器用班级账号登录后,
|
||||
> **班级码 + 班级密码 = ClassHelper 班级端的登录凭据。** ClassHelper 班级端没有个人账号,一台教室机器用班级账号登录后,
|
||||
> 那台机器上的「已读 / 完成」是**代全班**操作的。
|
||||
>
|
||||
> 学期周数只有管理员能改:它决定"现在是第几教学周",乱改会让课表的周次过滤和上课时段判定错位。
|
||||
@@ -30,7 +30,7 @@ Web 管理端 → **班级管理** → 「新建班级」:
|
||||
**学生管理** → 「新建」或「导入名单」:
|
||||
|
||||
- **单个新建**:填姓名即可,登录名会自动生成。
|
||||
- **导入名单**:下载模板(列只有 `用户名 / 姓名`,学生没有账号属性,**没有密码列**),填好上传。
|
||||
- **导入名单**:下载模板(列是 `学号 / 姓名 / 班级 / 性别 / 家长手机号`,学生**不是账号**,没有密码列),填好上传。
|
||||
用户名要匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复用户名在 `append` 模式下跳过、`upsert` 模式下更新姓名。
|
||||
用户名已经属于某个教师 / 管理员账号时,这一行会进错误列表,不会覆盖别人的账号。
|
||||
|
||||
@@ -39,7 +39,7 @@ Web 管理端 → **班级管理** → 「新建班级」:
|
||||
## 3. 加科任老师
|
||||
|
||||
教师的**录入**只有管理员能做(**教师管理**页,支持单个录入与导入名单)。班级层面的**分配**在班级详情里:
|
||||
把老师加进「协作教师」,他就能看到这个班、给这个班发作业 / 通知 / 叫人。
|
||||
在「班级详情 → 任课老师」里给某位老师配一门课,他就能看到这个班,并**只能改自己任教科目**的作业与成绩。
|
||||
|
||||
角色与可见范围见[班级、学生与教师](../app/classes.md)与[角色与权限](../dev/permissions.md)。
|
||||
|
||||
@@ -62,10 +62,10 @@ Web 管理端 → **班级管理** → 「新建班级」:
|
||||
|
||||
## 5. 确认「上课时段」判定是对的
|
||||
|
||||
班级小助手有一整套「上课时段策略」:普通通知在上课时段只进队列、下课自动补弹,紧急通知与叫人则立刻弹。
|
||||
ClassHelper有一整套「上课时段策略」:普通通知在上课时段只进队列、下课自动补弹,紧急通知与叫人则立刻弹。
|
||||
这套判定完全基于课表,所以**课表排错,提醒就会在该弹的时候不弹**。
|
||||
|
||||
排完课之后建议立刻验一下:在**通知发布**页选这个班,随便发一条普通通知,
|
||||
看客户端是不是在课间弹出来、上课时是不是安静地收着。规则详见[通知与提醒](../app/notifications.md)。
|
||||
|
||||
下一步:[装学生端](client.md)。
|
||||
下一步:[装 ClassHelper 班级端](client.md)。
|
||||
@@ -8,7 +8,7 @@
|
||||
href: web-admin.md
|
||||
- name: 建班级、排课表
|
||||
href: setup-class.md
|
||||
- name: 装学生端
|
||||
- name: 装 ClassHelper 班级端
|
||||
href: client.md
|
||||
- name: 接入 ClassIsland
|
||||
href: classisland.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 认识 Web 管理端
|
||||
description: 登录班级小助手 Web 管理端,认一认 12 个页面、新手引导与手机端布局。
|
||||
description: 登录 ClassHelper Web 管理端,认一认 12 个页面、新手引导与手机端布局。
|
||||
---
|
||||
|
||||
# 认识 Web 管理端
|
||||
|
||||
@@ -12,7 +12,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、14 张表的行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、16 张表的行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接测试 | 对任意目标库试连(切换前的第一步) |
|
||||
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
|
||||
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
|
||||
@@ -23,7 +23,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
|
||||
|
||||
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
|
||||
|
||||
- 14 张表按**拓扑序**导出;
|
||||
- 16 张表按**拓扑序**导出;
|
||||
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
|
||||
是**互相引用**的,任何排序都会被其中一侧卡死;
|
||||
- 分批 `createMany` 写入。
|
||||
|
||||
@@ -285,7 +285,7 @@ classhelper logs --unit classhelper-backup # 看定时备份的日志
|
||||
sudo classhelper password # 改 admin
|
||||
sudo classhelper password teacher1 # 改指定教师
|
||||
sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码)
|
||||
sudo classhelper password --class G101 # 改某个班的班级密码(学生端登录用)
|
||||
sudo classhelper password --class G101 # 改某个班的班级密码(ClassHelper 班级端登录用)
|
||||
```
|
||||
|
||||
安全约定(照要求逐条落实):
|
||||
@@ -432,7 +432,7 @@ sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
|
||||
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
|
||||
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
|
||||
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
|
||||
| 学生端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
|
||||
| ClassHelper 班级端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
|
||||
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
|
||||
|
||||
---
|
||||
@@ -527,7 +527,7 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
|
||||
|
||||
```bash
|
||||
# ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录
|
||||
# ② 学生端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
|
||||
# ② ClassHelper 班级端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
|
||||
# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包>
|
||||
# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线)
|
||||
# ④ 卸载:sudo classhelper uninstall --yes
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 形态 | 适用场景 | 产物 | 本机验证情况 |
|
||||
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
|
||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
||||
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
|
||||
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
|
||||
@@ -25,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
| 路径 | 说明 |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
|
||||
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||
@@ -35,9 +35,9 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
双击安装程序即可,**无需管理员权限**(当前用户级安装):
|
||||
|
||||
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
||||
- 静默安装:`"班级小助手服务端-1.1.2-x64-setup.exe" /S`
|
||||
- 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S`
|
||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||
- 桌面:`班级小助手服务端.lnk`(打开管理端)
|
||||
- 桌面:`ClassHelper 服务端.lnk`(打开管理端)
|
||||
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
||||
- 卸载信息注册到「应用和功能」
|
||||
|
||||
@@ -314,7 +314,7 @@ start.cmd :: Windows
|
||||
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
|
||||
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
|
||||
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
|
||||
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 |
|
||||
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(ClassHelper 班级端登录)的默认密码 |
|
||||
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
|
||||
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
|
||||
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
|
||||
|
||||
@@ -107,7 +107,7 @@ docker compose up -d
|
||||
|
||||
日志里会明确写出「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。
|
||||
|
||||
### 客户端(学生端)
|
||||
### 客户端(ClassHelper 班级端)
|
||||
|
||||
从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。
|
||||
|
||||
|
||||
+114
-34
@@ -1,7 +1,7 @@
|
||||
# ClassHelper · 完整参考
|
||||
|
||||
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
|
||||
**桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.1.2**。
|
||||
**桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.2.0**。
|
||||
|
||||
核心链路:
|
||||
|
||||
@@ -175,23 +175,28 @@ pnpm verify:desktop
|
||||
|
||||
### 默认账号与种子数据
|
||||
|
||||
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,**2026-09-30 实测**:
|
||||
用户 18(1 管理员 + 2 教师 + 15 名学生名单)/ 班级 3 / 课程 54(每班 18 科)/ 课表 93 / 作业 15 / 通知 12 / 成绩 90):
|
||||
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,**2026-10-06 实测**:
|
||||
账号 3 / 在读班级 3 / 学生 15 / 课程 54(每班 18 科)/ 课表 93 / 作业 16 / 通知 13 / 成绩 90 /
|
||||
调班历史 2 / 已归档届别 1):
|
||||
|
||||
| 角色 | 用户名 | 密码 | 说明 |
|
||||
| ------ | ------------- | ------------ | ------------------------------------------ |
|
||||
| 管理员 | `admin` | `admin123` | 可访问全部班级 |
|
||||
| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 |
|
||||
| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 |
|
||||
| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**ClassHelper 班级端唯一登录方式**) |
|
||||
| 班级 | 班级码 `G102` | `123456` | 高一(2)班 |
|
||||
| 班级 | 班级码 `G203` | `123456` | 高二(3)班(不是 `G103`) |
|
||||
| 角色 | 凭据 | 密码 | 说明 |
|
||||
| ----------------------- | ------------- | ------------ | ----------------------------------------------------------- |
|
||||
| 管理员 | `admin` | `admin123` | 可访问全部班级、可执行毕业归档 / 学年升级 / 学期周次 |
|
||||
| 教师 | `teacher1` | `teacher123` | 张老师:`2026级1班` 班主任,兼该班数学 |
|
||||
| 教师 | `teacher2` | `teacher123` | 李老师:`2026级2班` 班主任,兼 `2026级1班` 语文 |
|
||||
| ClassHelper 班级端 | 班级码 `G101` | `123456` | `2026级1班`(**教室机器唯一的登录方式**) |
|
||||
| ClassHelper 班级端 | 班级码 `G102` | `123456` | `2026级2班` |
|
||||
| ClassHelper 班级端 | 班级码 `G203` | `123456` | `2025级3班`(不是 `G103`) |
|
||||
|
||||
- 密码由**种子脚本硬编码**(`admin123` / `teacher123` / 班级 `123456`);`.env` 的
|
||||
`DEFAULT_CLASS_PASSWORD` 只决定**新建**班级时的初始班级密码。
|
||||
- **学生没有个人账号**(2026-10-01 起彻底清理):学生只是名单记录(成绩 / 未交名单 / 已读人数 / 叫人都按名单),
|
||||
没有用户名密码、不能登录;ClassHelper 班级端统一用班级码 + 班级密码。
|
||||
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。
|
||||
- **学生不是账号**(2026-10-06 起由表结构保证):`Student` 表里没有 `passwordHash` / `role`,
|
||||
学号(`202601`…`202615`)只是名单记录与查询键 —— 拿学号去登录只会得到 401。
|
||||
- 任课关系刻意让**语文**由另一位老师任教,所以班主任在语文上没有编辑权,
|
||||
正好覆盖「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
|
||||
- 演示数据里还有一个**已归档**的 `2023级1班`(3 名毕业生),用于验证「毕业归档」页。
|
||||
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算;管理员可在「学期周次」页逐周覆盖它,
|
||||
见[学期周次](#412-学期周次可逐周覆盖仅管理员)。
|
||||
|
||||
### 常用命令
|
||||
|
||||
@@ -283,7 +288,7 @@ Element Plus 也通过对齐它的 `--el-border-radius-*` 一并改造:
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **教师管理**(独立页面) | 单个录入(姓名 / 用户名 / 初始密码 / 角色)+导入名单(xlsx/csv)+编辑(改名 / 改角色)+修改密码+删除 |
|
||||
| **教师管理**(独立页面) | 单个录入(姓名 / **工号** / 手机号 / 初始密码 / 角色)+导入名单(xlsx/csv)+编辑(改名 / 改工号 / 改角色)+修改密码+删除 |
|
||||
| 录入教师仅管理员 | 页面只对 `ADMIN` 显示,路由 `meta.roles=['ADMIN']`;服务端 `/api/teachers` 全量接口 `requireRole('ADMIN')`(教师调用 403) |
|
||||
| 删除教师有护栏 | 四种情况一律 409:还是**班主任**、名下有课程 / 班级分配、**发布过作业或通知**(后两项也是级联删除,删账号会连带删掉他在各班发过的内容) |
|
||||
| 创建班级仅管理员 | `POST /api/classes` = `requireRole('ADMIN')`,教师(含班主任)403 |
|
||||
@@ -608,27 +613,30 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
||||
界面:Web 管理端 →「成绩录入」→「导入表格」(`components/TableImportDialog.vue`)。
|
||||
|
||||
1. **下载模板**:`GET /imports/template?kind=grades&format=xlsx|csv`
|
||||
模板列:`学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程`(CSV 带 BOM,Excel 直接双击不乱码)。
|
||||
模板列:`学生学号 / 学生姓名 / 考试名称 / 分数 / 总分 / 科目 / 等级`(CSV 带 BOM,Excel 直接双击不乱码)。
|
||||
2. **上传预览**:`POST /imports/table/preview` → 列名、前 20 行、`totalRows`、**建议字段映射**与校验问题。
|
||||
- 支持 `.xlsx / .xls / .csv / .tsv`(SheetJS,自动识别分隔符),**表格文件上限 8MB**;
|
||||
文件以 base64 放进 JSON,请求体上限设为 12MB 才能让 8MB 文件真正传得进来;超限返回 413 `IMPORT_TOO_LARGE`。
|
||||
- 列名按同义词自动映射(如「学号→学生用户名」「得分→分数」「满分→总分」),也可手动改;
|
||||
- 列名按同义词自动映射(如「用户名→学号」「得分→分数」「满分→总分」「课程→科目」),也可手动改;
|
||||
缺必填列不会静默通过,直接给出「缺少必填列「…」」提示。
|
||||
3. **确认导入**:`POST /imports/table/commit`,参数 `mapping`(规范字段 → 列名)+ `mode`:
|
||||
- `upsert`(默认)已存在则更新;`append` 已存在则跳过;
|
||||
- 成绩重复判定:**同班级 + 同学生 + 同考试 + 同课程**;学生按「用户名」优先、「姓名」兜底匹配;
|
||||
- 成绩重复判定:**同班级 + 同学生 + 同考试 + 同科目**;学生按「学号」优先、「姓名」兜底匹配;
|
||||
- 返回 `{ total, inserted, updated, skipped, failed, errors[], warnings[] }`,
|
||||
`errors[].row` 是 **Excel 视角的行号**(含表头),便于老师改完再导。
|
||||
|
||||
#### 2)学生名单导入(管理员)
|
||||
|
||||
界面:Web 管理端 →「学生管理」→「导入名单」(同一组件,`kind=students`)。模板列 `用户名 / 姓名`
|
||||
(学生没有账号属性,**没有密码列**);用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$`;重复按同一用户名
|
||||
(`append` 跳过 / `upsert` 更新姓名与班级);用户名被教师 / 管理员占用时该行进错误行,不会覆盖他人账号。
|
||||
界面:Web 管理端 →「学生管理」→「导入名单」(同一组件,`kind=students`)。模板列
|
||||
`学号 / 姓名 / 班级 / 性别 / 家长手机号`(学生不是账号,**没有密码列**);学号必须匹配
|
||||
`^[A-Za-z0-9._-]{1,32}$`(入库前去掉全部空白并统一大写,所以唯一约束天然大小写无关);
|
||||
重复按同一学号(`append` 跳过 / `upsert` 更新姓名、班级与联系方式);
|
||||
表格里带「班级」列时按班级名解析(重名会报错让老师改用班级码),否则用弹窗里选的班级。
|
||||
**只写 `Student` 名单,不创建任何账号。**
|
||||
|
||||
#### 3)教师名单导入(管理员)
|
||||
|
||||
界面:Web 管理端 →「教师管理」→「导入名单」(`kind=teachers`)。模板列 `用户名 / 姓名 / 初始密码 / 角色`;
|
||||
界面:Web 管理端 →「教师管理」→「导入名单」(`kind=teachers`)。模板列 `工号 / 姓名 / 手机号 / 初始密码 / 角色`;
|
||||
角色取 `ADMIN` / `TEACHER`(留空按 `TEACHER`);与新建教师同一套校验,导入后可直接登录。
|
||||
|
||||
#### 4)ClassIsland 时间配置导入(管理员 / 本班班主任)
|
||||
@@ -757,14 +765,14 @@ Web 管理端「数据库管理」页(`/database`,仅 ADMIN 可见,后端
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、14 张表行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、16 张表行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接测试 | 任意目标库的连通性试连(切换前的第一步) |
|
||||
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 |
|
||||
| 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
|
||||
| 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) |
|
||||
| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) |
|
||||
|
||||
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,14 张表按拓扑序导出,
|
||||
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,16 张表按拓扑序导出,
|
||||
恢复时临时关外键检查 —— `User.classId` 与 `Class.teacherId` 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。
|
||||
MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,不再是「切换时手动安装」)。
|
||||
|
||||
@@ -818,6 +826,72 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
||||
|
||||
## 5. 技术参考
|
||||
|
||||
### 4.11 毕业归档(仅管理员)
|
||||
|
||||
按**年度**把毕业的班级与学生归档,并留档当年转出的学生、以及毕业班级发过的作业与通知。
|
||||
入口:Web 管理端 →「毕业归档」。
|
||||
|
||||
**归档 = 打标记 + 只读,不删任何数据**:
|
||||
|
||||
| 动作 | 结果 |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| 归档一个届别 | 该届班级从常规列表隐去(`Class.archivedYearId`)、在读学生标记为「已毕业」(`Student.archivedYearId`) |
|
||||
| 作业与通知 | **原样留在库里**,在「届别详情 → 看作业与通知」里只读浏览 |
|
||||
| 撤销归档 | 班级与学生回到在读状态;同样不删任何数据(转出的学生保持转出 —— 那是事实,不是归档造成的) |
|
||||
| 班级端登录 | 归档班级的教室机器**不能再登录**(403 `CLASS_ARCHIVED`),继续登进去只会看到一个只读的壳子 |
|
||||
|
||||
**「未毕业而升级的班级不归档」**:学年升级在「班级管理 → 班级设置 → 学年升级」里做,
|
||||
**只改 `Class.grade`**(高一 → 高二),班级记录、学生名单、学号、作业与成绩全部沿用。
|
||||
|
||||
**班级称呼是「XXXX级X班」(入学年份 + 班号)**,不是「高一(1)班」—— 后者每年都要改一遍,
|
||||
前者一路跟着这届学生到毕业。称呼由服务端按 `enrollmentYear` + `classIndex` 生成,表单里只填这两个数。
|
||||
|
||||
**调班 / 转出**(Web 管理端 →「学生管理」):
|
||||
|
||||
- **调班**(单个 / 批量同一接口):只改学生**当前班级**,**学号不变**、不创建账号;
|
||||
原班级 / 新班级 / 操作人 / 时间记入 `StudentClassTransfer`,可查;
|
||||
历史作业与成绩保留原归属(它们挂在各自的 `classId` 上,不随学生走)。
|
||||
- **转出**:学籍离开本校(不是调班)。班级归属保留作历史,状态置为 `transferred`,
|
||||
并写一条 `mode='transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
|
||||
|
||||
### 4.12 学期周次(可逐周覆盖,仅管理员)
|
||||
|
||||
入口:Web 管理端 →「学期周次」。
|
||||
|
||||
课表、作业、成绩都按**教学周**组织,而「开学日期 + 每周七天」的线性推算遇到法定节假日调休、
|
||||
周末补课、错峰开学就会**整体错位**(错一周全错)。所以支持逐周指定实际日期:
|
||||
|
||||
| 操作 | 说明 |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| 选配置范围 | 留空 = **全校默认**;也可以挑某个班单独配(该班优先用自己的) |
|
||||
| 学期开始日期 | 第 1 教学周的周一;填完点「按开学日期生成」得到一份标准学期 |
|
||||
| 逐周日期区间 | 用日期范围选择器改任意一周(调休那一周拉长 / 开学前那几天并进第 1 周…) |
|
||||
| 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** |
|
||||
| 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** |
|
||||
|
||||
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`;
|
||||
- 判定:`lib/term.ts` 的 `loadTermContext()` 做「班级覆盖 → 全校默认 → 纯线性」三级回落,
|
||||
`resolveCurrentWeek(now, context)` 优先按区间判定;
|
||||
- 机房/服务器**没有外网时,"拉取放假安排"返回 `ok:false` 是正常结果**(不是错误),
|
||||
失败结果同样进缓存,避免断网点一次要白等一个超时。
|
||||
|
||||
### 4.13 课表同步方向(服务端 → 教室)
|
||||
|
||||
2026-10-06 起,课表以**服务端为准**:
|
||||
|
||||
| 方向 | 开关 | 默认 | 谁触发 |
|
||||
| --------------------- | --------------------------------- | ------ | ------------------------------------------ |
|
||||
| 服务端 → 教室(下发) | `mirrorScheduleToClassIsland` | **开** | 老师改完课表自动镜像回教室 |
|
||||
| 教室 → 服务端(上传) | `syncScheduleToServer` | **关** | 老师在 Web 端点「**从教室机器获取课表**」 |
|
||||
|
||||
为什么要反过来:自动回传会在老师手排课之后被教室的旧课表**悄悄覆盖**。
|
||||
所以「取教室课表」改成一次显式的人工动作 —— 点击前会弹**警告对话框**说明会覆盖 / 合并本班课表,
|
||||
确认后服务端置一个待办(`IntegrationDevice.requestScheduleReport`),
|
||||
插件在下一次心跳(约 1 分钟)看到就把当前课表推上来并清除标记。
|
||||
|
||||
> 插件侧对应 `ReportSettingsDto.scheduleRequested` 与 `ReportAsync(..., forceSchedule: true)`;
|
||||
> 补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)。
|
||||
|
||||
### 5.1 目录结构
|
||||
|
||||
```
|
||||
@@ -939,7 +1013,7 @@ class-helper/
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- |
|
||||
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) |
|
||||
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
|
||||
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 |
|
||||
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
|
||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
|
||||
@@ -994,7 +1068,7 @@ class-helper/
|
||||
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
|
||||
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
|
||||
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 14 张表行数 / 备份列表 / 定时配置) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
|
||||
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
|
||||
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
|
||||
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
|
||||
@@ -1039,11 +1113,11 @@ class-helper/
|
||||
(`packages/shared/src/permissions.ts`,后端 `lib/access.ts` 复用,前端用它隐藏入口):
|
||||
|
||||
| 角色 | 判定方式 | 可见范围 |
|
||||
| ------------------ | ------------------------------ | -------------------------- |
|
||||
| --------------------- | ---------------------------------------- | -------------------------- |
|
||||
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
|
||||
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
|
||||
| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 |
|
||||
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) |
|
||||
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
|
||||
| `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录 | 本班(代全班读写个人数据) |
|
||||
|
||||
**权限矩阵**(`PERMISSION_MATRIX`,验收脚本按此逐条断言):
|
||||
|
||||
@@ -1118,15 +1192,21 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
||||
|
||||
> MySQL 驱动适配器 `@prisma/adapter-mariadb` **已随包内置**(数据库管理模块的连接测试 / 迁移需要),无需手动安装。
|
||||
|
||||
数据模型共 **14 张表**:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` / `Homework` /
|
||||
`HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` / `IntegrationDevice` / `ClassIslandPush`。
|
||||
其中 `Class.code` / `Class.passwordHash` 是班级账号字段;`Class.notificationChannel` 决定提醒弹在哪端;
|
||||
数据模型共 **16 张表**:`User`(纯账号表)/ `Student`(名单,不是账号)/ `StudentClassTransfer`(调班与转出历史)/
|
||||
`ArchivedYear`(毕业归档届别)/ `Class` / `TermWeek`(学期周次区间)/ `Course`(= 班级 + 科目 + 教师的任课关系)/
|
||||
`Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` /
|
||||
`IntegrationDevice` / `ClassIslandPush`。
|
||||
其中 `Class.name` 是「2026级1班」这种**届别称呼**;`Class.code` / `Class.passwordHash` 是班级端账号字段;
|
||||
`Class.notificationChannel` 决定提醒弹在哪端;`Class.studentGradeQueryEnabled` 是班级端查成绩的开关;
|
||||
`Student.studentNo` 是学生的唯一标识与查询键;`Grade.levelType` / `level` 是等级口径与等级;
|
||||
`Homework.assignDate` 是作业的「所属日期」;`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源;
|
||||
`ClassIslandPush.kind` 区分通知类与叫人。
|
||||
|
||||
迁移历史(**10 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` →
|
||||
迁移历史(**16 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` →
|
||||
`add_class_term_weeks` → `add_classisland_integration` → `add_class_notification_channel` →
|
||||
`add_homework_assign_date` → `add_push_kind` → `add_schedule_source`。
|
||||
`add_homework_assign_date` → `add_push_kind` → `add_schedule_source` →
|
||||
`add_student_table` → `add_student_grade_query` → `add_grade_level` →
|
||||
`add_classhelper_heartbeat` → `add_archives` → `add_term_weeks`。
|
||||
|
||||
**启动时迁移(安装版自动升级)**:`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名 ——
|
||||
全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过),
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* 班级小助手文档站 —— docfx modern 模板的样式覆盖。
|
||||
* ClassHelper文档站 —— docfx modern 模板的样式覆盖。
|
||||
*
|
||||
* docfx 按 `template` 数组的顺序合并模板,这个文件排在最后,因此覆盖掉 modern 模板自带的
|
||||
* `public/main.css`(它本身是空的,被留作官方覆盖点)。docfx.min.css 在本文件之前加载,
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "class-helper",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# 班级小助手联动插件
|
||||
# ClassHelper 联动插件
|
||||
|
||||
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
| 方向 | 说明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
| ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
|
||||
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
|
||||
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。
|
||||
3. 打开「设置 → 班级小助手联动」,填入:
|
||||
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
|
||||
3. 打开「设置 → ClassHelper 联动」,填入:
|
||||
- **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
||||
|
||||
## 配置项(全部在「设置 → 班级小助手联动」里)
|
||||
## 配置项(全部在「设置 → ClassHelper 联动」里)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
||||
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
||||
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
||||
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
||||
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
||||
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
||||
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
||||
@@ -48,20 +48,20 @@
|
||||
|
||||
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
|
||||
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
|
||||
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||
> 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||
|
||||
> **两个方向各自需要的开关**:
|
||||
>
|
||||
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
||||
|
||||
> **提醒到底会不会弹,取决于两个开关:**
|
||||
>
|
||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
||||
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
|
||||
> 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
|
||||
>
|
||||
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
||||
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
||||
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
||||
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
||||
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
||||
@@ -85,7 +85,7 @@
|
||||
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
||||
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
|
||||
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
||||
|
||||
## 上机联调记录(ClassIsland 2.1.0.1 实测)
|
||||
@@ -129,7 +129,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
|
||||
## 常见问题
|
||||
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)**
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
|
||||
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
|
||||
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
|
||||
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
|
||||
@@ -151,7 +151,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
|
||||
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
||||
|
||||
**课表上报了但班级小助手上没变化**
|
||||
**课表上报了但 ClassHelper上没变化**
|
||||
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
||||
|
||||
**镜像开关打开了,但 ClassIsland 的课表没变**
|
||||
@@ -160,4 +160,4 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
|
||||
## 许可
|
||||
|
||||
与班级小助手仓库一致。
|
||||
与 ClassHelper仓库一致。
|
||||
@@ -1,19 +1,19 @@
|
||||
# 班级小助手 ClassIsland 联动插件 —— 清单文件
|
||||
# ClassHelper ClassIsland 联动插件 —— 清单文件
|
||||
#
|
||||
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
||||
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
||||
|
||||
id: classhelper.classisland.bridge
|
||||
name: 班级小助手联动
|
||||
name: ClassHelper 联动
|
||||
description: |-
|
||||
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
|
||||
把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
|
||||
功能:
|
||||
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
|
||||
1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
|
||||
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
||||
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 1.1.2.0
|
||||
version: 1.2.0.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
author: ClassHelper
|
||||
@@ -101,7 +101,7 @@ check(
|
||||
|
||||
check(
|
||||
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
|
||||
);
|
||||
check(
|
||||
'设置页 XAML 的 x:Class 与代码后置类一致',
|
||||
@@ -255,6 +255,34 @@ check(
|
||||
);
|
||||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||||
|
||||
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
|
||||
check(
|
||||
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
|
||||
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
|
||||
);
|
||||
check(
|
||||
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
|
||||
/scheduleRequested/.test(clientSource),
|
||||
);
|
||||
check(
|
||||
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
||||
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
|
||||
/forceSchedule:\s*true/.test(bridgeSource),
|
||||
);
|
||||
check(
|
||||
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
|
||||
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
|
||||
);
|
||||
check(
|
||||
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
||||
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
|
||||
);
|
||||
check(
|
||||
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
|
||||
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
|
||||
);
|
||||
|
||||
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
||||
check(
|
||||
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
||||
@@ -286,7 +314,7 @@ check(
|
||||
);
|
||||
check(
|
||||
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
||||
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'),
|
||||
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
|
||||
);
|
||||
check(
|
||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||
@@ -330,7 +358,7 @@ check(
|
||||
);
|
||||
check(
|
||||
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
|
||||
clientSource.includes('响应不是班级小助手的标准格式') &&
|
||||
clientSource.includes('响应不是 ClassHelper的标准格式') &&
|
||||
clientSource.includes('MaxResponseContentBufferSize'),
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -80,6 +80,14 @@ public class ReportSettingsDto
|
||||
{
|
||||
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
|
||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
|
||||
///
|
||||
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
|
||||
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
|
||||
/// </summary>
|
||||
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
|
||||
}
|
||||
|
||||
public class ReportResultDto
|
||||
@@ -157,10 +165,32 @@ public class ClassPlanMirrorDto
|
||||
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
|
||||
public class TermWeekRangeDto
|
||||
{
|
||||
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
|
||||
/// <summary>YYYY-MM-DD</summary>
|
||||
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
|
||||
/// <summary>YYYY-MM-DD</summary>
|
||||
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
|
||||
}
|
||||
|
||||
public class PullResultDto
|
||||
{
|
||||
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
|
||||
///
|
||||
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
|
||||
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
|
||||
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
|
||||
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
|
||||
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
|
||||
/// </summary>
|
||||
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -174,7 +204,7 @@ internal class ApiEnvelope<T>
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
public class ApiCallResult
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
@@ -187,7 +217,7 @@ public class ApiCallResult
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端客户端。
|
||||
/// ClassHelper后端客户端。
|
||||
///
|
||||
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
|
||||
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
|
||||
@@ -214,7 +244,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 与班级小助手通信的 HttpClient。
|
||||
/// 与 ClassHelper通信的 HttpClient。
|
||||
///
|
||||
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
|
||||
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
|
||||
@@ -335,7 +365,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
// 老师截图给我们就能一眼定位。
|
||||
message = envelope is null
|
||||
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
|
||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
|
||||
"或该系统代理是否拦截了内网请求"
|
||||
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
||||
}
|
||||
@@ -350,7 +380,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
|
||||
LastError =
|
||||
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||
return new ApiEnvelope<T> { Success = false, Message = LastError };
|
||||
}
|
||||
|
||||
@@ -365,7 +395,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
LastError = $"无法连接班级小助手服务:{exception.Message}";
|
||||
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -17,8 +17,11 @@ public class PluginSettings : ObservableObject
|
||||
private string _deviceKey = "";
|
||||
private bool _autoReport = true;
|
||||
private int _reportIntervalSeconds = 60;
|
||||
private bool _uploadSchedule = true;
|
||||
private bool _mirrorSchedule = false;
|
||||
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
|
||||
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
|
||||
private bool _uploadSchedule = false;
|
||||
private bool _mirrorSchedule = true;
|
||||
private bool _receiveNotifications = true;
|
||||
private int _notificationDurationSeconds = 8;
|
||||
private int _notificationPollSeconds = 10;
|
||||
@@ -26,7 +29,7 @@ public class PluginSettings : ObservableObject
|
||||
private bool _showDebugInfo = false;
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// </summary>
|
||||
public string ServerUrl
|
||||
{
|
||||
@@ -35,7 +38,7 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
|
||||
/// </summary>
|
||||
public string DeviceToken
|
||||
{
|
||||
@@ -74,8 +77,13 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 ClassIsland 的课表同步到班级小助手。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
|
||||
/// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
|
||||
///
|
||||
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
|
||||
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
|
||||
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
|
||||
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态。
|
||||
/// </summary>
|
||||
public bool UploadSchedule
|
||||
{
|
||||
@@ -84,8 +92,10 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「班级小助手-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
/// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「ClassHelper-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
///
|
||||
/// **默认开启**:这是现在课表同步的主方向。
|
||||
/// </summary>
|
||||
public bool MirrorSchedule
|
||||
{
|
||||
@@ -94,7 +104,7 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// </summary>
|
||||
public bool ReceiveNotifications
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ public class Plugin : PluginBase
|
||||
var application = AppBase.Current;
|
||||
if (application is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,14 +72,14 @@ public class Plugin : PluginBase
|
||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||
if (lessons is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
var bridge = IAppHost.TryGetService<BridgeService>();
|
||||
if (bridge is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ public class Plugin : PluginBase
|
||||
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
||||
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
||||
_ = bridge.PullPendingAsync();
|
||||
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 联动失败绝不能把 ClassIsland 本体拖下水
|
||||
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
|
||||
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ public class Plugin : PluginBase
|
||||
{
|
||||
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
}
|
||||
|
||||
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
||||
@@ -165,7 +165,7 @@ public class Plugin : PluginBase
|
||||
catch (Exception exception)
|
||||
{
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:保存设置失败({Path})", path);
|
||||
"ClassHelper 联动:保存设置失败({Path})", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
/// <summary>
|
||||
/// 联动主循环。三件事:
|
||||
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
|
||||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
|
||||
///
|
||||
/// 为什么用"上报即拉取"而不是长连接:
|
||||
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
|
||||
@@ -174,7 +174,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
|
||||
Raise(nameof(IsAttached));
|
||||
_logger.LogInformation("班级小助手联动:已订阅课程事件");
|
||||
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
|
||||
RequestReport("应用启动");
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/// 执行一次上报并返回是否真的发出去了。
|
||||
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
||||
/// </summary>
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
|
||||
{
|
||||
if (_disposed) return false;
|
||||
|
||||
@@ -253,17 +253,21 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var layoutCount = 0;
|
||||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||||
if (includeSchedule && !_settings.UploadSchedule)
|
||||
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
|
||||
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
|
||||
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
|
||||
|
||||
if (includeSchedule && !uploadSchedule)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到班级小助手请打开该开关");
|
||||
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
|
||||
}
|
||||
|
||||
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||
if (includeSchedule && uploadSchedule && profile is not null)
|
||||
{
|
||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
|
||||
entryCount = entries.Count;
|
||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||||
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
||||
@@ -272,8 +276,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
request.Schedule = null;
|
||||
_logger.LogWarning(
|
||||
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
|
||||
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
|
||||
}
|
||||
|
||||
var layout = ResolvePrimaryTimeLayout(profile);
|
||||
@@ -296,7 +300,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
.ConfigureAwait(false);
|
||||
if (!result.Ok || data is null)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
SetState(false, result.Message, entryCount, layoutCount, null);
|
||||
return false;
|
||||
}
|
||||
@@ -306,15 +310,15 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
if (data.ScheduleApplied)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
data.ScheduleCreated, data.ScheduleUpdated);
|
||||
}
|
||||
|
||||
var detail = entryCount > 0
|
||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||
: _settings.UploadSchedule
|
||||
: uploadSchedule
|
||||
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||||
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
|
||||
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
|
||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||
entryCount, layoutCount, data.Week);
|
||||
|
||||
@@ -325,6 +329,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
NotificationReceived?.Invoke(this, pending);
|
||||
}
|
||||
|
||||
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
|
||||
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
|
||||
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
|
||||
{
|
||||
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
|
||||
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
||||
}
|
||||
|
||||
if (_settings.MirrorSchedule)
|
||||
{
|
||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||
@@ -338,7 +350,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
|
||||
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
|
||||
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
|
||||
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason);
|
||||
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
|
||||
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
|
||||
return false;
|
||||
}
|
||||
@@ -369,13 +381,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.Ok)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
|
||||
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
NotificationReceived?.Invoke(this, item);
|
||||
}
|
||||
}
|
||||
@@ -392,16 +404,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
if (result.Ok)
|
||||
{
|
||||
NotificationCount += 1;
|
||||
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
|
||||
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||||
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
|
||||
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
public async Task MirrorScheduleAsync()
|
||||
{
|
||||
var profile = ResolveProfile();
|
||||
@@ -412,7 +424,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:跳过课表镜像({Message})。" +
|
||||
"ClassHelper 联动:跳过课表镜像({Message})。" +
|
||||
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||||
result.Message);
|
||||
return;
|
||||
@@ -423,7 +435,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var signature = MirrorSignature(plan);
|
||||
if (signature == _lastMirrorSignature)
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
|
||||
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,22 +463,22 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
|
||||
if (!outcome.Applied)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
|
||||
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastMirrorSignature = signature;
|
||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
||||
foreach (var warning in outcome.Warnings)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
|
||||
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "1.1.2.0";
|
||||
private const string PluginVersion = "1.2.0.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
@@ -524,13 +536,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
|
||||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
|
||||
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
|
||||
///
|
||||
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
|
||||
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
|
||||
@@ -578,7 +590,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
|
||||
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
|
||||
///
|
||||
/// 与 <see cref="BridgeService"/> 的分工:
|
||||
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
||||
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
||||
/// 全屏特效 / 语音 / 音效那一整套流程。
|
||||
///
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
|
||||
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
||||
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
||||
/// </summary>
|
||||
@@ -27,9 +27,9 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
||||
[NotificationProviderInfo(
|
||||
ProviderGuidValue,
|
||||
"班级小助手提醒",
|
||||
"ClassHelper提醒",
|
||||
"\uE8BD",
|
||||
"显示老师从班级小助手下发的提醒。")]
|
||||
"显示老师从 ClassHelper下发的提醒。")]
|
||||
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
/// <summary>
|
||||
@@ -100,11 +100,11 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessonsHooked = true;
|
||||
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
|
||||
var pending = _deferred.ToList();
|
||||
_deferred.Clear();
|
||||
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
foreach (var item in pending) ShowOnUiThread(item);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
if (!_playing.Add(notification.Id))
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
if (IsInClass() && !IsImmediate(notification))
|
||||
{
|
||||
_deferred.Add(notification);
|
||||
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||
AckWhenFinished(request, notification);
|
||||
ShowNotification(request);
|
||||
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
notification.Title, notification.DurationSeconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -185,7 +185,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
||||
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
||||
lock (_playing) _playing.Remove(notification.Id);
|
||||
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
|
||||
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
var duration = TimeSpan.FromSeconds(seconds);
|
||||
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title;
|
||||
var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
|
||||
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
|
||||
|
||||
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
|
||||
@@ -240,7 +240,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
|
||||
}
|
||||
|
||||
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
|
||||
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
|
||||
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
|
||||
{
|
||||
void Handler(object? sender, EventArgs args)
|
||||
|
||||
@@ -5,10 +5,10 @@ using ClassIsland.Shared.Models.Profile;
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
|
||||
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
|
||||
///
|
||||
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
||||
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
|
||||
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
|
||||
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
|
||||
///
|
||||
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
|
||||
@@ -25,10 +25,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
public static class ClassPlanWriter
|
||||
{
|
||||
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
|
||||
public const string PlanNamePrefix = "班级小助手-";
|
||||
public const string PlanNamePrefix = "ClassHelper-";
|
||||
|
||||
/// <summary>时间表名字后缀。</summary>
|
||||
public const string TimeLayoutNameSuffix = "(班级小助手)";
|
||||
public const string TimeLayoutNameSuffix = "(ClassHelper)";
|
||||
|
||||
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
|
||||
public sealed class ApplyResult
|
||||
@@ -146,7 +146,7 @@ public static class ClassPlanWriter
|
||||
{
|
||||
result.Warnings.Add(
|
||||
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
||||
"两者重叠时以班级小助手同步的课表为准");
|
||||
"两者重叠时以 ClassHelper同步的课表为准");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ public static class ClassPlanWriter
|
||||
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
|
||||
///
|
||||
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
|
||||
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
|
||||
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
|
||||
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
|
||||
/// </summary>
|
||||
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
||||
@@ -226,7 +226,7 @@ public static class ClassPlanWriter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 课表名:单组时用「班级小助手-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// 课表名:单组时用「ClassHelper-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// </summary>
|
||||
private static string BuildPlanName(
|
||||
string profileName,
|
||||
@@ -235,7 +235,7 @@ public static class ClassPlanWriter
|
||||
int weekCountDivTotal,
|
||||
int groupCount)
|
||||
{
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
|
||||
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
baseName = PlanNamePrefix + baseName;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
/// <summary>
|
||||
/// 课表 / 状态 → 上报载荷 的换算层。
|
||||
///
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
|
||||
/// ClassIsland 的模型变化而修改:
|
||||
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
|
||||
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
|
||||
@@ -91,7 +91,7 @@ public static class ScheduleMapper
|
||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||
if (plan.TimeRule.WeekCountDivTotal > 2)
|
||||
{
|
||||
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
|
||||
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
|
||||
}
|
||||
|
||||
var classes = plan.Classes;
|
||||
@@ -102,7 +102,7 @@ public static class ScheduleMapper
|
||||
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
|
||||
var point = classPoints[index];
|
||||
if (info is null) continue;
|
||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
|
||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
|
||||
if (!info.IsEnabled) continue;
|
||||
|
||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Classes="settings-container animated-intro" Spacing="0">
|
||||
<!-- 连接 -->
|
||||
<TextBlock Text="连接班级小助手"
|
||||
<TextBlock Text="连接 ClassHelper"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
|
||||
<TextBox Watermark="http://127.0.0.1:4000"
|
||||
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
|
||||
@@ -53,10 +53,10 @@
|
||||
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
|
||||
<fa:SettingsExpander Header="上报课表到班级小助手"
|
||||
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
|
||||
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
|
||||
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -66,7 +66,7 @@
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
|
||||
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
|
||||
Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -101,10 +101,10 @@
|
||||
<TextBlock Margin="0 24 0 0" Text="提醒"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
|
||||
<fa:SettingsExpander Header="接收班级小助手提醒"
|
||||
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander Header="接收 ClassHelper提醒"
|
||||
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -165,7 +165,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</controls:SettingsPageBase>
|
||||
@@ -11,7 +11,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 「班级小助手联动」设置页。
|
||||
/// 「ClassHelper 联动」设置页。
|
||||
///
|
||||
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
|
||||
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
|
||||
@@ -20,7 +20,7 @@ namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
|
||||
[SettingsPageInfo(
|
||||
"classhelper.classisland.bridge.settings",
|
||||
"班级小助手联动",
|
||||
"ClassHelper 联动",
|
||||
"\uE71B",
|
||||
"\uE71B",
|
||||
SettingsPageCategory.External)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: com.classhelper.desktop
|
||||
productName: 班级小助手
|
||||
productName: ClassHelper
|
||||
copyright: Copyright © 2026 Class Helper
|
||||
|
||||
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
|
||||
@@ -10,7 +10,7 @@ copyright: Copyright © 2026 Class Helper
|
||||
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
|
||||
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
|
||||
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
|
||||
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
|
||||
# 另外安装包有硬底:`ClassHelper.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
|
||||
electronLanguages:
|
||||
- zh-CN
|
||||
|
||||
@@ -57,7 +57,7 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: 班级小助手
|
||||
shortcutName: ClassHelper
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
|
||||
portable:
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
|
||||
/>
|
||||
<title>班级小助手</title>
|
||||
<title>ClassHelper</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
|
||||
/>
|
||||
<title>班级小助手 · 灵动岛</title>
|
||||
<title>ClassHelper · 灵动岛</title>
|
||||
<style>
|
||||
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
|
||||
html,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"author": "Class Helper",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
|
||||
*
|
||||
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
|
||||
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
* 免安装/ electron-builder 的 win-unpacked 内容
|
||||
* 构建中间/ builder-*.yml、*.nsis.7z
|
||||
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
|
||||
|
||||
@@ -58,7 +58,7 @@ if (online === undefined) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录):
|
||||
* 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
|
||||
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
|
||||
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
|
||||
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
|
||||
|
||||
@@ -827,7 +827,7 @@ onMounted(async () => {
|
||||
<footer class="foot">
|
||||
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
|
||||
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
||||
<span v-else class="more muted">来自班级小助手</span>
|
||||
<span v-else class="more muted">来自 ClassHelper</span>
|
||||
<span class="actions">
|
||||
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
|
||||
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">
|
||||
|
||||
@@ -60,7 +60,7 @@ function createWindow(): BrowserWindow {
|
||||
minWidth: 1000,
|
||||
minHeight: 540,
|
||||
center: true,
|
||||
title: '班级小助手',
|
||||
title: 'ClassHelper',
|
||||
// 冒烟验证时不弹窗,避免打扰使用者
|
||||
show: !isSmokeTest,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -4253,7 +4253,7 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
|
||||
record(
|
||||
'窗口标题正确',
|
||||
dom?.title === '班级小助手' || (dom?.title ?? '').includes('班级小助手'),
|
||||
dom?.title === 'ClassHelper' || (dom?.title ?? '').includes('ClassHelper'),
|
||||
`title=${dom?.title}`,
|
||||
);
|
||||
record(
|
||||
|
||||
@@ -34,13 +34,13 @@ export function createTray(handlers: TrayHandlers): Tray | null {
|
||||
const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
|
||||
tray = new Tray(image);
|
||||
|
||||
tray.setToolTip('班级小助手(后台运行中)');
|
||||
tray.setToolTip('ClassHelper(后台运行中)');
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: '显示主窗口', click: () => handlers.onShow() },
|
||||
{ label: '隐藏到托盘', click: () => handlers.onHide() },
|
||||
{ type: 'separator' },
|
||||
{ label: '退出班级小助手', click: () => handlers.onQuit() },
|
||||
{ label: '退出 ClassHelper', click: () => handlers.onQuit() },
|
||||
]),
|
||||
);
|
||||
// 左键单击/双击托盘图标:恢复窗口(用户习惯)
|
||||
|
||||
@@ -136,7 +136,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div v-if="booting" class="boot-screen">
|
||||
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
|
||||
<p class="boot-title">班级小助手</p>
|
||||
<p class="boot-title">ClassHelper</p>
|
||||
<!--
|
||||
启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。
|
||||
交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**,
|
||||
|
||||
@@ -15,19 +15,20 @@ import {
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type StudentDto,
|
||||
type SessionUser,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { api } from './http.js';
|
||||
|
||||
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
|
||||
/** ClassHelper 班级端只需要这一批接口(与后端同一套 REST 契约) */
|
||||
export const authApi = {
|
||||
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
|
||||
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
||||
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
|
||||
/** 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码 */
|
||||
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
|
||||
api.post('/auth/class-login', payload),
|
||||
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
||||
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
||||
me: (): Promise<SessionUser> => api.get(API_PATHS.auth.me),
|
||||
/**
|
||||
* 修改密码(设置页「账号信息」里的入口)。
|
||||
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
|
||||
@@ -128,6 +129,24 @@ export const notificationApi = {
|
||||
|
||||
export const gradeApi = {
|
||||
my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`),
|
||||
/**
|
||||
* 按**学号**查某个学生的成绩明细。
|
||||
* 学生没有账号,因此这是教室机器代学生查本人的通道;是否开放由班级上的
|
||||
* `studentGradeQueryEnabled` 开关决定(关闭时服务端返回 403 并给出原因)。
|
||||
*/
|
||||
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
|
||||
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
|
||||
};
|
||||
|
||||
/**
|
||||
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
|
||||
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。
|
||||
*/
|
||||
export const classGradeQueryApi = {
|
||||
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
|
||||
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
|
||||
+1
-1
@@ -315,7 +315,7 @@ export async function onlineScenario(credentials?: {
|
||||
const detail: string[] = [];
|
||||
|
||||
try {
|
||||
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
|
||||
// ClassHelper 班级端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
|
||||
// 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
|
||||
// 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
|
||||
const code = credentials?.code ?? '';
|
||||
|
||||
@@ -17,9 +17,9 @@ const onboarding = useOnboardingStore();
|
||||
const steps = [
|
||||
{
|
||||
icon: 'School',
|
||||
title: '欢迎使用班级小助手',
|
||||
title: '欢迎使用 ClassHelper',
|
||||
lines: [
|
||||
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
|
||||
'这台电脑将以「班级」的身份使用 ClassHelper:实时接收课表、作业、通知与成绩。',
|
||||
'第一次使用,先花半分钟看看怎么开始。',
|
||||
],
|
||||
},
|
||||
@@ -127,7 +127,7 @@ onUnmounted(() => {
|
||||
<el-icon :size="18"><School /></el-icon>
|
||||
</span>
|
||||
<div class="onboarding-heading">
|
||||
<h2 class="onboarding-title">班级小助手</h2>
|
||||
<h2 class="onboarding-title">ClassHelper</h2>
|
||||
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
|
||||
</div>
|
||||
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Pointer,
|
||||
Refresh,
|
||||
School,
|
||||
Search,
|
||||
Setting,
|
||||
Sunny,
|
||||
SwitchButton,
|
||||
@@ -75,6 +76,7 @@ const APP_ICONS = {
|
||||
Pointer,
|
||||
Refresh,
|
||||
School,
|
||||
Search,
|
||||
Setting,
|
||||
Sunny,
|
||||
SwitchButton,
|
||||
|
||||
@@ -298,7 +298,7 @@ onUnmounted(() => {
|
||||
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
|
||||
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
|
||||
<img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" />
|
||||
<span class="brand-text">班级小助手</span>
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</div>
|
||||
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
|
||||
@@ -44,9 +44,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级账号登录(学生端主入口):班级码 + 班级密码。
|
||||
* 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码。
|
||||
*
|
||||
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体;
|
||||
* 需求 1:ClassHelper 班级端以「班级」为主体,个人学生不再是登录主体;
|
||||
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
|
||||
* 保存的 username 字段记录班级码,便于下次预填。
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ type RealtimeEventName = keyof ServerToClientEvents;
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
/**
|
||||
* 实时通道(学生端)。
|
||||
* 实时通道(ClassHelper 班级端)。
|
||||
* 与 Web 管理端使用同一套事件名;收到事件后除了通知用户,
|
||||
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ============================================================================
|
||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||
* ClassHelper 班级端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||
*
|
||||
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
||||
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
type GradeDto,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
@@ -16,7 +17,10 @@ import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
|
||||
/**
|
||||
* ClassHelper 班级端看到的是**全班成绩总览**(学生不是账号,登录主体就是班级),
|
||||
* 标题与副标题随会话类型变化。
|
||||
*/
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
@@ -42,15 +46,46 @@ const latestGrades = computed(() =>
|
||||
[...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5),
|
||||
);
|
||||
|
||||
/**
|
||||
* 等级分布:优先用**库里存的等级**(教师可以手改、也可能是自定义等级),
|
||||
* 只有老数据没有等级时才按得分率现算 —— 否则教师在 Web 端改过的等级在这里会被"算回去"。
|
||||
*/
|
||||
const levelCounts = computed(() => {
|
||||
const levels: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, E: 0 };
|
||||
const levels: Record<string, number> = {};
|
||||
for (const item of grades.value) {
|
||||
levels[gradeLevel(gradePercent(item.score, item.totalScore))] =
|
||||
(levels[gradeLevel(gradePercent(item.score, item.totalScore))] ?? 0) + 1;
|
||||
const level = item.level || gradeLevel(gradePercent(item.score, item.totalScore));
|
||||
levels[level] = (levels[level] ?? 0) + 1;
|
||||
}
|
||||
return levels;
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 按学号查成绩明细
|
||||
*
|
||||
* 学生没有账号、不能登录,因此"查自己的成绩"这件事由**教室机器**代做:
|
||||
* 输入学号 → 服务端返回该生的全部成绩明细(含等级)。
|
||||
* 是否开放由教师/管理员在班级里开关控制(关闭后服务端返回 403,这里直接提示原因)。
|
||||
*/
|
||||
const queryNo = ref('');
|
||||
const queryLoading = ref(false);
|
||||
const queried = ref<StudentGradeDetailDto | null>(null);
|
||||
const queryError = ref('');
|
||||
|
||||
async function queryByStudentNo(): Promise<void> {
|
||||
const studentNo = queryNo.value.trim();
|
||||
if (!studentNo) return;
|
||||
queryLoading.value = true;
|
||||
queryError.value = '';
|
||||
queried.value = null;
|
||||
try {
|
||||
queried.value = await gradeApi.studentDetail(studentNo);
|
||||
} catch (error) {
|
||||
// 403 的文案由服务端给出("本班未开放学生自助查询成绩,请联系老师开启"),原样展示最准确
|
||||
queryError.value = error instanceof Error ? error.message : '查询失败,请稍后重试';
|
||||
} finally {
|
||||
queryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
|
||||
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
|
||||
@@ -111,10 +146,55 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="queryNo"
|
||||
placeholder="输入学号查个人明细"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="queryByStudentNo"
|
||||
/>
|
||||
<el-button :icon="'Search'" :loading="queryLoading" @click="queryByStudentNo">按学号查询</el-button>
|
||||
<el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按学号查询结果:ClassHelper 班级端代学生查本人明细 -->
|
||||
<el-card v-if="queried || queryError" shadow="never" class="mt-12">
|
||||
<template #header>
|
||||
<div class="query-header">
|
||||
<span v-if="queried">
|
||||
学号 {{ queried.student.studentNo }} · {{ queried.student.name }}
|
||||
<span class="text-muted">({{ queried.className ?? '未分班' }})</span>
|
||||
</span>
|
||||
<span v-else>查询失败</span>
|
||||
<el-button link @click="((queried = null), (queryError = ''))">收起</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert v-if="queryError" type="warning" :closable="false" show-icon :title="queryError" />
|
||||
<template v-else-if="queried">
|
||||
<div class="text-muted" style="margin-bottom: 8px">
|
||||
共 {{ queried.items.length }} 条记录 · 平均得分率 {{ queried.averagePercent }}%
|
||||
</div>
|
||||
<el-table :data="queried.items" size="small" empty-text="该生还没有成绩记录" max-height="320">
|
||||
<el-table-column prop="examName" label="考试" width="150" />
|
||||
<el-table-column label="科目" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分数" width="120">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.level || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="stat-card">
|
||||
|
||||
@@ -467,7 +467,7 @@ async function openSubmissions(item: HomeworkDto): Promise<void> {
|
||||
const result = await homeworkApi.submissions(item.id);
|
||||
submissions.value = result.students;
|
||||
// 默认沿用服务端已有的未交数据(老师只需改动的部分)
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.studentId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败');
|
||||
submissions.value = [];
|
||||
@@ -482,7 +482,7 @@ function markAllSubmitted(): void {
|
||||
}
|
||||
|
||||
function markAllNotSubmitted(): void {
|
||||
notSubmittedIds.value = submissions.value.map((student) => student.userId);
|
||||
notSubmittedIds.value = submissions.value.map((student) => student.studentId);
|
||||
}
|
||||
|
||||
async function saveSubmissions(): Promise<void> {
|
||||
@@ -496,7 +496,7 @@ async function saveSubmissions(): Promise<void> {
|
||||
try {
|
||||
const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value);
|
||||
submissions.value = result.students;
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.studentId);
|
||||
target.completedCount = result.completedCount;
|
||||
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
|
||||
submissionsVisible.value = false;
|
||||
@@ -835,7 +835,7 @@ onUnmounted(() => {
|
||||
<div v-loading="submissionsLoading" class="submission-list">
|
||||
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
|
||||
<el-checkbox-group v-model="notSubmittedIds">
|
||||
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
|
||||
<el-checkbox v-for="student in submissions" :key="student.studentId" :value="student.studentId">
|
||||
{{ student.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
|
||||
@@ -95,7 +95,7 @@ async function enterOffline(): Promise<void> {
|
||||
<div class="login-head">
|
||||
<img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" />
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<h1 class="login-title">ClassHelper</h1>
|
||||
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,11 +85,11 @@ onMounted(async () => {
|
||||
<el-icon><School /></el-icon>
|
||||
{{ collapseItems[0].title }}
|
||||
</span>
|
||||
<span class="about-extra">班级小助手 v{{ appInfo?.appVersion ?? '-' }}</span>
|
||||
<span class="about-extra">ClassHelper v{{ appInfo?.appVersion ?? '-' }}</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<p class="about-copy">
|
||||
Copyright © 2025-2026 班级小助手
|
||||
Copyright © 2025-2026 ClassHelper
|
||||
<br />
|
||||
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
|
||||
</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
} from '@classhelper/shared';
|
||||
import { classIslandStatusApi } from '../../api/index.js';
|
||||
import { classGradeQueryApi, classIslandStatusApi } from '../../api/index.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
|
||||
@@ -37,6 +37,38 @@ const classIslandStatus = ref<{
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
/* ------------------------------------------------------------ 班级端成绩查询开关
|
||||
*
|
||||
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」——
|
||||
* 教室机器在这里决定,值存在班级上;Web 端「班级管理」里也能改同一个开关。
|
||||
*/
|
||||
const gradeQueryEnabled = ref(true);
|
||||
const gradeQuerySaving = ref(false);
|
||||
|
||||
async function loadGradeQuery(): Promise<void> {
|
||||
const classId = auth.user?.classId;
|
||||
if (!classId) return;
|
||||
try {
|
||||
const result = await classGradeQueryApi.get(classId);
|
||||
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
|
||||
} catch {
|
||||
// 读不到就按默认值展示(默认开启),不阻断设置页
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGradeQuery(value: boolean): Promise<void> {
|
||||
const classId = auth.user?.classId;
|
||||
if (!classId) return;
|
||||
gradeQuerySaving.value = true;
|
||||
try {
|
||||
const result = await classGradeQueryApi.set(classId, value);
|
||||
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
|
||||
ElMessage.success(value ? '已允许按学号查询成绩明细' : '已关闭成绩查询');
|
||||
} finally {
|
||||
gradeQuerySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
@@ -71,6 +103,8 @@ onMounted(async () => {
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
// 班级端成绩查询开关(存在班级上,与 Web 端共享同一个值)
|
||||
await loadGradeQuery();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -94,7 +128,7 @@ onMounted(async () => {
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
<strong>通知模式</strong>
|
||||
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
:老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
@@ -114,11 +148,28 @@ onMounted(async () => {
|
||||
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||
<template v-if="!classIslandConnected">
|
||||
<br />
|
||||
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
|
||||
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
|
||||
ClassIsland 上弹")。
|
||||
</template>
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 成绩查询:让教室机器能按学号代学生查本人明细 -->
|
||||
<el-card shadow="never" class="mt-16">
|
||||
<template #header><span>成绩查询</span></template>
|
||||
<p class="text-muted">
|
||||
打开后,可以在「成绩」页按<b>学号</b>查到本班学生的成绩明细(含等级)。
|
||||
学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。
|
||||
关闭后本机查成绩会被服务端拒绝,教师端不受影响。
|
||||
</p>
|
||||
<el-switch
|
||||
:model-value="gradeQueryEnabled"
|
||||
:loading="gradeQuerySaving"
|
||||
active-text="允许按学号查询成绩明细"
|
||||
inactive-text="已关闭"
|
||||
@change="(value: string | number | boolean) => applyGradeQuery(value === true)"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
-- 学生从「账号」变成「名单记录」:新建 Student 表,个人数据外键改指向它
|
||||
--
|
||||
-- 为什么要有这一版:
|
||||
-- 在此之前学生是 `User` 表里 role='STUDENT' 的行 —— 与教师登录名共用同一个全局唯一
|
||||
-- 命名空间、靠 passwordHash='' 占位、靠"登录时先判 role 再 403"来阻止登录。
|
||||
-- 「学生不是账号」这条不变量只靠约定维持,任何一处漏判都会让一个学生变成可登录账号。
|
||||
-- 本迁移把它变成**结构性保证**:学生只存在于 Student 表,那里没有 passwordHash、没有 role。
|
||||
--
|
||||
-- 语义变化:
|
||||
-- 1) `User` 收缩为纯账号表(ADMIN / TEACHER / CLASS_DEVICE),去掉 classId 与 role='STUDENT';
|
||||
-- 2) `Student.studentNo`(学号)= 原 `User.username`,是学生唯一标识与查询键;
|
||||
-- 3) `Grade.userId` / `HomeworkStatus.userId` / `NotificationRead.userId` → `studentId`,
|
||||
-- 指向 Student(值就是原来那个 User.id,因此历史数据一条不丢);
|
||||
-- 4) 删除 `Enrollment`(`Student.classId` 已是唯一归属,两处双写必然漂移)
|
||||
-- 与 `ClassTeacher`(没有科目的协作关系;任课关系的唯一来源改为 `Course.teacherId`);
|
||||
-- 5) `User.phone`(教师联系方式)。
|
||||
--
|
||||
-- 数据保全:
|
||||
-- - 学生行按 id 原样搬进 Student,因此 Grade / HomeworkStatus / NotificationRead 里
|
||||
-- 旧 `userId` 直接就是新的 `studentId`,无需任何映射表;
|
||||
-- - 三张个人数据表回填时用 `IN (SELECT id FROM Student)` 兜底:历史上若有教师/管理员
|
||||
-- 误留下个人记录(例如教师给自己勾过作业完成),这些行会被丢弃而不是撞外键失败;
|
||||
-- - 成绩的 `level` 按旧口径(得分率)回填,保持升级后界面显示不变。
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "ClassTeacher_classId_teacherId_key";
|
||||
DROP INDEX "ClassTeacher_teacherId_idx";
|
||||
DROP INDEX "Enrollment_userId_classId_key";
|
||||
DROP INDEX "Enrollment_classId_idx";
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "ClassTeacher";
|
||||
DROP TABLE "Enrollment";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Student" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"classId" TEXT,
|
||||
"gender" TEXT NOT NULL DEFAULT '',
|
||||
"guardianPhone" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 把原「学生账号」整行搬成名单记录:id 保持不变,这样下面三张表的 userId 可以直接当 studentId 用。
|
||||
INSERT INTO "Student" ("id", "studentNo", "name", "classId", "gender", "guardianPhone", "status", "createdAt", "updatedAt")
|
||||
SELECT "id", "username", "name", "classId", '', '', 'active', "createdAt", "updatedAt"
|
||||
FROM "User"
|
||||
WHERE "role" = 'STUDENT';
|
||||
|
||||
CREATE TABLE "StudentClassTransfer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"studentName" TEXT NOT NULL,
|
||||
"fromClassId" TEXT,
|
||||
"fromClassName" TEXT NOT NULL DEFAULT '',
|
||||
"toClassId" TEXT,
|
||||
"toClassName" TEXT NOT NULL DEFAULT '',
|
||||
"operatorId" TEXT,
|
||||
"operatorName" TEXT NOT NULL DEFAULT '',
|
||||
"mode" TEXT NOT NULL DEFAULT 'single',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE "new_Class" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"teacherId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"termWeeks" INTEGER NOT NULL DEFAULT 20,
|
||||
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt" FROM "Class";
|
||||
DROP TABLE "Class";
|
||||
ALTER TABLE "new_Class" RENAME TO "Class";
|
||||
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
|
||||
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
|
||||
|
||||
CREATE TABLE "new_Grade" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"courseId" TEXT,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"examName" TEXT NOT NULL,
|
||||
"score" REAL NOT NULL,
|
||||
"totalScore" REAL NOT NULL DEFAULT 100,
|
||||
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Grade_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Grade_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Grade_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Grade" ("classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "studentId", "totalScore", "updatedAt")
|
||||
SELECT "classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "userId", "totalScore", "updatedAt"
|
||||
FROM "Grade"
|
||||
WHERE "userId" IN (SELECT "id" FROM "Student");
|
||||
DROP TABLE "Grade";
|
||||
ALTER TABLE "new_Grade" RENAME TO "Grade";
|
||||
CREATE INDEX "Grade_studentId_publishedAt_idx" ON "Grade"("studentId", "publishedAt");
|
||||
CREATE INDEX "Grade_classId_examName_idx" ON "Grade"("classId", "examName");
|
||||
CREATE INDEX "Grade_courseId_idx" ON "Grade"("courseId");
|
||||
|
||||
CREATE TABLE "new_HomeworkStatus" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"homeworkId" TEXT NOT NULL,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "HomeworkStatus_homeworkId_fkey" FOREIGN KEY ("homeworkId") REFERENCES "Homework" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "HomeworkStatus_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_HomeworkStatus" ("completed", "homeworkId", "id", "studentId", "updatedAt")
|
||||
SELECT "completed", "homeworkId", "id", "userId", "updatedAt"
|
||||
FROM "HomeworkStatus"
|
||||
WHERE "userId" IN (SELECT "id" FROM "Student");
|
||||
DROP TABLE "HomeworkStatus";
|
||||
ALTER TABLE "new_HomeworkStatus" RENAME TO "HomeworkStatus";
|
||||
CREATE INDEX "HomeworkStatus_studentId_idx" ON "HomeworkStatus"("studentId");
|
||||
CREATE UNIQUE INDEX "HomeworkStatus_homeworkId_studentId_key" ON "HomeworkStatus"("homeworkId", "studentId");
|
||||
|
||||
CREATE TABLE "new_NotificationRead" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"notificationId" TEXT NOT NULL,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"readAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "NotificationRead_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "NotificationRead_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_NotificationRead" ("id", "notificationId", "readAt", "studentId")
|
||||
SELECT "id", "notificationId", "readAt", "userId"
|
||||
FROM "NotificationRead"
|
||||
WHERE "userId" IN (SELECT "id" FROM "Student");
|
||||
DROP TABLE "NotificationRead";
|
||||
ALTER TABLE "new_NotificationRead" RENAME TO "NotificationRead";
|
||||
CREATE INDEX "NotificationRead_studentId_idx" ON "NotificationRead"("studentId");
|
||||
CREATE UNIQUE INDEX "NotificationRead_notificationId_studentId_key" ON "NotificationRead"("notificationId", "studentId");
|
||||
|
||||
-- User 收缩成纯账号表:只留 ADMIN / TEACHER(+ 未来的 CLASS_DEVICE),去掉 classId,加 phone。
|
||||
-- 学生行在这里被丢弃 —— 它们已经在上面搬进 Student 了。
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'TEACHER',
|
||||
"phone" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username")
|
||||
SELECT "createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username"
|
||||
FROM "User"
|
||||
WHERE "role" <> 'STUDENT';
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
|
||||
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
|
||||
CREATE INDEX "Student_name_idx" ON "Student"("name");
|
||||
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
|
||||
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
|
||||
CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- ClassHelper 班级端成绩查询开关(按班级存,默认开启)
|
||||
--
|
||||
-- 需求:「ClassHelper 班级端可按学号查询本班学生成绩明细;是否允许学生通过班级端查看本人明细,
|
||||
-- 由教师或管理员开关控制」。
|
||||
--
|
||||
-- 默认 true 是刻意的:教室机器装好就能查(开箱可用),要收紧的班级由班主任/管理员关掉即可。
|
||||
-- 关闭后班级端调 `GET /api/grades/student/:studentNo` 一律 403,教师端不受影响。
|
||||
ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 成绩等级:落到库里,教师可单个/批量手动编辑
|
||||
--
|
||||
-- 在此之前等级是**前端按得分率现算**的(`@classhelper/shared` 的 `gradeLevel(percent)`),
|
||||
-- 教师既看不到存下来的等级,也无法手动调整 —— 而需求要求
|
||||
-- 「发布成绩时可选百分制 / 等级制 A·B·C·D / 自定义等级,且可手动编辑等级(单个 + 批量)」。
|
||||
--
|
||||
-- `levelType`:
|
||||
-- percent 百分制 —— 等级由得分率自动换算(教师仍可手改并覆盖)
|
||||
-- letter 等级制 —— 直接给 A / B / C / D
|
||||
-- custom 自定义等级 —— 教师填任意文本(优 / 良 / 合格 / 待提高…)
|
||||
--
|
||||
-- `level` 永远有值:percent 下写入换算结果,其余两种按教师指定写入。
|
||||
ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent';
|
||||
ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 回填旧数据:按升级前的同一套口径(得分率 ≥90 A / ≥80 B / ≥70 C / ≥60 D / 其余 E)算,
|
||||
-- 保证升级前后界面上看到的等级完全一致。总分非法(≤0)一律落到 E。
|
||||
UPDATE "Grade"
|
||||
SET "level" = CASE
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 90 THEN 'A'
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 80 THEN 'B'
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 70 THEN 'C'
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 60 THEN 'D'
|
||||
ELSE 'E'
|
||||
END
|
||||
WHERE "level" = '';
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- ClassHelper 班级端心跳时间:把「在线判定」和「最后带状态的上报」拆成两个字段
|
||||
--
|
||||
-- 原先只有一个 `lastSeenAt`(每次上报都刷)。但同一台设备的上报其实有两种:
|
||||
-- - **心跳**:插件每隔 60 秒打一次,只表明"我还在线";
|
||||
-- - **状态上报**:带运行状态 / 课表,表明"我拿到了新东西"。
|
||||
-- 合成一个字段后,教师网页无法区分「设备在线但一直没有新状态」和「设备整个失联」。
|
||||
--
|
||||
-- 于是:
|
||||
-- `lastHeartbeatAt` 每次上报都刷新 —— **在线判定看它**(60 秒窗口,见 shared 的
|
||||
-- `CLASS_HELPER_ONLINE_WINDOW_MS`);
|
||||
-- `lastSeenAt` 只在带运行状态的上报时刷新 —— 展示为「最后在线时间」。
|
||||
--
|
||||
-- 回填:老库只有 lastSeenAt,就把它当作心跳时间的初值(否则所有设备升级后都会显示"从未心跳")。
|
||||
ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME;
|
||||
UPDATE "IntegrationDevice" SET "lastHeartbeatAt" = "lastSeenAt" WHERE "lastHeartbeatAt" IS NULL;
|
||||
@@ -0,0 +1,145 @@
|
||||
-- 毕业归档:届别档案 + 班级/学生的归档标记
|
||||
--
|
||||
-- 背景(需求):管理员要有一个「归档」页,按**年度**记录毕业的班级与学生、当年转出的学生,
|
||||
-- 以及毕业班级发过的作业与通知;**未毕业而升级的班级不归档、数据沿用**。
|
||||
--
|
||||
-- 关键设计:**归档是打标记 + 只读,不删任何数据**。
|
||||
-- `ArchivedYear` 是届别档案(入学年份 + 毕业年份 + 归档时的统计快照);
|
||||
-- `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
|
||||
-- 毕业班级的作业与通知原样留在库里(`Homework.classId` / `Notification.classId` 不变),
|
||||
-- 归档详情页只读地查它们 —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
|
||||
--
|
||||
-- 升级 ≠ 归档:高一 → 高二 只改 `Class.grade`,班级记录、学生名单、作业成绩全部沿用,
|
||||
-- 不碰 archivedYearId。因此归档只能由管理员在归档页显式执行。
|
||||
--
|
||||
-- 班级称呼改成「XXXX级X班」:新列 `enrollmentYear`(入学年份)+ `classIndex`(班号),
|
||||
-- `Class.name` 由服务端按二者生成。下面会对**已有班级**做一次尽力而为的推断回填。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArchivedYear" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"enrollmentYear" INTEGER NOT NULL,
|
||||
"graduationYear" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"operatorId" TEXT,
|
||||
"operatorName" TEXT NOT NULL DEFAULT '',
|
||||
"classCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"studentCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"transferredCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"homeworkCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"notificationCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"archivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE "new_Class" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"enrollmentYear" INTEGER,
|
||||
"classIndex" INTEGER,
|
||||
"teacherId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"termWeeks" INTEGER NOT NULL DEFAULT 20,
|
||||
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
|
||||
"studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archivedYearId" TEXT,
|
||||
"archivedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Class_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt" FROM "Class";
|
||||
DROP TABLE "Class";
|
||||
ALTER TABLE "new_Class" RENAME TO "Class";
|
||||
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
|
||||
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
|
||||
CREATE INDEX "Class_enrollmentYear_idx" ON "Class"("enrollmentYear");
|
||||
CREATE INDEX "Class_archivedYearId_idx" ON "Class"("archivedYearId");
|
||||
|
||||
-- 已有班级的届别推断(尽力而为):
|
||||
-- 入学年份 = 当前学年起始年 − 该班现在的年级偏移。学年在 9 月切换,
|
||||
-- 所以 9 月及以后算今年起始、之前算去年起始;高一偏移 0、高二 1、高三 2,
|
||||
-- 认不出的年级(初中 / 四年制等)按高三处理(宁可偏早,管理员可在班级管理页改正)。
|
||||
UPDATE "Class"
|
||||
SET "enrollmentYear" = (
|
||||
CASE WHEN CAST(strftime('%m', 'now') AS INTEGER) >= 9
|
||||
THEN CAST(strftime('%Y', 'now') AS INTEGER)
|
||||
ELSE CAST(strftime('%Y', 'now') AS INTEGER) - 1 END
|
||||
) - CASE "grade" WHEN '高二' THEN 1 WHEN '高三' THEN 2 ELSE 0 END
|
||||
WHERE "enrollmentYear" IS NULL;
|
||||
|
||||
-- 班号按「同一届内按创建先后」编号(老名字里的括号数字没法跨库 reliably 解析,这是等价的近似)。
|
||||
UPDATE "Class"
|
||||
SET "classIndex" = (
|
||||
SELECT COUNT(*) FROM "Class" c2
|
||||
WHERE c2."enrollmentYear" = "Class"."enrollmentYear"
|
||||
AND (c2."createdAt" < "Class"."createdAt"
|
||||
OR (c2."createdAt" = "Class"."createdAt" AND c2."id" <= "Class"."id"))
|
||||
)
|
||||
WHERE "classIndex" IS NULL;
|
||||
|
||||
-- 按新口径重写称呼:「2026级1班」。管理员若觉得推断不对,可在班级管理页改入学年份/班号。
|
||||
UPDATE "Class"
|
||||
SET "name" = CAST("enrollmentYear" AS TEXT) || '级' || CAST("classIndex" AS TEXT) || '班'
|
||||
WHERE "enrollmentYear" IS NOT NULL AND "classIndex" IS NOT NULL;
|
||||
|
||||
CREATE TABLE "new_Student" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"classId" TEXT,
|
||||
"gender" TEXT NOT NULL DEFAULT '',
|
||||
"guardianPhone" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"archivedYearId" TEXT,
|
||||
"archivedAt" DATETIME,
|
||||
"transferredAt" DATETIME,
|
||||
"transferNote" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Student_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Student" ("classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt") SELECT "classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt" FROM "Student";
|
||||
DROP TABLE "Student";
|
||||
ALTER TABLE "new_Student" RENAME TO "Student";
|
||||
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
|
||||
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
|
||||
CREATE INDEX "Student_name_idx" ON "Student"("name");
|
||||
CREATE INDEX "Student_status_idx" ON "Student"("status");
|
||||
CREATE INDEX "Student_archivedYearId_idx" ON "Student"("archivedYearId");
|
||||
|
||||
CREATE TABLE "new_StudentClassTransfer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"studentName" TEXT NOT NULL,
|
||||
"fromClassId" TEXT,
|
||||
"fromClassName" TEXT NOT NULL DEFAULT '',
|
||||
"toClassId" TEXT,
|
||||
"toClassName" TEXT NOT NULL DEFAULT '',
|
||||
"operatorId" TEXT,
|
||||
"operatorName" TEXT NOT NULL DEFAULT '',
|
||||
"mode" TEXT NOT NULL DEFAULT 'single',
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_StudentClassTransfer" ("createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName") SELECT "createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName" FROM "StudentClassTransfer";
|
||||
DROP TABLE "StudentClassTransfer";
|
||||
ALTER TABLE "new_StudentClassTransfer" RENAME TO "StudentClassTransfer";
|
||||
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
|
||||
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ArchivedYear_enrollmentYear_key" ON "ArchivedYear"("enrollmentYear");
|
||||
@@ -0,0 +1,79 @@
|
||||
-- 学期周次配置 + 课表同步方向反转
|
||||
--
|
||||
-- 一、学期周次(需求:「第几周的周次数据可以由 admin 修改」)
|
||||
-- `TermWeek` 让管理员为**每一个教学周**指定实际的起止日期(调休、周末补课、错峰开学),
|
||||
-- `classId = ''` 表示全校默认;没配到的周次仍按学期开始日期线性兜底。
|
||||
-- `Class.termStartDate` 允许某个班单独覆盖全局的开学日期(它随镜像课表下发给教室的 ClassIsland)。
|
||||
--
|
||||
-- 二、课表同步方向反转(需求:「课表不再自动从教室的 ClassIsland 获取,改为手动获取;
|
||||
-- ClassHelper 班级端的课表由服务端自动下发」)
|
||||
-- `syncScheduleToServer` 默认 true → **false**:插件不再自动把教室的课表推上来
|
||||
-- `mirrorScheduleToClassIsland` 默认 false → **true**:服务端的课表自动下发给教室
|
||||
-- `requestScheduleReport` 新增:Web 端点「从教室机器获取课表」时置位,
|
||||
-- 插件下次心跳看到就把当前课表推上来并清除标记
|
||||
--
|
||||
-- 下面会把**已有设备**也改成新方向:这正是需求要的行为变化(否则升级后教室里
|
||||
-- 仍然在自动回传,老师手排的课会被教室的旧课表悄悄覆盖)。设备管理员可以逐台改回去。
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Class" ADD COLUMN "termStartDate" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TermWeek" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL DEFAULT '',
|
||||
"weekNumber" INTEGER NOT NULL,
|
||||
"startDate" TEXT NOT NULL,
|
||||
"endDate" TEXT NOT NULL,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_IntegrationDevice" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"deviceKey" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL DEFAULT 'plugin',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenHint" TEXT NOT NULL,
|
||||
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT true,
|
||||
"requestScheduleReport" BOOLEAN NOT NULL DEFAULT false,
|
||||
"classIslandVersion" TEXT,
|
||||
"pluginVersion" TEXT,
|
||||
"lastSeenAt" DATETIME,
|
||||
"lastHeartbeatAt" DATETIME,
|
||||
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"currentSubject" TEXT,
|
||||
"currentTimeState" TEXT,
|
||||
"currentPeriodStart" TEXT,
|
||||
"currentPeriodEnd" TEXT,
|
||||
"nextSubject" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_IntegrationDevice" ("classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt") SELECT "classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt" FROM "IntegrationDevice";
|
||||
|
||||
-- 已有设备切到新方向:不再自动回传课表,改为由服务端自动下发。
|
||||
UPDATE "new_IntegrationDevice" SET "syncScheduleToServer" = false, "mirrorScheduleToClassIsland" = true;
|
||||
|
||||
DROP TABLE "IntegrationDevice";
|
||||
ALTER TABLE "new_IntegrationDevice" RENAME TO "IntegrationDevice";
|
||||
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
|
||||
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
|
||||
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TermWeek_classId_idx" ON "TermWeek"("classId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TermWeek_classId_weekNumber_key" ON "TermWeek"("classId", "weekNumber");
|
||||
@@ -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)
|
||||
// 教师创建(负责)的班级
|
||||
/// 教师担任班主任的班级(每班 1 人)
|
||||
ownedClasses Class[] @relation("ClassOwner")
|
||||
// 教师被分配协作的班级
|
||||
classAssignments ClassTeacher[]
|
||||
/// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
|
||||
courses Course[]
|
||||
homeworkStatuses HomeworkStatus[]
|
||||
notificationReads NotificationRead[]
|
||||
grades Grade[]
|
||||
enrollments Enrollment[]
|
||||
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?
|
||||
|
||||
+208
-42
@@ -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<void> {
|
||||
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<void> {
|
||||
@@ -86,11 +98,11 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, { id: string; name: string }[]>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 成绩
|
||||
//
|
||||
// 三种等级口径各演示一门,方便在界面上看出差别:
|
||||
// 数学(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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 调班 / 转出历史
|
||||
//
|
||||
// 调班只改 `Student.classId`(学号不变,历史作业与成绩留在原班级),并留一条可查的痕迹。
|
||||
const movedStudent = students[7]!; // 赵依然:2026级2班 → 2026级1班(曾经)
|
||||
await prisma.student.update({
|
||||
where: { id: movedStudent.id },
|
||||
data: { classId: classes[0]!.id },
|
||||
});
|
||||
await prisma.studentClassTransfer.create({
|
||||
data: {
|
||||
studentId: movedStudent.id,
|
||||
studentNo: movedStudent.studentNo,
|
||||
studentName: movedStudent.name,
|
||||
fromClassId: classes[0]!.id,
|
||||
fromClassName: classes[0]!.name,
|
||||
toClassId: classes[1]!.id,
|
||||
toClassName: classes[1]!.name,
|
||||
operatorId: admin.id,
|
||||
operatorName: admin.name,
|
||||
mode: 'batch',
|
||||
note: '军训后按分班考试成绩重新均衡',
|
||||
createdAt: new Date(now - 30 * day),
|
||||
},
|
||||
});
|
||||
|
||||
// 转出:学籍离开本校(不是调班),班级归属保留作历史
|
||||
const transferredStudent = students[13]!; // 朱佳琪
|
||||
await prisma.student.update({
|
||||
where: { id: transferredStudent.id },
|
||||
data: {
|
||||
status: 'transferred',
|
||||
transferredAt: new Date(now - 12 * day),
|
||||
transferNote: '随家长工作调动转学至外地',
|
||||
},
|
||||
});
|
||||
await prisma.studentClassTransfer.create({
|
||||
data: {
|
||||
studentId: transferredStudent.id,
|
||||
studentNo: transferredStudent.studentNo,
|
||||
studentName: transferredStudent.name,
|
||||
fromClassId: transferredStudent.classId,
|
||||
fromClassName: classes[2]!.name,
|
||||
toClassId: null,
|
||||
toClassName: '转出本校',
|
||||
operatorId: admin.id,
|
||||
operatorName: admin.name,
|
||||
mode: 'transfer-out',
|
||||
note: '随家长工作调动转学至外地',
|
||||
createdAt: new Date(now - 12 * day),
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 已毕业届别(归档页的演示数据)
|
||||
//
|
||||
// 归档 = 打标记 + 只读:班级与它的作业/通知原样留在库里,只是从常规列表里隐去。
|
||||
const graduatedClass = await prisma.class.create({
|
||||
data: {
|
||||
name: formatClassName(2023, 1),
|
||||
grade: '高三',
|
||||
enrollmentYear: 2023,
|
||||
classIndex: 1,
|
||||
teacherId: teacher1.id,
|
||||
code: 'G231',
|
||||
passwordHash: classPasswordHash,
|
||||
},
|
||||
});
|
||||
const archivedYear = await prisma.archivedYear.create({
|
||||
data: {
|
||||
enrollmentYear: 2023,
|
||||
graduationYear: 2023 + 3,
|
||||
name: formatYearLabel(2023),
|
||||
note: '演示用:2023 级已于 2026 年 6 月毕业',
|
||||
operatorId: admin.id,
|
||||
operatorName: admin.name,
|
||||
archivedAt: new Date(now - 100 * day),
|
||||
},
|
||||
});
|
||||
await prisma.class.update({
|
||||
where: { id: graduatedClass.id },
|
||||
data: { archivedYearId: archivedYear.id, archivedAt: new Date(now - 100 * day) },
|
||||
});
|
||||
|
||||
const graduateNames = ['毕业演示甲', '毕业演示乙', '毕业演示丙'];
|
||||
for (const [index, name] of graduateNames.entries()) {
|
||||
await prisma.student.create({
|
||||
data: {
|
||||
studentNo: `2023${String(index + 1).padStart(2, '0')}`,
|
||||
name,
|
||||
classId: graduatedClass.id,
|
||||
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
|
||||
status: 'graduated',
|
||||
archivedYearId: archivedYear.id,
|
||||
archivedAt: new Date(now - 100 * day),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 这一届也要有作业与通知,「记录每个年度毕业班级的消息和作业」才有东西可看
|
||||
await prisma.homework.create({
|
||||
data: {
|
||||
classId: graduatedClass.id,
|
||||
title: '高考前最后一轮复习',
|
||||
content: '按考点清单过一遍错题本,重点看函数与数列。',
|
||||
assignDate: dayKeyLocal(new Date(now - 120 * day)),
|
||||
createdBy: teacher1.id,
|
||||
},
|
||||
});
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
classId: graduatedClass.id,
|
||||
title: '毕业典礼安排',
|
||||
content: '6 月 20 日上午 9 点在礼堂举行毕业典礼,请全体同学准时参加。',
|
||||
priority: 'HIGH',
|
||||
createdBy: teacher1.id,
|
||||
createdAt: new Date(now - 110 * day),
|
||||
},
|
||||
});
|
||||
|
||||
const counts = {
|
||||
用户: 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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
|
||||
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
},
|
||||
);
|
||||
record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
|
||||
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();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -98,7 +98,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>班级小助手 · 后端服务</title>
|
||||
<title>ClassHelper · 后端服务</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
|
||||
@@ -116,7 +116,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
|
||||
<h1>ClassHelper · 后端服务 <span class="ok">running</span></h1>
|
||||
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
||||
<p class="sub">
|
||||
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
||||
@@ -127,7 +127,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
<ul>${moduleRows}</ul>
|
||||
<div class="hint">
|
||||
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
|
||||
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。
|
||||
本服务只提供 REST API 与 WebSocket;ClassHelper 班级端请使用「ClassHelper」桌面客户端。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -182,7 +182,7 @@ export function createApp(): Express {
|
||||
|
||||
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
||||
app.use(API_PREFIX, apiRateLimiter());
|
||||
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
|
||||
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(ClassHelper 班级端
|
||||
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
|
||||
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
|
||||
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
|
||||
|
||||
@@ -27,7 +27,7 @@ const envSchema = z.object({
|
||||
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||||
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
|
||||
/** 新建班级时的默认班级密码(ClassHelper 班级端班级账号登录用),管理员可随时重置 */
|
||||
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||||
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
||||
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
|
||||
|
||||
@@ -79,7 +79,7 @@ httpServer.listen(env.port, env.host, () => {
|
||||
writePidFile();
|
||||
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
|
||||
const webDir = resolveWebDistDir();
|
||||
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||||
logger.info(`ClassHelper后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||||
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
|
||||
if (webDir) {
|
||||
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
canManageGrades,
|
||||
canManageSchedule,
|
||||
canPublishContent,
|
||||
canManageClasses,
|
||||
canAssignTeachers,
|
||||
canManageClasses,
|
||||
canManageGrades,
|
||||
canManageRoster,
|
||||
canManageSchedule,
|
||||
canManageSubjectContent,
|
||||
canPublishNotification,
|
||||
resolveClassRole,
|
||||
type ClassRole,
|
||||
} from '@classhelper/shared';
|
||||
@@ -13,11 +14,20 @@ import { ApiError } from './http.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 权限(RBAC)规则:
|
||||
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表
|
||||
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人
|
||||
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人
|
||||
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据
|
||||
* 权限(RBAC)规则。
|
||||
*
|
||||
* | 角色 | 范围 | 能做什么 |
|
||||
* | ---- | ---- | -------- |
|
||||
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
|
||||
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
|
||||
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
|
||||
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
|
||||
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
|
||||
*
|
||||
* 三条铁律(都是这次重构的落点):
|
||||
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
|
||||
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
|
||||
* 3. **所有权限都在后端判**,前端只做展示控制。
|
||||
*
|
||||
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
|
||||
*/
|
||||
@@ -30,14 +40,36 @@ export function isTeacher(user: TokenPayload): boolean {
|
||||
return user.role === 'TEACHER';
|
||||
}
|
||||
|
||||
export function isStudent(user: TokenPayload): boolean {
|
||||
return user.role === 'STUDENT';
|
||||
}
|
||||
|
||||
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
|
||||
export function isStaff(user: TokenPayload): boolean {
|
||||
return user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
|
||||
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
|
||||
*/
|
||||
export function isClassDevice(user: TokenPayload): boolean {
|
||||
return user.role === 'CLASS_DEVICE';
|
||||
}
|
||||
|
||||
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
|
||||
export function classDeviceClassId(user: TokenPayload): string | null {
|
||||
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
|
||||
*
|
||||
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
|
||||
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
|
||||
*/
|
||||
function classMembershipWhere(teacherId: string) {
|
||||
return {
|
||||
OR: [{ teacherId }, { courses: { some: { teacherId } } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前用户可访问的全部班级 id */
|
||||
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> {
|
||||
if (isAdmin(user)) {
|
||||
@@ -47,39 +79,23 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise<string[
|
||||
|
||||
if (isTeacher(user)) {
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }] },
|
||||
where: classMembershipWhere(user.sub),
|
||||
select: { id: true },
|
||||
});
|
||||
return classes.map((item) => item.id);
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
if (user.classId) ids.add(user.classId);
|
||||
const enrollments = await prisma.enrollment.findMany({
|
||||
where: { userId: user.sub },
|
||||
select: { classId: true },
|
||||
});
|
||||
for (const item of enrollments) ids.add(item.classId);
|
||||
return [...ids];
|
||||
const classId = classDeviceClassId(user);
|
||||
return classId ? [classId] : [];
|
||||
}
|
||||
|
||||
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
|
||||
if (isAdmin(user)) return true;
|
||||
if (isTeacher(user)) {
|
||||
const count = await prisma.class.count({
|
||||
where: {
|
||||
id: classId,
|
||||
OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }],
|
||||
},
|
||||
});
|
||||
const count = await prisma.class.count({ where: { id: classId, ...classMembershipWhere(user.sub) } });
|
||||
return count > 0;
|
||||
}
|
||||
if (user.classId === classId) return true;
|
||||
const enrollment = await prisma.enrollment.findFirst({
|
||||
where: { userId: user.sub, classId },
|
||||
select: { id: true },
|
||||
});
|
||||
return enrollment !== null;
|
||||
return classDeviceClassId(user) === classId;
|
||||
}
|
||||
|
||||
/** 读权限断言 */
|
||||
@@ -89,31 +105,31 @@ export async function assertClassAccess(user: TokenPayload, classId: string): Pr
|
||||
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
|
||||
}
|
||||
|
||||
/** 写权限断言:学生一律拒绝 */
|
||||
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
|
||||
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
|
||||
if (!isStaff(user)) throw ApiError.forbidden('学生账号没有管理权限');
|
||||
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
|
||||
await assertClassAccess(user, classId);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 班级内角色 */
|
||||
|
||||
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */
|
||||
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
|
||||
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
|
||||
if (user.role === 'ADMIN') return 'ADMIN';
|
||||
if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE'));
|
||||
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
|
||||
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: {
|
||||
teacherId: true,
|
||||
teachers: { where: { teacherId: user.sub }, select: { id: true } },
|
||||
courses: { where: { teacherId: user.sub }, select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!record) return 'NONE';
|
||||
return resolveClassRole({
|
||||
role: user.role,
|
||||
isHeadTeacher: record.teacherId === user.sub,
|
||||
isSubjectTeacher: record.teachers.length > 0,
|
||||
teachesSubjects: record.courses.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,17 +140,30 @@ export function assertCanManageClasses(user: TokenPayload): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 班主任 / 科任老师分配:仅管理员 */
|
||||
/** 班主任 / 各科科任老师的分配:仅管理员 */
|
||||
export function assertCanAssignTeachers(user: TokenPayload): void {
|
||||
if (!canAssignTeachers(user.role)) {
|
||||
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师');
|
||||
throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
|
||||
}
|
||||
}
|
||||
|
||||
/** 学生名单管理:仅管理员 */
|
||||
export function assertCanManageRoster(user: TokenPayload): void {
|
||||
if (!canManageRoster(user.role)) {
|
||||
throw ApiError.forbidden('只有管理员可以管理学生名单与账号');
|
||||
/**
|
||||
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
|
||||
*
|
||||
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
|
||||
*/
|
||||
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
|
||||
if (!classId) {
|
||||
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
return;
|
||||
}
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canManageRoster(classRole)) {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT'
|
||||
? '科任老师不能管理学生名单,请联系班主任或管理员'
|
||||
: '只能管理自己担任班主任的班级的学生名单',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,30 +172,32 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canManageSchedule(classRole)) {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表',
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
|
||||
: '无权管理该班级的课表',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */
|
||||
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> {
|
||||
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
|
||||
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canPublishContent(classRole)) {
|
||||
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限');
|
||||
if (!canPublishNotification(classRole)) {
|
||||
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩录入 / 修改 / 导入:管理员,或**本班班主任**(需求 6"老师端可用且不越权")。
|
||||
* 科任老师仍然被拒(需求 7:科任只有作业 / 叫人 / 通知)。
|
||||
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
|
||||
*
|
||||
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
|
||||
*
|
||||
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
|
||||
*/
|
||||
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
|
||||
if (!classId) {
|
||||
// 没有班级上下文(例如下载空白模板):只有管理员可以执行
|
||||
if (user.role !== 'ADMIN') throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,22 +205,86 @@ export async function assertCanManageGrades(user: TokenPayload, classId?: string
|
||||
if (canManageGrades(classRole)) return;
|
||||
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT'
|
||||
? '科任老师不能录入或导入成绩,请联系班主任或管理员'
|
||||
: '只能录入或导入自己担任班主任的班级的成绩',
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
|
||||
: '只能录入自己担任班主任的班级的成绩',
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生的主班级,未分配班级时抛 403 */
|
||||
export function requireStudentClassId(user: TokenPayload): string {
|
||||
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
|
||||
return user.classId;
|
||||
/**
|
||||
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
|
||||
*
|
||||
* | 情形 | 谁可以写 |
|
||||
* | ---- | -------- |
|
||||
* | 管理员 | 全部 |
|
||||
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
|
||||
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
|
||||
* | 没挂科目(courseId 为空) | 本班**班主任** |
|
||||
*
|
||||
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
|
||||
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
|
||||
*
|
||||
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
|
||||
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
|
||||
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
|
||||
*/
|
||||
export async function assertCanManageSubjectContent(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
courseId: string | null | undefined,
|
||||
action: string,
|
||||
options: { allowClassDevice?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
const allowClassDevice = options.allowClassDevice === true;
|
||||
|
||||
// 管理员不受科目限制,提前返回省掉一次课程查询
|
||||
if (classRole === 'ADMIN') return;
|
||||
if (classRole === 'CLASS_DEVICE') {
|
||||
if (allowClassDevice) return;
|
||||
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
|
||||
}
|
||||
|
||||
let isSubjectTeacherOfCourse = false;
|
||||
let courseName = '';
|
||||
if (courseId) {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { classId: true, teacherId: true, name: true },
|
||||
});
|
||||
if (!course) throw ApiError.badRequest('课程不存在');
|
||||
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
|
||||
courseName = course.name;
|
||||
isSubjectTeacherOfCourse = course.teacherId === user.sub;
|
||||
}
|
||||
|
||||
const allowed = canManageSubjectContent({
|
||||
classRole,
|
||||
hasSubject: Boolean(courseId),
|
||||
isSubjectTeacherOfCourse,
|
||||
allowClassDevice,
|
||||
});
|
||||
if (allowed) return;
|
||||
|
||||
throw ApiError.forbidden(
|
||||
courseId
|
||||
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
|
||||
: `未指定科目的内容只有班主任或管理员可以${action}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
|
||||
export function requireClassDeviceClassId(user: TokenPayload): string {
|
||||
const classId = classDeviceClassId(user);
|
||||
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
|
||||
return classId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询范围解析:
|
||||
* - 传入 classId:校验后只查该班级
|
||||
* - 未传:教师/学生只能查自己可访问的班级;管理员不加限制
|
||||
* - 未传:教师只能查自己可访问的班级;管理员不加限制
|
||||
*/
|
||||
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
|
||||
|
||||
@@ -215,8 +310,8 @@ export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } }
|
||||
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
|
||||
}
|
||||
|
||||
/** 单班级场景:学生强制取自己班级,其他角色沿用请求参数 */
|
||||
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
|
||||
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
|
||||
if (isStudent(user)) return requireStudentClassId(user);
|
||||
if (isClassDevice(user)) return requireClassDeviceClassId(user);
|
||||
return requestedClassId;
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { ApiError } from './http.js';
|
||||
import { hashPassword, verifyPassword } from './password.js';
|
||||
|
||||
/**
|
||||
* 班级账号(学生端「班级」主体):
|
||||
* ClassHelper 班级端(ClassHelper 班级端「班级」主体):
|
||||
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
|
||||
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
|
||||
* - 教室机器用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT(角色 `CLASS_DEVICE`);
|
||||
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
|
||||
*
|
||||
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
|
||||
* **学生没有账号**:`Student` 只是名单记录,这里与它无关。
|
||||
*/
|
||||
|
||||
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
|
||||
@@ -31,7 +31,7 @@ export async function generateClassCode(prefix = ''): Promise<string> {
|
||||
const length = Math.max(4, 6 - prefix.length);
|
||||
let body = '';
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
|
||||
// 用 CSPRNG 而不是 Math.random():班级码会作为 ClassHelper 班级端登录的账号名打印/张贴,
|
||||
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
|
||||
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
|
||||
}
|
||||
@@ -91,18 +91,27 @@ export async function updateClassAccount(
|
||||
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
|
||||
}
|
||||
|
||||
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
|
||||
/** ClassHelper 班级端登录:返回班级记录(密码校验在 service 层完成) */
|
||||
export async function findClassForLogin(code: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
code: string;
|
||||
passwordHash: string | null;
|
||||
studentGradeQueryEnabled: boolean;
|
||||
}> {
|
||||
const normalized = normalizeClassCode(code);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { code: normalized },
|
||||
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
code: true,
|
||||
passwordHash: true,
|
||||
studentGradeQueryEnabled: true,
|
||||
archivedYearId: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
|
||||
if (!record.passwordHash) {
|
||||
@@ -112,6 +121,11 @@ export async function findClassForLogin(code: string): Promise<{
|
||||
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
|
||||
);
|
||||
}
|
||||
// 已毕业归档的班级不再允许教室机器登录:继续登进去只会看到一个只读的历史壳子,
|
||||
// 明确拒绝比"能进但没有东西可做"好排查。
|
||||
if (record.archivedYearId) {
|
||||
throw new ApiError(403, 'CLASS_ARCHIVED', '该班级已毕业归档,教室端已停止使用,请联系管理员');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export type ApiErrorCode =
|
||||
| 'IMPORT_EMPTY'
|
||||
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
|
||||
| 'CLASS_PASSWORD_NOT_SET'
|
||||
/** 班级账号:该班级已毕业归档,教室端已停止使用 */
|
||||
| 'CLASS_ARCHIVED'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -7,13 +7,14 @@ import { ApiError } from './http.js';
|
||||
/** JWT 载荷(同时作为 req.auth.user 的类型来源) */
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
/** 教师/管理员的工号,或 ClassHelper 班级端的班级码 */
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
|
||||
/** true 表示「ClassHelper 班级端」登录:sub 是班级 id,而非某个账号 */
|
||||
classSession?: boolean;
|
||||
/** 班级账号的班级码(便于日志/审计) */
|
||||
/** 班级端的班级码(便于日志/审计) */
|
||||
classCode?: string;
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ export function verifyToken(token: string): TokenPayload {
|
||||
|
||||
const record = decoded as Record<string, unknown>;
|
||||
const role = record.role;
|
||||
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'STUDENT') {
|
||||
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'CLASS_DEVICE') {
|
||||
throw new Error('unexpected role in token');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { dayKeyLocal } from '@classhelper/shared';
|
||||
import type {
|
||||
ArchivedClassDto,
|
||||
ArchivedYearDto,
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
ClassTeacherBrief,
|
||||
@@ -11,7 +13,10 @@ import type {
|
||||
NotificationDto,
|
||||
NotificationPriority,
|
||||
ScheduleDto,
|
||||
SessionUser,
|
||||
StudentBrief,
|
||||
StudentDto,
|
||||
StudentTransferDto,
|
||||
UserDto,
|
||||
UserRole,
|
||||
} from '@classhelper/shared';
|
||||
@@ -28,13 +33,13 @@ const toIso = (value: Date): string => value.toISOString();
|
||||
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
|
||||
|
||||
/**
|
||||
* "个人记录"归属范围解析:
|
||||
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
|
||||
* - 否则退化为单个 userId(普通学生账号 = 自己)。
|
||||
* "个人记录"归属范围解析(记录键是**学生 id**):
|
||||
* - 传入 studentIds(ClassHelper 班级端 = 全班学生)时优先使用;
|
||||
* - 否则退化为单个 studentId。
|
||||
*/
|
||||
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
|
||||
if (options.userIds && options.userIds.length > 0) return options.userIds;
|
||||
return options.userId ? [options.userId] : [];
|
||||
function resolvePool(options: { studentId?: string | null; studentIds?: string[] }): string[] {
|
||||
if (options.studentIds && options.studentIds.length > 0) return options.studentIds;
|
||||
return options.studentId ? [options.studentId] : [];
|
||||
}
|
||||
|
||||
export interface UserLike {
|
||||
@@ -42,7 +47,8 @@ export interface UserLike {
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
classId: string | null;
|
||||
/** 手机号;老库/老对象可能没有这个字段 */
|
||||
phone?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -52,7 +58,32 @@ export interface TeacherBriefLike {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface StudentLike extends UserLike {
|
||||
/** 学生简要信息(id + 学号 + 姓名) */
|
||||
export interface StudentBriefLike {
|
||||
id: string;
|
||||
studentNo: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生记录(`Student` 表)。
|
||||
*
|
||||
* 学生不是账号 —— 这里没有 `role`、没有 `passwordHash`,
|
||||
* 唯一的标识是 `studentNo`(学号)。
|
||||
*/
|
||||
export interface StudentLike {
|
||||
id: string;
|
||||
studentNo: string;
|
||||
name: string;
|
||||
classId: string | null;
|
||||
gender: string;
|
||||
guardianPhone: string;
|
||||
status: string;
|
||||
archivedYearId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
transferredAt?: Date | null;
|
||||
transferNote?: string;
|
||||
createdAt: Date;
|
||||
class?: { name: string; grade: string } | null;
|
||||
}
|
||||
|
||||
@@ -67,19 +98,78 @@ export function toUserDto(user: UserLike): UserDto {
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
phone: user.phone ?? '',
|
||||
createdAt: toIso(user.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStudentDto(user: StudentLike): StudentDto {
|
||||
/** 账号的会话主体(登录返回 / GET /auth/me) */
|
||||
export function toUserSessionUser(user: UserLike): SessionUser {
|
||||
return {
|
||||
...toUserDto(user),
|
||||
className: user.class?.name ?? null,
|
||||
grade: user.class?.grade ?? null,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
phone: user.phone ?? '',
|
||||
classId: null,
|
||||
createdAt: toIso(user.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** ClassHelper 班级端登录时用到的班级字段 */
|
||||
export interface ClassDeviceLike {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
studentGradeQueryEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassHelper 班级端的会话主体:`id` 与 `classId` 都是**班级 id**,
|
||||
* 角色是 `CLASS_DEVICE`(学生不是账号,因此这里不会出现学生身份)。
|
||||
*/
|
||||
export function toClassDeviceUser(record: ClassDeviceLike): SessionUser {
|
||||
return {
|
||||
id: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
className: record.name,
|
||||
grade: record.grade,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
|
||||
createdAt: toIso(record.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStudentDto(student: StudentLike): StudentDto {
|
||||
return {
|
||||
id: student.id,
|
||||
studentNo: student.studentNo,
|
||||
name: student.name,
|
||||
classId: student.classId,
|
||||
className: student.class?.name ?? null,
|
||||
grade: student.class?.grade ?? null,
|
||||
gender: (student.gender ?? '') as StudentDto['gender'],
|
||||
guardianPhone: student.guardianPhone ?? '',
|
||||
status: (student.status ?? 'active') as StudentDto['status'],
|
||||
archivedYearId: student.archivedYearId ?? null,
|
||||
archivedAt: toIsoOrNull(student.archivedAt),
|
||||
transferredAt: toIsoOrNull(student.transferredAt),
|
||||
transferNote: student.transferNote ?? '',
|
||||
createdAt: toIso(student.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** 学生简要信息(成绩、提交名单等引用位) */
|
||||
export function toStudentBrief(student: StudentBriefLike): StudentBrief {
|
||||
return { id: student.id, studentNo: student.studentNo, name: student.name };
|
||||
}
|
||||
|
||||
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
|
||||
return { id: user.id, name: user.name, username: user.username };
|
||||
}
|
||||
@@ -94,11 +184,20 @@ export interface ClassLike {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
/** 入学年份(届别);老数据可能为空 */
|
||||
enrollmentYear?: number | null;
|
||||
/** 班号;老数据可能为空 */
|
||||
classIndex?: number | null;
|
||||
teacherId: string;
|
||||
/** 本学期教学周数(班主任可调,默认 20) */
|
||||
termWeeks?: number;
|
||||
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
|
||||
/** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
|
||||
notificationChannel?: string | null;
|
||||
/** 班级端按学号查成绩的开关 */
|
||||
studentGradeQueryEnabled?: boolean | null;
|
||||
/** 毕业归档所属届别;null = 在读 */
|
||||
archivedYearId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
createdAt: Date;
|
||||
teacher?: TeacherBriefLike | null;
|
||||
_count?: {
|
||||
@@ -120,9 +219,15 @@ export function toClassDto(item: ClassLike): ClassDto {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
grade: item.grade,
|
||||
enrollmentYear: item.enrollmentYear ?? null,
|
||||
classIndex: item.classIndex ?? null,
|
||||
teacherId: item.teacherId,
|
||||
termWeeks: item.termWeeks ?? 20,
|
||||
notificationChannel: toNotificationChannel(item.notificationChannel),
|
||||
// 默认开启:老库补列时的默认值也是 true,两边保持一致
|
||||
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
|
||||
archivedYearId: item.archivedYearId ?? null,
|
||||
archivedAt: toIsoOrNull(item.archivedAt),
|
||||
createdAt: toIso(item.createdAt),
|
||||
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||
studentCount: item._count?.students ?? undefined,
|
||||
@@ -193,7 +298,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
|
||||
export interface HomeworkStatusLike {
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
completed: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -218,24 +323,24 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
|
||||
return {
|
||||
id: item.id,
|
||||
homeworkId: item.homeworkId,
|
||||
userId: item.userId,
|
||||
studentId: item.studentId,
|
||||
completed: item.completed,
|
||||
updatedAt: toIso(item.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
|
||||
* @param options.studentId 指定单个学生,用于填充 completed / homeworkStatus
|
||||
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找完成状态
|
||||
* @param options.withStatus 是否统计完成人数(教师视角)
|
||||
*/
|
||||
export function toHomeworkDto(
|
||||
item: HomeworkLike,
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
|
||||
): HomeworkDto {
|
||||
const statuses = item.statuses ?? [];
|
||||
const pool = resolvePool(options);
|
||||
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
|
||||
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.studentId)) : undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -261,7 +366,7 @@ export function toHomeworkDto(
|
||||
export interface NotificationReadLike {
|
||||
id: string;
|
||||
notificationId: string;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
readAt: Date;
|
||||
}
|
||||
|
||||
@@ -278,17 +383,17 @@ export interface NotificationLike {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户(普通学生账号)
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
|
||||
* @param options.studentId 指定单个学生
|
||||
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找已读状态
|
||||
* @param options.withStatus 是否统计已读人数(教师视角)
|
||||
*/
|
||||
export function toNotificationDto(
|
||||
item: NotificationLike,
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
|
||||
): NotificationDto {
|
||||
const reads = item.reads ?? [];
|
||||
const pool = resolvePool(options);
|
||||
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
|
||||
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.studentId)) : undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -311,13 +416,17 @@ export interface GradeLike {
|
||||
id: string;
|
||||
classId: string;
|
||||
courseId: string | null;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
examName: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
/** 等级口径:percent / letter / custom(老库可能为空,按 percent 兜底) */
|
||||
levelType?: string | null;
|
||||
/** 等级文本(老库可能为空) */
|
||||
level?: string | null;
|
||||
publishedAt: Date;
|
||||
course?: CourseBriefLike | null;
|
||||
student?: TeacherBriefLike | null;
|
||||
student?: StudentBriefLike | null;
|
||||
class?: { name: string } | null;
|
||||
}
|
||||
|
||||
@@ -326,13 +435,111 @@ export function toGradeDto(item: GradeLike): GradeDto {
|
||||
id: item.id,
|
||||
classId: item.classId,
|
||||
courseId: item.courseId,
|
||||
userId: item.userId,
|
||||
studentId: item.studentId,
|
||||
examName: item.examName,
|
||||
score: item.score,
|
||||
totalScore: item.totalScore,
|
||||
levelType: (item.levelType ?? 'percent') as GradeDto['levelType'],
|
||||
level: item.level ?? '',
|
||||
publishedAt: toIso(item.publishedAt),
|
||||
course: item.course ? toCourseBrief(item.course) : null,
|
||||
student: item.student ? toTeacherBrief(item.student) : null,
|
||||
student: item.student ? toStudentBrief(item.student) : null,
|
||||
className: item.class?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 调班 / 归档 */
|
||||
|
||||
export interface StudentTransferLike {
|
||||
id: string;
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
fromClassId: string | null;
|
||||
fromClassName: string;
|
||||
toClassId: string | null;
|
||||
toClassName: string;
|
||||
operatorId: string | null;
|
||||
operatorName: string;
|
||||
mode: string;
|
||||
note?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function toStudentTransferDto(item: StudentTransferLike): StudentTransferDto {
|
||||
return {
|
||||
id: item.id,
|
||||
studentId: item.studentId,
|
||||
studentNo: item.studentNo,
|
||||
studentName: item.studentName,
|
||||
fromClassId: item.fromClassId,
|
||||
fromClassName: item.fromClassName,
|
||||
toClassId: item.toClassId,
|
||||
toClassName: item.toClassName,
|
||||
operatorId: item.operatorId,
|
||||
operatorName: item.operatorName,
|
||||
mode: item.mode as StudentTransferDto['mode'],
|
||||
note: item.note ?? '',
|
||||
createdAt: toIso(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArchivedYearLike {
|
||||
id: string;
|
||||
enrollmentYear: number;
|
||||
graduationYear: number;
|
||||
name: string;
|
||||
note: string;
|
||||
operatorId: string | null;
|
||||
operatorName: string;
|
||||
classCount: number;
|
||||
studentCount: number;
|
||||
transferredCount: number;
|
||||
homeworkCount: number;
|
||||
notificationCount: number;
|
||||
archivedAt: Date;
|
||||
}
|
||||
|
||||
export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
|
||||
return {
|
||||
id: item.id,
|
||||
enrollmentYear: item.enrollmentYear,
|
||||
graduationYear: item.graduationYear,
|
||||
name: item.name,
|
||||
note: item.note,
|
||||
operatorId: item.operatorId,
|
||||
operatorName: item.operatorName,
|
||||
classCount: item.classCount,
|
||||
studentCount: item.studentCount,
|
||||
transferredCount: item.transferredCount,
|
||||
homeworkCount: item.homeworkCount,
|
||||
notificationCount: item.notificationCount,
|
||||
archivedAt: toIso(item.archivedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArchivedClassLike {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
enrollmentYear: number | null;
|
||||
classIndex: number | null;
|
||||
archivedAt: Date | null;
|
||||
teacher?: { name: string } | null;
|
||||
_count?: { students?: number; homeworks?: number; notifications?: number };
|
||||
}
|
||||
|
||||
export function toArchivedClassDto(item: ArchivedClassLike): ArchivedClassDto {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
grade: item.grade,
|
||||
enrollmentYear: item.enrollmentYear,
|
||||
classIndex: item.classIndex,
|
||||
headTeacherName: item.teacher?.name ?? null,
|
||||
studentCount: item._count?.students ?? 0,
|
||||
homeworkCount: item._count?.homeworks ?? 0,
|
||||
notificationCount: item._count?.notifications ?? 0,
|
||||
archivedAt: toIsoOrNull(item.archivedAt),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { isValidStudentNo, normalizeStudentNo } from '@classhelper/shared';
|
||||
|
||||
/** 通用路径参数::id */
|
||||
export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') });
|
||||
@@ -52,3 +53,43 @@ export const attachmentUrlSchema = z
|
||||
|
||||
/** 周次:1..30 */
|
||||
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
|
||||
|
||||
/**
|
||||
* 学号:学生的唯一标识与查询键。
|
||||
*
|
||||
* 只做**格式**校验(1~32 位数字/字母/`._-`),归一化(去空白)后入库;
|
||||
* 唯一性由 `Student.studentNo` 的唯一约束 + service 层的大小写无关查重共同保证。
|
||||
*/
|
||||
export const studentNoSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请填写学号')
|
||||
.max(32, '学号最多 32 位')
|
||||
.transform((value) => normalizeStudentNo(value))
|
||||
.refine((value) => isValidStudentNo(value), {
|
||||
message: '学号只能包含字母、数字与 . _ -(1~32 位)',
|
||||
});
|
||||
|
||||
/** 学生姓名 */
|
||||
export const studentNameSchema = z.string().trim().min(1, '请输入姓名').max(32);
|
||||
|
||||
/** 学生性别:'' / MALE / FEMALE */
|
||||
export const studentGenderSchema = z.enum(['', 'MALE', 'FEMALE']).optional();
|
||||
|
||||
/** 学生状态 */
|
||||
export const studentStatusSchema = z.enum(['active', 'graduated', 'transferred', 'inactive']).optional();
|
||||
|
||||
/** 家长手机号(可空) */
|
||||
export const guardianPhoneSchema = z.string().trim().max(32).optional();
|
||||
|
||||
/** 入学年份(届别) */
|
||||
export const enrollmentYearSchema = z.coerce.number().int().min(2000).max(2100);
|
||||
|
||||
/** 班号 */
|
||||
export const classIndexSchema = z.coerce.number().int().min(1).max(99);
|
||||
|
||||
/** 成绩等级口径 */
|
||||
export const gradeLevelTypeSchema = z.enum(['percent', 'letter', 'custom']);
|
||||
|
||||
/** 成绩等级文本(自定义等级也要能填,所以只限长度) */
|
||||
export const gradeLevelSchema = z.string().trim().max(16);
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user