feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
+38
View File
@@ -0,0 +1,38 @@
# dependencies
node_modules/
.pnpm-store/
# build output
dist/
dist-electron/
release/
*.tsbuildinfo
# generated code
packages/server/src/generated/
# env / secrets
.env
.env.local
.env.*.local
# local database
packages/server/prisma/*.db
packages/server/prisma/*.db-journal
packages/server/prisma/*.db-wal
packages/server/prisma/*.db-shm
# logs & caches
*.log
logs/
.cache/
.probe/
coverage/
eslint-report.json
# editor / os
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
dist-electron
release
packages/server/src/generated
pnpm-lock.yaml
.cache
.pnpm-store
.probe
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 110,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "ignore"
}
+328
View File
@@ -0,0 +1,328 @@
# 班级小助手(Class Helper)
班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与
**桌面客户端**(学生)三端。核心链路:
```
教师在 Web 端发布内容
│ REST API(JWT 鉴权)
▼
后端服务(Express + Prisma)
│ 写入数据库 + 按班级房间广播
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
```
## 当前交付范围
| 阶段 | 内容 | 状态 |
| ---- | ----------------------------------------------------------------------------------------- | --------------------------------------------- |
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO Client + 离线缓存) | ⏳ 待开发 |
| 5 | 三端联调脚本、打包命令、完整 README | 🟡 后端联调脚本与文档已完成,EXE 打包待阶段 4 |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
> **与原始提示词的两处偏差(已与你确认)**
>
> 1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia):正文 90% 的要求
> (Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron,
> 且本机没有 .NET SDK。
> 2. 数据库 **先用 SQLite(libSQL 内嵌)跑通 MVP,并预留 MySQL 切换**:本机没有可用 MySQL
> 实例;`docs/mysql.md` 给出云数据库切换的完整步骤。
## 目录结构
```
class-helper/
├── package.json # 根脚本(dev / build / db:* / verify:e2e)
├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单)
├── tsconfig.base.json # 共享 TS 基础配置
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
├── scripts/use-database.mjs # SQLite ⇄ MySQL provider 切换助手
├── docs/mysql.md # MySQL 切换与生产部署指南
└── packages/
├── shared/ # 三端共享:类型契约、常量、工具函数
│ └── src/{types,constants,utils,index}.ts
├── server/ # 后端服务
│ ├── prisma/schema.prisma # 数据模型
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
│ ├── prisma/seed.ts # 种子数据
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
│ ├── scripts/verify-e2e.mjs # 端到端验收脚本(54 项)
│ └── src/
│ ├── app.ts # Express 装配(健康检查 + 模块挂载)
│ ├── index.ts # 启动入口(HTTP + Socket.IO + 优雅退出)
│ ├── config/env.ts # 环境变量校验(zod)
│ ├── lib/ # db / http / jwt / password / access(RBAC) / mappers
│ ├── middleware/ # auth / error / validate
│ ├── realtime/ # socket.ts + bus.ts(事件总线)
│ └── modules/ # 功能模块 + registry.ts
└── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus)
└── src/{api,stores,router,layouts,views,styles}
```
## 技术栈
| 层 | 选型 | 版本 |
| ------ | ------------------------------------------------------------------ | ----------------------------------------------------- |
| 包管理 | pnpm workspace | 11.8.0 |
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
| 后端 | Node.js + Express | 24.18 / 5.2 |
| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`) |
| 实时 | Socket.IO | 4.8 |
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
| 校验 | zod | 4.6 |
| Web 端 | Vue 3 + Vite + Element Plus + Pinia + Vue Router + Axios + ECharts | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 / 6.1 |
> 运行环境要求:**Node.js ≥ 20.19**(Prisma 7 硬性要求,推荐 22/24)。
## 快速开始
```bash
# 1. 安装依赖(Node >= 20.19,pnpm >= 10)
pnpm install
# 2. 准备环境变量(后端)
cp packages/server/.env.example packages/server/.env
# → 至少修改 JWT_SECRET;本地开发可直接使用仓库内已有的 .env
# 3. 生成 Prisma Client 并建库、写入种子数据
pnpm db:generate
pnpm db:migrate
pnpm db:seed
# 4. 启动(后端 4000 + Web 端 5173 一起启动)
pnpm dev
```
启动后访问:
| 服务 | 地址 |
| ------------------ | -------------------------------- |
| Web 管理端(开发) | http://127.0.0.1:5173 |
| 后端 API | http://127.0.0.1:4000/api |
| 健康检查 | http://127.0.0.1:4000/api/health |
也可以分开启动:`pnpm dev:server`(仅后端)、`pnpm dev:web`(仅 Web 端)。
### 端到端验收脚本
```bash
# 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e
```
脚本会自动登录教师与学生账号,验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项,
并打印逐项结果与耗时(实测:通知 37ms、作业 26ms、成绩 25ms 到达,远优于 5 秒要求)。
## 默认账号与种子数据
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(1 管理员 / 2 教师 / 3 班级 / 15 学生 /
15 课程 / 90 条课表 / 15 份作业 / 12 条通知 / 90 条成绩)。
| 角色 | 用户名 | 密码 | 说明 |
| ------ | ------------------------- | ------------ | ------------------------------------------ |
| 管理员 | `admin` | `admin123` | 可访问全部班级 |
| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 |
| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 |
| 学生 | `student01` | `student123` | 高一(1)班 |
| 学生 | `student02` … `student05` | `student123` | 高一(1)班 |
| 学生 | `student06` … `student10` | `student123` | 高一(2)班 |
| 学生 | `student11` … `student15` | `student123` | 高二(3)班 |
- 学生初始密码可用 `DEFAULT_STUDENT_PASSWORD` 配置(默认 `123456`),
教师也可在「学生管理」里一键重置为默认密码。
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。
## 常用命令
| 命令 | 说明 |
| ------------------------------------------- | ------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm build` | 构建 shared + 后端(tsc 产出 `dist`) + Web 端(Vite 产出 `dist`) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
| `pnpm verify:e2e` | 端到端验收(需后端已启动) |
## REST API 一览
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer <token>`。
| 方法 | 路径 | 权限 | 说明 |
| --------------------------- | -------------------------------------------------------- | --------------- | ---------------------------------------------------------- |
| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码 |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) |
| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 |
| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) |
| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 |
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 |
| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 |
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) |
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) |
| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) |
| GET | `/homeworks/:id` | 班级可见 | 作业详情 |
| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) |
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`) |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 |
| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 |
| GET | `/grades/my` | 登录 | 个人成绩 |
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 |
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 |
| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) |
| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 |
| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 |
| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 |
| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 |
| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) |
| POST | `/teachers` | 管理员 | 新建教师账号 |
| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) |
## WebSocket 事件
连接方式:`io(url, { auth: { token } })`,握手阶段用 JWT 鉴权。
| 事件 | 方向 | 载荷 |
| ------------------------------------- | --------------- | ------------------------------------- |
| `connected` | 服务端 → 客户端 | `{ userId, role, rooms }` |
| `notification:new` | 服务端 → 客户端 | `NotificationDto` |
| `homework:new` | 服务端 → 客户端 | `HomeworkDto` |
| `homework:updated` | 服务端 → 客户端 | `HomeworkDto & { deleted?: boolean }` |
| `homework:status` | 服务端 → 客户端 | `HomeworkStatusDto & { classId }` |
| `grade:updated` | 服务端 → 客户端 | `GradeDto` |
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
房间规则(`@classhelper/shared` 的 `SOCKET_ROOMS`):
`class:{classId}`、`user:{userId}`、`role:{role}`、`students`、`teachers`。
客户端断线由 Socket.IO 自动重连(Web 端 store 与后续 EXE 端共用同一套事件名)。
## 权限模型(RBAC)
| 角色 | 可见范围 | 写权限 |
| --------- | ------------------------------------------------------------- | --------------------------------------- |
| `ADMIN` | 全部班级 | 全部 |
| `TEACHER` | 自己创建(`Class.teacherId`)或被分配(`ClassTeacher`)的班级 | 仅上述班级 |
| `STUDENT` | 仅自己所在班级(`User.classId` + `Enrollment`) | 无(仅能标记自己的作业状态 / 已读通知) |
实现要点:
- `src/middleware/auth.ts`:JWT 校验后会**回查数据库**,班级/角色变更立即生效。
- `src/lib/access.ts`:集中实现 `assertClassAccess` / `assertClassWritable` / `resolveClassScope`,
列表接口通过 scope 收敛数据(`classId in [...]`),单条接口直接断言。
- 越权请求统一返回 `403`,未登录返回 `401`(验收脚本已覆盖 10 项越权场景)。
## 模块化设计(可随时增删模块)
后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`:
```ts
// packages/server/src/modules/registry.ts
export const apiModules: ApiModule[] = [
authModule,
classesModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
gradesModule,
studentsModule,
teachersModule, // ← 新增模块只需在这里加一行
dashboardModule,
].filter((module) => module.enabled !== false);
```
- **新增模块**:新建 `modules/<name>/`(`*.schemas.ts` 校验、`*.service.ts` 业务、`*.module.ts` 路由),
在 registry 里加一行即可,`app.ts` 无需改动。
- **移除/下线模块**:删掉对应行,或把 `enabled` 置为 `false`,其接口立即从路由表消失。
- **实时推送解耦**:业务模块只调用 `realtime/bus.ts` 的 `emitToClass/emitToUser`,
不直接依赖 Socket.IO 实例,因此「移除实时通道」不影响业务代码。
- **共享契约**:所有 DTO 与类型集中在 `@classhelper/shared`,三端共用,避免契约漂移。
## 数据库
- 默认:SQLite(Prisma 7 的 libSQL driver adapter,数据文件 `packages/server/prisma/dev.db`)。
- 模型设计为跨库通用(无 enum、无 `@db.*` 原生类型),**切换到 MySQL 只需改 provider + 连接串**。
- 完整步骤见 [`docs/mysql.md`](docs/mysql.md):
```bash
pnpm db:switch:mysql
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
# 修改 packages/server/.env 的 DATABASE_PROVIDER / DATABASE_URL
pnpm db:migrate && pnpm db:seed
```
数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` /
`Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade`(共 11 张)。
## 验收标准对照
| 验收项 | 结果 | 证据 |
| --------------------------------------------- | --------- | ----------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
| 断网后客户端可查看缓存数据 | ⏳ 阶段 4 | 依赖 EXE 客户端(IndexedDB 缓存 + 联网后同步) |
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
| 能成功打包 Windows EXE | ⏳ 阶段 4 | 待 EXE 客户端完成后用 electron-builder 打包 |
| 提供完整 README(启动、构建、打包、默认账号) | 🟡 | 启动/构建/账号已完成;EXE 打包命令随阶段 4 补充 |
当前实测:`pnpm verify:e2e` → **54/54 项通过**;`pnpm typecheck`、`pnpm lint`、`pnpm build` 全部通过。
## 常见问题(本机环境已知坑)
1. **在 DSH 桌面端(Electron 宿主)里执行 pnpm 时,依赖的 `.bin` 不会被注入 PATH**
现象:`pnpm run <script>` 报 `'prisma' 不是内部或外部命令`。
原因:本机 pnpm 由 Electron 进程承载,bin 目录位于
`node_modules/.pnpm/node_modules/.bin`,未注入子进程 PATH。
规避:直接用 Node 调用 CLI 入口,例如
`node packages/server/node_modules/prisma/build/index.js generate`、
`node packages/web-admin/node_modules/vite/bin/vite.js build`。
在普通终端(非 Electron 宿主)中 `pnpm <script>` 一切正常。
2. **原生模块会按 Electron ABI 构建**
同一原因会让 `node-gyp` / `prebuild-install` 以 `runtime=electron` 为目标,
装出的 `.node` 在 Node 进程里无法加载。本项目因此改用 **libSQL 适配器**
(`@libsql/win32-x64-msvc` 为 npm 预编译包,无需本地编译),彻底规避该问题。
3. **本机 TLS 中间人证书导致依赖二进制下载失败**
现象:`prebuild-install warn install unable to verify the first certificate`。
规避:安装时设置 `NODE_OPTIONS=--use-system-ca`(Node ≥ 22.15 支持),
并确保 `npm_config_cache` 指向可写目录(如仓库内 `.cache/npm-cache`)。
4. **受限沙箱下 Vite 构建报 `spawn EPERM`**
Vite 在 Windows 上通过 `execFile` 解析真实路径,需要放开子进程管道限制后再构建。
5. **Prisma 7 与旧版本差异**
`prisma` 的 npm `latest` 标签当前指向 `8.0.0-rc`,本项目**显式锁定 7.10.0**;
生成器为 `prisma-client`(输出到 `src/generated/prisma`,已 gitignore),
连接串在 `prisma.config.ts` 中配置,且必须通过 driver adapter 接入数据库。
## 后续计划(阶段 4-6)
1. **阶段 4 · EXE 客户端**:`packages/desktop-client`(Electron 主进程 + Vue 渲染进程),
登录页(服务器地址/用户名/密码)、侧边栏(课表/作业/通知/成绩/设置)、
Socket.IO 实时更新、IndexedDB 离线缓存与联网后自动同步、`electron-builder` 打包 Windows EXE。
2. **阶段 5**:三端联调脚本(含 EXE 端时延与断网恢复用例)、根目录一键打包命令。
3. **阶段 6**:打包产物分发说明、云端 MySQL 部署清单(Docker Compose)、CI 流水线。
+90
View File
@@ -0,0 +1,90 @@
# MySQL 切换指南
本项目默认使用 **SQLite(libSQL 内嵌模式)** 跑本地开发,数据模型与访问层已按
MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储。
## 为什么默认是 SQLite
- 本地零依赖即可跑通全部功能(无需安装数据库服务)
- `schema.prisma` 刻意只使用跨库通用类型
(`String` / `Int` / `Float` / `Boolean` / `DateTime`,无 `enum`、无 `@db.*` 原生类型)
## 设计上的兼容约定
| 约定 | 说明 |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 不使用 Prisma `enum` | 角色(`ADMIN/TEACHER/STUDENT`)与优先级(`LOW/NORMAL/HIGH/URGENT`)以 `String` 存储,由 zod 在 API 层校验,两种数据库行为完全一致 |
| 主键为 `String @default(cuid())` | 不依赖自增,便于多端离线生成与合并 |
| 时间统一 `DateTime` | 由应用层与驱动适配器负责时区,不使用数据库原生时间函数 |
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
## 切换步骤
### 1. 修改 provider
```bash
# 自动把 prisma/schema.prisma 的 provider 改成 mysql
node scripts/use-database.mjs mysql
```
### 2. 安装 MySQL 驱动适配器
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。
```bash
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
```
`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。
### 3. 配置环境变量
编辑 `packages/server/.env`:
```dotenv
DATABASE_PROVIDER=mysql
DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
```
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
### 4. 重建迁移并生成客户端
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
```bash
# 删除 SQLite 迁移历史(迁移文件是方言相关的)
rm -rf packages/server/prisma/migrations
# 重新生成并应用(需要目标库可连接,且账号有建表权限)
pnpm db:migrate
# 初始化演示数据
pnpm db:seed
```
也可以先只生成 SQL 供 DBA 审核:
```bash
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
```
### 5. 回到 SQLite
```bash
node scripts/use-database.mjs sqlite
# 同步 .env:DATABASE_PROVIDER=sqlite、DATABASE_URL="file:./prisma/dev.db"
rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
pnpm db:migrate
```
## 生产部署注意事项
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
与 Prisma 6 默认值不同(例如默认无连接超时)。建议通过适配器参数显式设置连接上限与时区。
2. **时区**:`Grade.publishedAt`、`Homework.dueAt` 等字段建议在数据库会话中统一为 UTC,
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
+77
View File
@@ -0,0 +1,77 @@
import tseslint from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
const sharedGlobals = {
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
fetch: 'readonly',
global: 'readonly',
};
export default tseslint.config(
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/dist-electron/**',
'**/release/**',
'**/coverage/**',
'**/src/generated/**',
'**/.cache/**',
'**/.pnpm-store/**',
'**/.probe/**',
'**/*.d.ts',
],
},
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
ecmaVersion: 'latest',
sourceType: 'module',
extraFileExtensions: ['.vue'],
},
globals: sharedGlobals,
},
},
{
files: ['**/*.{ts,mts,cts,js,mjs,cjs,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: sharedGlobals,
},
rules: {
'no-console': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
// Vue 组件名使用多单词(Element Plus 视图文件均为 XxxView.vue)
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': 'off',
'vue/attributes-order': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/first-attribute-linebreak': 'off',
},
},
);
+39
View File
@@ -0,0 +1,39 @@
{
"name": "class-helper",
"version": "0.1.0",
"private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"build:shared": "pnpm --filter @classhelper/shared build",
"dev": "pnpm build:shared && concurrently -k -n shared,server,web -c cyan,green,magenta \"pnpm --filter @classhelper/shared dev\" \"pnpm --filter @classhelper/server dev\" \"pnpm --filter @classhelper/web-admin dev\"",
"dev:server": "pnpm build:shared && pnpm --filter @classhelper/server dev",
"dev:web": "pnpm build:shared && pnpm --filter @classhelper/web-admin dev",
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"",
"format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"",
"db:generate": "pnpm --filter @classhelper/server db:generate",
"db:migrate": "pnpm --filter @classhelper/server db:migrate",
"db:seed": "pnpm --filter @classhelper/server db:seed",
"db:reset": "pnpm --filter @classhelper/server db:reset",
"db:studio": "pnpm --filter @classhelper/server db:studio",
"db:switch:mysql": "node scripts/use-database.mjs mysql",
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e"
},
"devDependencies": {
"concurrently": "^10.0.5",
"eslint": "^10.10.0",
"eslint-plugin-vue": "^10.11.0",
"prettier": "^3.9.6",
"typescript": "~5.9.3",
"typescript-eslint": "^8.70.0",
"vue-eslint-parser": "^10.4.1"
}
}
+39
View File
@@ -0,0 +1,39 @@
# ---------------------------------------------------------------------------
# 班级小助手 - 后端服务环境变量
# 复制为 .env 后按需修改:cp .env.example .env
# ---------------------------------------------------------------------------
# 运行环境:development | test | production
NODE_ENV=development
# HTTP / WebSocket 监听端口
PORT=4000
# 数据库类型:sqlite | mysql
# 说明:sqlite 用于本地开发;mysql 用于云端统一存储(见 docs/mysql.md)
DATABASE_PROVIDER=sqlite
# SQLite:相对路径以 packages/server 为基准
DATABASE_URL="file:./prisma/dev.db"
# MySQL 示例(切换时替换上面一行,并执行 pnpm db:switch:mysql):
# DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
# JWT 签名密钥:必须修改,建议 `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
JWT_SECRET=change-me-to-a-random-48-bytes-hex-string
# JWT 有效期,例如 7d / 12h
JWT_EXPIRES_IN=7d
# 允许跨域的来源,多个用英文逗号分隔;* 表示全部允许(开发环境)
CORS_ORIGIN=*
# bcrypt 加密强度
BCRYPT_ROUNDS=10
# 教师新增学生账号时未指定密码所使用的初始密码
DEFAULT_STUDENT_PASSWORD=123456
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
TERM_START_DATE=2026-02-23
# 日志级别:debug | info | warn | error
LOG_LEVEL=info
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@classhelper/server",
"version": "0.1.0",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"start:tsx": "tsx src/index.ts",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio",
"verify:e2e": "node scripts/verify-e2e.mjs"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0",
"@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"socket.io": "^4.8.3",
"zod": "^4.6.2"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.20.2",
"prisma": "7.10.0",
"socket.io-client": "^4.8.3",
"tsx": "^4.23.13",
"typescript": "~5.9.3"
}
}
+28
View File
@@ -0,0 +1,28 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
import { defineConfig } from 'prisma/config';
/**
* Prisma 7 使用 prisma.config.ts 取代了旧的 package.json#prisma 配置块,
* 并且 datasource 的 url 从 schema.prisma 迁移到这里。
*
* 注意:Prisma 7 的生成器不再自动加载 .env,因此这里显式加载
* packages/server/.env(以 config 文件所在目录为基准,避免依赖 cwd)。
*/
const configDir = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.join(configDir, '.env') });
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
// 直接指向 tsx 的入口,避免依赖 shell 的 PATH 里是否有 node_modules/.bin
seed: 'node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts',
},
datasource: {
// SQLite:file:./prisma/dev.db 会由 src/config/env.ts 解析为绝对路径;
// MySQL:mysql://user:password@host:3306/classhelper
url: process.env.DATABASE_URL ?? 'file:./prisma/dev.db',
},
});
@@ -0,0 +1,209 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'STUDENT',
"classId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "User_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"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
);
-- CreateTable
CREATE TABLE "ClassTeacher" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClassTeacher_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ClassTeacher_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Enrollment" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"classId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Enrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Enrollment_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Course" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"classId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Course_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Course_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Schedule" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"dayOfWeek" INTEGER NOT NULL,
"startTime" TEXT NOT NULL,
"endTime" TEXT NOT NULL,
"location" TEXT,
"weekStart" INTEGER NOT NULL DEFAULT 1,
"weekEnd" INTEGER NOT NULL DEFAULT 20,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Schedule_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Schedule_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Homework" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"courseId" TEXT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachmentUrl" TEXT,
"dueAt" DATETIME,
"createdBy" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Homework_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Homework_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Homework_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "HomeworkStatus" (
"id" TEXT NOT NULL PRIMARY KEY,
"homeworkId" TEXT NOT NULL,
"userId" 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_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"createdBy" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Notification_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Notification_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "NotificationRead" (
"id" TEXT NOT NULL PRIMARY KEY,
"notificationId" TEXT NOT NULL,
"userId" 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_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Grade" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"courseId" TEXT,
"userId" 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_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE INDEX "User_classId_idx" ON "User"("classId");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
-- CreateIndex
CREATE INDEX "ClassTeacher_teacherId_idx" ON "ClassTeacher"("teacherId");
-- CreateIndex
CREATE UNIQUE INDEX "ClassTeacher_classId_teacherId_key" ON "ClassTeacher"("classId", "teacherId");
-- CreateIndex
CREATE INDEX "Enrollment_classId_idx" ON "Enrollment"("classId");
-- CreateIndex
CREATE UNIQUE INDEX "Enrollment_userId_classId_key" ON "Enrollment"("userId", "classId");
-- CreateIndex
CREATE INDEX "Course_classId_idx" ON "Course"("classId");
-- CreateIndex
CREATE UNIQUE INDEX "Course_classId_name_key" ON "Course"("classId", "name");
-- CreateIndex
CREATE INDEX "Schedule_classId_dayOfWeek_idx" ON "Schedule"("classId", "dayOfWeek");
-- CreateIndex
CREATE INDEX "Schedule_courseId_idx" ON "Schedule"("courseId");
-- CreateIndex
CREATE INDEX "Homework_classId_createdAt_idx" ON "Homework"("classId", "createdAt");
-- CreateIndex
CREATE INDEX "Homework_courseId_idx" ON "Homework"("courseId");
-- CreateIndex
CREATE INDEX "Homework_dueAt_idx" ON "Homework"("dueAt");
-- CreateIndex
CREATE INDEX "HomeworkStatus_userId_idx" ON "HomeworkStatus"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "HomeworkStatus_homeworkId_userId_key" ON "HomeworkStatus"("homeworkId", "userId");
-- CreateIndex
CREATE INDEX "Notification_classId_createdAt_idx" ON "Notification"("classId", "createdAt");
-- CreateIndex
CREATE INDEX "NotificationRead_userId_idx" ON "NotificationRead"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationRead_notificationId_userId_key" ON "NotificationRead"("notificationId", "userId");
-- CreateIndex
CREATE INDEX "Grade_userId_publishedAt_idx" ON "Grade"("userId", "publishedAt");
-- CreateIndex
CREATE INDEX "Grade_classId_examName_idx" ON "Grade"("classId", "examName");
-- CreateIndex
CREATE INDEX "Grade_courseId_idx" ON "Grade"("courseId");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+235
View File
@@ -0,0 +1,235 @@
// 班级小助手 - 数据模型
//
// 设计说明:
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
// 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型,
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 版本见同目录 schema.mysql.prisma,切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
// 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
/// 用户:教师 / 管理员 / 学生统一存储
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
name String
role String @default("STUDENT")
classId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 学生的主班级(一个学生属于一个主班级)
class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull)
// 教师创建(负责)的班级
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])
}
/// 班级
model Class {
id String @id @default(cuid())
name String
grade String
teacherId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
students User[] @relation("ClassStudents")
teachers ClassTeacher[]
courses Course[]
schedules Schedule[]
homeworks Homework[]
notifications Notification[]
grades Grade[]
enrollments Enrollment[]
@@index([teacherId])
}
/// 教师-班级 分配关系(一个教师可管理多个班级)
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])
}
/// 课程
model Course {
id String @id @default(cuid())
name String
teacherId String
classId String
createdAt DateTime @default(now())
teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
schedules Schedule[]
homeworks Homework[]
grades Grade[]
@@unique([classId, name])
@@index([classId])
}
/// 课表条目
model Schedule {
id String @id @default(cuid())
classId String
courseId String
/// 1=周一 ... 7=周日
dayOfWeek Int
/// HH:mm
startTime String
/// HH:mm
endTime String
location String?
/// 学期起始周次(含)
weekStart Int @default(1)
/// 学期结束周次(含)
weekEnd Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@index([classId, dayOfWeek])
@@index([courseId])
}
/// 作业
model Homework {
id String @id @default(cuid())
classId String
courseId String?
title String
content String
attachmentUrl String?
dueAt DateTime?
createdBy String
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)
creator User @relation("HomeworkCreator", fields: [createdBy], references: [id], onDelete: Cascade)
statuses HomeworkStatus[]
@@index([classId, createdAt])
@@index([courseId])
@@index([dueAt])
}
/// 学生作业完成状态
model HomeworkStatus {
id String @id @default(cuid())
homeworkId String
userId 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)
@@unique([homeworkId, userId])
@@index([userId])
}
/// 通知
model Notification {
id String @id @default(cuid())
classId String
title String
content String
priority String @default("NORMAL")
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
creator User @relation("NotificationCreator", fields: [createdBy], references: [id], onDelete: Cascade)
reads NotificationRead[]
@@index([classId, createdAt])
}
/// 通知已读记录
model NotificationRead {
id String @id @default(cuid())
notificationId String
userId String
readAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([notificationId, userId])
@@index([userId])
}
/// 成绩
model Grade {
id String @id @default(cuid())
classId String
courseId String?
userId String
examName String
score Float
totalScore Float @default(100)
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)
@@index([userId, publishedAt])
@@index([classId, examName])
@@index([courseId])
}
+365
View File
@@ -0,0 +1,365 @@
/**
* 种子数据:pnpm db:seed
*
* 会清空业务表后重建一套完整演示数据:
* 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩
*
* 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员
* teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任)
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
* student01..student15 / student123
*/
import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
import { MAX_TERM_WEEK } from '../src/lib/term.js';
const STUDENT_NAMES = [
'王小明',
'李思远',
'张欣怡',
'刘子涵',
'陈嘉豪',
'杨雨欣',
'赵依然',
'黄浩然',
'周思彤',
'吴俊杰',
'徐若曦',
'孙晨曦',
'马文博',
'朱佳琪',
'胡一鸣',
];
const COURSE_NAMES = ['语文', '数学', '英语', '物理', '化学'];
/** 每天的上课节次模板(dayOfWeek 1-5) */
const PERIOD_TEMPLATE = [
{ startTime: '08:00', endTime: '08:45' },
{ startTime: '08:55', endTime: '09:40' },
{ startTime: '10:00', endTime: '10:45' },
{ startTime: '10:55', endTime: '11:40' },
{ startTime: '14:00', endTime: '14:45' },
{ startTime: '14:55', endTime: '15:40' },
];
const CLASS_ROOMS = ['教学楼 A301', '教学楼 A302', '实验楼 B205'];
/** 固定种子的伪随机数,保证每次 seed 结果一致 */
function createRandom(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
}
async function resetDatabase(): Promise<void> {
// 顺序敏感:先删子表,再删主表
await prisma.grade.deleteMany();
await prisma.homeworkStatus.deleteMany();
await prisma.homework.deleteMany();
await prisma.notificationRead.deleteMany();
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.user.deleteMany();
await prisma.class.deleteMany();
}
async function main(): Promise<void> {
logger.info('开始写入种子数据...');
if (process.env.NODE_ENV === 'production') {
throw new Error('拒绝在生产环境执行 seed(NODE_ENV=production)');
}
await resetDatabase();
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
const [adminHash, teacherHash, studentHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
hashPassword('student123'),
]);
const admin = await prisma.user.create({
data: { username: 'admin', name: '系统管理员', role: 'ADMIN', passwordHash: adminHash },
});
const teacher1 = await prisma.user.create({
data: { username: 'teacher1', name: '张老师', role: 'TEACHER', passwordHash: teacherHash },
});
const teacher2 = await prisma.user.create({
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: { name: seed.name, grade: seed.grade, teacherId: seed.teacherId },
});
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({
data: {
username: `student${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id,
passwordHash: studentHash,
},
});
students.push(student);
await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
}
// ---------------------------------------------------------------- 课程
const coursesByClass = new Map<string, { id: string; name: string }[]>();
for (const targetClass of classes) {
const created = [];
for (const courseName of COURSE_NAMES) {
const course = await prisma.course.create({
data: { name: courseName, classId: targetClass.id, teacherId: targetClass.teacherId },
});
created.push({ id: course.id, name: course.name });
}
coursesByClass.set(targetClass.id, created);
}
// ---------------------------------------------------------------- 课表
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
for (let dayOfWeek = 1; dayOfWeek <= 5; dayOfWeek += 1) {
for (const [periodIndex, period] of PERIOD_TEMPLATE.entries()) {
const course = classCourses[(dayOfWeek + periodIndex) % classCourses.length];
if (!course) continue;
// 前 2 节限制在第 1 周(演示按周筛选)
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: course.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
},
});
}
}
}
// ---------------------------------------------------------------- 作业
const now = Date.now();
const day = 86_400_000;
const homeworkSeeds = [
{
title: '《劝学》全文背诵',
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
dueInDays: 3,
courseIndex: 0,
},
{
title: '数学必修一 3.2 习题',
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
dueInDays: 1,
courseIndex: 1,
},
{
title: '英语周记一篇',
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
dueInDays: 5,
courseIndex: 2,
},
{
title: '物理实验报告',
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
dueInDays: -2,
courseIndex: 3,
},
{
title: '化学方程式默写',
content: '默写第一至第三章全部化学方程式,家长签字。',
dueInDays: -1,
courseIndex: 4,
},
];
const homeworks = [];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
for (const seed of homeworkSeeds) {
const course = classCourses[seed.courseIndex % classCourses.length];
const homework = await prisma.homework.create({
data: {
classId: targetClass.id,
courseId: course?.id ?? null,
title: `${seed.title}`,
content: seed.content,
attachmentUrl:
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
dueAt: new Date(now + seed.dueInDays * day),
createdBy: targetClass.teacherId,
},
});
homeworks.push(homework);
}
}
// 部分学生已完成部分作业
const studentsByClass = new Map<string, typeof students>();
for (const student of students) {
if (!student.classId) continue;
const bucket = studentsByClass.get(student.classId) ?? [];
bucket.push(student);
studentsByClass.set(student.classId, bucket);
}
for (const homework of homeworks) {
const classmates = studentsByClass.get(homework.classId) ?? [];
for (const [index, student] of classmates.entries()) {
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 },
});
}
}
// ---------------------------------------------------------------- 通知
const notificationSeeds = [
{
title: '关于期中考试安排的通知',
content: '期中考试定于下月 8 日至 10 日进行,请同学们提前复习,考试安排详见班级公告栏。',
priority: 'HIGH',
daysAgo: 0.2,
},
{
title: '明天下午临时调课',
content: '因教师外出教研,明天下午第 5、6 节课与周五下午对调,请相互转告。',
priority: 'URGENT',
daysAgo: 0.6,
},
{
title: '校运动会报名开始',
content: '本届校运动会开始报名,每人限报 2 个项目,请到体育委员处登记。',
priority: 'NORMAL',
daysAgo: 2,
},
{
title: '教室卫生值日表更新',
content: '新的值日表已张贴在教室后墙,请各小组按时完成值日。',
priority: 'LOW',
daysAgo: 4,
},
];
for (const targetClass of classes) {
for (const seed of notificationSeeds) {
const notification = await prisma.notification.create({
data: {
classId: targetClass.id,
title: seed.title,
content: seed.content,
priority: seed.priority,
createdBy: targetClass.teacherId,
createdAt: new Date(now - seed.daysAgo * day),
},
});
// 第一条通知:部分学生已读,用于演示未读红点
if (seed.priority === 'LOW') {
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 },
});
}
}
}
}
// ---------------------------------------------------------------- 成绩
const examNames = ['第一次月考', '期中考试'];
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 student of classmates) {
const base = 60 + random() * 38;
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
await prisma.grade.create({
data: {
classId: targetClass.id,
courseId: course.id,
userId: student.id,
examName,
score,
totalScore: 100,
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
},
});
}
}
}
}
const counts = {
用户: await prisma.user.count(),
班级: await prisma.class.count(),
课程: await prisma.course.count(),
课表: await prisma.schedule.count(),
作业: await prisma.homework.count(),
通知: await prisma.notification.count(),
成绩: await prisma.grade.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('学生 student01 / student123 (高一(1)班)');
console.log('学生 student06 / student123 (高一(2)班)');
console.log('学生 student11 / student123 (高二(3)班)');
console.log('学生 student15 / student123 (高二(3)班)');
console.log('==========================================\n');
}
main()
.catch((error) => {
logger.error('种子数据写入失败', error);
process.exitCode = 1;
})
.finally(async () => {
await disconnectPrisma();
});
+536
View File
@@ -0,0 +1,536 @@
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用
*
* 用法:
* 1) 另开终端启动后端:pnpm dev:server
* 2) 执行:pnpm verify:e2e
*
* 环境变量:
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000
*/
import { io } from 'socket.io-client';
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
const TIME_LIMIT_MS = 5000;
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
const mark = ok ? '\u2713' : '\u2717';
console.log(`${mark} ${name}${detail ? ` (${detail})` : ''}`);
}
async function api(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${BASE_URL}/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待事件 ${event} 超时(${timeoutMs}ms)`)), timeoutMs);
socket.once(event, (payload) => {
clearTimeout(timer);
resolve({ payload, elapsed: Date.now() - startedAt });
});
});
}
async function main() {
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health');
record(
'后端健康检查 /api/health',
health.status === 200 && health.payload?.data?.status === 'ok',
`status=${health.status} db=${health.payload?.data?.database?.connected}`,
);
if (health.status !== 200) {
console.log('\n后端未启动,请先运行 pnpm dev:server\n');
process.exit(1);
}
console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`);
// ---------------------------------------------------------------- 1. 登录
const teacherLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher1', password: 'teacher123' },
});
const teacherToken = teacherLogin.payload?.data?.token;
record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`);
if (!teacherToken) process.exit(1);
const studentLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'student01', password: 'student123' },
});
const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user;
record('学生登录 student01', Boolean(studentToken), `classId=${studentUser?.classId}`);
const wrongPassword = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher1', password: 'bad-password' },
});
record('错误密码被拒绝(401)', wrongPassword.status === 401, `status=${wrongPassword.status}`);
// ---------------------------------------------------------------- 2. 数据准备
const myClasses = await api('/classes', { token: teacherToken });
const classes = myClasses.payload?.data ?? [];
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0];
const classId = ownClass?.id;
if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
process.exit(1);
}
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离
const teacher2Login = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
const foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
record(
'教师权限隔离测试数据就绪',
Boolean(foreignClass),
foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级',
);
const studentList = await api(`/students?classId=${classId}`, { token: teacherToken });
const classmates = studentList.payload?.data ?? [];
const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0];
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
const courseId = courses.payload?.data?.[0]?.id ?? null;
record('教师获取课程列表', Boolean(courseId), `course=${courses.payload?.data?.[0]?.name ?? '-'}`);
const weekGrid = await api(`/schedules/grid?classId=${classId}&week=1`, { token: studentToken });
const totalSlots = (weekGrid.payload?.data?.columns ?? []).reduce((sum, col) => sum + col.items.length, 0);
record(
'学生按周查看课表(week=1)',
weekGrid.status === 200 && totalSlots > 0,
`第 1 周 ${totalSlots} 节课`,
);
// ---------------------------------------------------------------- 3. WebSocket 连接
const socket = io(BASE_URL, {
auth: { token: studentToken, clientType: 'desktop' },
transports: ['websocket'],
reconnection: false,
});
const connected = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), TIME_LIMIT_MS);
socket.on('connected', (payload) => {
clearTimeout(timer);
resolve(payload);
});
socket.on('connect_error', (error) => {
clearTimeout(timer);
resolve({ error: error.message });
});
});
record(
'学生端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
);
// ---------------------------------------------------------------- 4. 通知实时推送
const notificationWait = waitForEvent(socket, 'notification:new');
const createdNotification = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `联调验证通知 ${new Date().toISOString()}`,
content: '这是端到端验证脚本自动创建的通知。',
priority: 'HIGH',
},
});
record('教师发布通知', createdNotification.status === 201, `status=${createdNotification.status}`);
try {
const { payload, elapsed } = await notificationWait;
record('学生端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 notification:new', false, error.message);
}
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
const homeworkWait = waitForEvent(socket, 'homework:new');
const createdHomework = await api('/homeworks', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
title: `联调验证作业 ${new Date().toISOString()}`,
content: '请完成验证脚本创建的作业。',
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
},
});
const homeworkId = createdHomework.payload?.data?.id;
record('教师发布作业', createdHomework.status === 201, `status=${createdHomework.status}`);
try {
const { payload, elapsed } = await homeworkWait;
record('学生端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 homework:new', false, error.message);
}
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
method: 'PATCH',
token: studentToken,
body: { completed: true },
});
record(
'学生标记作业完成',
statusUpdate.status === 200 && statusUpdate.payload?.data?.completed === true,
`status=${statusUpdate.status}`,
);
const myHomeworks = await api(`/homeworks?classId=${classId}`, { token: studentToken });
const markedHomework = (myHomeworks.payload?.data ?? []).find((item) => item.id === homeworkId);
record(
'学生作业列表回显完成状态',
markedHomework?.completed === true,
`completed=${markedHomework?.completed}`,
);
// ---------------------------------------------------------------- 6. 成绩实时推送
const gradeWait = waitForEvent(socket, 'grade:updated');
const createdGrade = await api('/grades', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
userId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88,
totalScore: 100,
},
});
record('教师录入成绩', createdGrade.status === 201, `status=${createdGrade.status}`);
if (targetStudent?.id === studentUser?.id) {
try {
const { payload, elapsed } = await gradeWait;
record(
'学生端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
);
} catch (error) {
record('学生端 5 秒内收到 grade:updated', false, error.message);
}
} else {
gradeWait.catch(() => {});
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
}
const myGrades = await api('/grades/my', { token: studentToken });
record(
'学生查看个人成绩 /grades/my',
(myGrades.payload?.data ?? []).length > 0,
`共 ${myGrades.payload?.data?.length ?? 0} 条`,
);
// ---------------------------------------------------------------- 6.1 Web 管理端依赖的接口全覆盖
const classDetail = await api(`/classes/${classId}`, { token: teacherToken });
record(
'班级详情(学生/课程/协作教师)',
classDetail.status === 200 &&
Array.isArray(classDetail.payload?.data?.students) &&
Array.isArray(classDetail.payload?.data?.courses),
`学生 ${classDetail.payload?.data?.students?.length ?? 0} 人 · 课程 ${classDetail.payload?.data?.courses?.length ?? 0} 门`,
);
const teacherList = await api('/teachers', { token: teacherToken });
record(
'教师列表 /teachers',
(teacherList.payload?.data ?? []).length >= 2,
`共 ${teacherList.payload?.data?.length ?? 0} 人`,
);
const summary = await api('/dashboard/summary', { token: teacherToken });
record(
'仪表盘汇总 /dashboard/summary',
summary.status === 200 && (summary.payload?.data?.classCount ?? 0) > 0,
`班级 ${summary.payload?.data?.classCount ?? 0} · 学生 ${summary.payload?.data?.studentCount ?? 0}`,
);
const term = await api('/dashboard/term', { token: teacherToken });
record(
'学期信息 /dashboard/term',
term.status === 200 && (term.payload?.data?.currentWeek ?? 0) > 0,
`当前第 ${term.payload?.data?.currentWeek} 周`,
);
const createdCourse = await api('/courses', {
method: 'POST',
token: teacherToken,
body: { name: `联调验证课程 ${Date.now()}`, classId },
});
const createdCourseId = createdCourse.payload?.data?.id;
record('新增课程 POST /courses', createdCourse.status === 201, `status=${createdCourse.status}`);
if (createdCourseId) {
const removedCourse = await api(`/courses/${createdCourseId}`, { method: 'DELETE', token: teacherToken });
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
}
const assigned = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: teacherToken,
body: { 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',
token: teacherToken,
});
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
const tempUsername = `e2e_student_${Date.now()}`;
const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST',
token: teacherToken,
body: { username: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
record(
'学生名单包含新加入学生',
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId),
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
);
const resetResult = await api(`/students/${addedStudentId}/reset-password`, {
method: 'POST',
token: teacherToken,
body: {},
});
record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`);
const renamed = await api(`/students/${addedStudentId}`, {
method: 'PATCH',
token: teacherToken,
body: { name: '联调学生(已改名)' },
});
record(
'修改学生信息',
renamed.status === 200 && renamed.payload?.data?.name?.includes('已改名'),
`name=${renamed.payload?.data?.name}`,
);
const removedStudent = await api(`/classes/${classId}/students/${addedStudentId}`, {
method: 'DELETE',
token: teacherToken,
});
record('班级移出学生', removedStudent.status === 200, `status=${removedStudent.status}`);
// 移出后学生已无班级,按权限规则只有管理员可以删除该账号
const teacherDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: teacherToken });
record('教师删除未分班学生被拒绝(403)', teacherDelete.status === 403, `status=${teacherDelete.status}`);
const adminLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'admin', password: 'admin123' },
});
const adminToken = adminLogin.payload?.data?.token;
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
const adminDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: adminToken });
record('管理员删除学生账号', adminDelete.status === 200, `status=${adminDelete.status}`);
}
const bulkResult = await api('/grades/bulk', {
method: 'POST',
token: teacherToken,
body: {
classId,
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 })),
},
});
record(
'批量录入成绩 POST /grades/bulk',
bulkResult.status === 201,
`录入 ${bulkResult.payload?.data?.count ?? 0} 条`,
);
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record(
'成绩统计 /grades/stats',
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
);
const unread = await api('/notifications/unread-count', { token: studentToken });
record(
'未读数 /notifications/unread-count',
unread.status === 200,
`未读 ${unread.payload?.data?.count ?? 0} 条`,
);
const markedRead = await api(`/notifications/${createdNotification.payload?.data?.id}/read`, {
method: 'POST',
token: studentToken,
});
record('标记通知已读', markedRead.status === 200, `status=${markedRead.status}`);
const readAll = await api('/notifications/read-all', { method: 'POST', token: studentToken, body: {} });
record('全部标为已读', readAll.status === 200, `本次标记 ${readAll.payload?.data?.marked ?? 0} 条`);
const pendingOnly = await api('/homeworks?pendingOnly=true', { token: studentToken });
const allPending = (pendingOnly.payload?.data ?? []).every((item) => item.completed !== true);
record(
'学生查询未完成作业(pendingOnly)',
pendingOnly.status === 200 && allPending,
`共 ${pendingOnly.payload?.data?.length ?? 0} 条`,
);
const homeworkDetail = await api(`/homeworks/${homeworkId}`, { token: studentToken });
record('作业详情 GET /homeworks/:id', homeworkDetail.status === 200, `status=${homeworkDetail.status}`);
const updatedGrade = await api(`/grades/${createdGrade.payload?.data?.id}`, {
method: 'PATCH',
token: teacherToken,
body: { score: 95 },
});
record(
'修改成绩 PATCH /grades/:id',
updatedGrade.status === 200 && updatedGrade.payload?.data?.score === 95,
`score=${updatedGrade.payload?.data?.score}`,
);
const dashboardForStudent = await api('/dashboard/summary', { token: studentToken });
record(
'学生仪表盘汇总',
dashboardForStudent.status === 200 && (dashboardForStudent.payload?.data?.classCount ?? 0) === 1,
`可见班级 ${dashboardForStudent.payload?.data?.classCount ?? 0} 个`,
);
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
record(
'学生访问其他班级被拒绝(403)',
studentCrossClass.status === 403,
`status=${studentCrossClass.status}`,
);
const studentCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: studentToken });
record(
'学生跨班查询作业被拒绝(403)',
studentCrossHomework.status === 403,
`status=${studentCrossHomework.status}`,
);
const studentCrossSchedule = await api(`/schedules?classId=${foreignClass.id}`, { token: studentToken });
record(
'学生跨班查询课表被拒绝(403)',
studentCrossSchedule.status === 403,
`status=${studentCrossSchedule.status}`,
);
const teacherCrossClass = await api(`/classes/${foreignClass.id}`, { token: teacherToken });
record(
'教师访问他人班级被拒绝(403)',
teacherCrossClass.status === 403,
`status=${teacherCrossClass.status}`,
);
const teacherCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: teacherToken });
record(
'教师跨班发布/查询被拒绝(403)',
teacherCrossHomework.status === 403,
`status=${teacherCrossHomework.status}`,
);
const teacherCrossPublish = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: foreignClass.id, title: '跨班发布', content: '不应成功' },
});
record(
'教师向他人班级发布通知被拒绝(403)',
teacherCrossPublish.status === 403,
`status=${teacherCrossPublish.status}`,
);
}
const studentPublish = await api('/notifications', {
method: 'POST',
token: studentToken,
body: { classId, title: '学生越权发布', content: '不应成功' },
});
record('学生调用教师接口被拒绝(403)', studentPublish.status === 403, `status=${studentPublish.status}`);
const anonymous = await api('/classes');
record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`);
// 学生只能看到自己班级:作用域内所有返回项都应属于自己班级
const studentClasses = await api('/classes', { token: studentToken });
const studentVisible = studentClasses.payload?.data ?? [];
record(
'学生班级列表仅含自己所在班级',
studentVisible.length === 1 && studentVisible[0]?.id === studentUser?.classId,
`共 ${studentVisible.length} 个`,
);
const studentHomeworkAll = await api('/homeworks', { token: studentToken });
const homeworkClassIds = new Set((studentHomeworkAll.payload?.data ?? []).map((item) => item.classId));
record(
'学生作业列表已按班级收敛',
homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(studentUser?.classId)),
`涉及 ${homeworkClassIds.size} 个班级`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
const passed = results.filter((item) => item.ok).length;
const failed = results.filter((item) => !item.ok);
console.log(`\n=== 结果:${passed}/${results.length} 项通过 ===`);
if (failed.length > 0) {
console.log('失败项:');
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
process.exit(1);
}
console.log('全部验收项通过 \u2713\n');
}
main().catch((error) => {
console.error('\n验证脚本执行失败:', error);
process.exit(1);
});
+57
View File
@@ -0,0 +1,57 @@
import express, { type Express, type Request, type Response } from 'express';
import cors from 'cors';
import { API_PREFIX } from '@classhelper/shared';
import { env } from './config/env.js';
import { pingDatabase } from './lib/db.js';
import { sendOk } from './lib/http.js';
import { logger } from './lib/logger.js';
import { errorHandler, notFoundHandler } from './middleware/error.js';
import { apiModules } from './modules/registry.js';
async function healthHandler(_req: Request, res: Response): Promise<void> {
sendOk(
res,
{
status: 'ok',
version: '0.1.0',
uptimeSeconds: Math.round(process.uptime()),
database: {
provider: env.databaseProvider,
connected: await pingDatabase(),
},
modules: apiModules.map((module) => module.name),
},
'服务运行正常',
);
}
/** 组装 Express 应用:中间件 -> 健康检查 -> 模块路由 -> 404 -> 统一错误处理 */
export function createApp(): Express {
const app = express();
app.disable('x-powered-by');
app.use(cors({ origin: env.corsOrigins, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
app.use((req, _res, next) => {
logger.debug(`${req.method} ${req.originalUrl}`);
next();
});
// 健康检查(同时暴露已挂载模块列表,便于确认模块化装配结果)
app.get(`${API_PREFIX}/health`, healthHandler);
app.get('/health', healthHandler);
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
for (const module of apiModules) {
const mountPath = `${API_PREFIX}${module.basePath}`;
app.use(mountPath, module.router);
logger.info(`已挂载模块 ${module.name.padEnd(14)} -> ${mountPath}`);
}
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
+77
View File
@@ -0,0 +1,77 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
import { z } from 'zod';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
/**
* packages/server 根目录。
* 源码运行时为 src/config -> ../..;编译后为 dist/config -> ../..,两者一致。
*/
export const serverRoot = path.resolve(currentDir, '..', '..');
// 显式按包根目录加载 .env,避免受启动时 cwd 影响
loadEnv({ path: path.join(serverRoot, '.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
JWT_SECRET: z.string().min(16),
JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
TERM_START_DATE: z.string().default(''),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('\n');
throw new Error(`环境变量校验失败,请检查 packages/server/.env(可从 .env.example 复制):\n${issues}`);
}
const raw = parsed.data;
/** 把 file:./prisma/dev.db 解析为绝对路径,保证任何 cwd 下都指向同一个数据库文件 */
function resolveSqliteUrl(url: string): string {
if (!url.startsWith('file:')) return url;
const filePath = url.slice('file:'.length);
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(serverRoot, filePath);
return `file:${absolute.replace(/\\/g, '/')}`;
}
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
export const env = {
nodeEnv: raw.NODE_ENV,
isProduction: raw.NODE_ENV === 'production',
isDevelopment: raw.NODE_ENV === 'development',
port: raw.PORT,
databaseProvider: raw.DATABASE_PROVIDER,
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
databaseUrl: isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL,
rawDatabaseUrl: raw.DATABASE_URL,
jwtSecret: raw.JWT_SECRET,
jwtExpiresIn: raw.JWT_EXPIRES_IN,
/** true 表示允许所有来源,否则为白名单数组 */
corsOrigins:
raw.CORS_ORIGIN === '*'
? true
: raw.CORS_ORIGIN.split(',')
.map((item) => item.trim())
.filter(Boolean),
bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新增学生时未指定密码时的初始密码 */
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
termStartDate: raw.TERM_START_DATE,
logLevel: raw.LOG_LEVEL,
serverRoot,
} as const;
export type AppEnv = typeof env;
+46
View File
@@ -0,0 +1,46 @@
import { createServer } from 'node:http';
import { API_PREFIX } from '@classhelper/shared';
import { createApp } from './app.js';
import { env } from './config/env.js';
import { disconnectPrisma, pingDatabase } from './lib/db.js';
import { logger } from './lib/logger.js';
import { initRealtime } from './realtime/socket.js';
const app = createApp();
const httpServer = createServer(app);
const io = initRealtime(httpServer);
if (!(await pingDatabase())) {
logger.warn('数据库连接失败:请先执行 pnpm db:migrate 与 pnpm db:seed');
}
httpServer.listen(env.port, () => {
logger.info(`班级小助手后端已启动:http://127.0.0.1:${env.port}${API_PREFIX}`);
logger.info(`健康检查:http://127.0.0.1:${env.port}${API_PREFIX}/health`);
logger.info(`WebSocket:ws://127.0.0.1:${env.port}/socket.io`);
logger.info(`运行环境:${env.nodeEnv},数据库:${env.databaseProvider}`);
});
let shuttingDown = false;
async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`收到 ${signal},正在优雅关闭...`);
await new Promise<void>((resolve) => io.close(() => resolve()));
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
await disconnectPrisma();
logger.info('已关闭所有连接');
process.exit(0);
}
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
process.on('unhandledRejection', (reason) => {
logger.error('未处理的 Promise 拒绝', reason);
});
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from './db.js';
import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js';
/**
* 权限(RBAC)规则:
* - ADMIN :可访问全部班级
* - TEACHER :仅可访问自己创建(teacherId)或被分配(ClassTeacher)的班级
* - STUDENT :仅可查看自己所在班级的数据,且无任何写权限
*/
export function isAdmin(user: TokenPayload): boolean {
return user.role === 'ADMIN';
}
export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER';
}
export function isStudent(user: TokenPayload): boolean {
return user.role === 'STUDENT';
}
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> {
if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id);
}
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: { OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: 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];
}
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 } } }],
},
});
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;
}
/** 读权限断言 */
export async function assertClassAccess(user: TokenPayload, classId: string): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
}
/** 写权限断言:学生一律拒绝 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user)) throw ApiError.forbidden('学生账号没有管理权限');
await assertClassAccess(user, classId);
}
/** 学生的主班级,未分配班级时抛 403 */
export function requireStudentClassId(user: TokenPayload): string {
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
return user.classId;
}
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师/学生只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope(user: TokenPayload, requestedClassId?: string): Promise<ClassScope> {
if (requestedClassId) {
await assertClassAccess(user, requestedClassId);
return { mode: 'in', classIds: [requestedClassId] };
}
if (isAdmin(user)) return { mode: 'all' };
const classIds = await getAccessibleClassIds(user);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
return { mode: 'in', classIds };
}
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
}
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
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);
return requestedClassId;
}
+44
View File
@@ -0,0 +1,44 @@
import { PrismaLibSql } from '@prisma/adapter-libsql';
import { PrismaClient } from '../generated/prisma/client.js';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/**
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
*
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
* 也不会影响类型检查与 SQLite 模式的运行。
*/
async function createAdapter(): Promise<unknown> {
if (env.databaseProvider === 'mysql') {
const adapterModule = '@prisma/adapter-mariadb';
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
logger.info('已加载 MySQL/MariaDB 驱动适配器');
return new mod.PrismaMariaDb(env.databaseUrl);
}
return new PrismaLibSql({ url: env.databaseUrl });
}
const adapter = await createAdapter();
export const prisma = new PrismaClient({ adapter: adapter as never });
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
/** 进程退出时释放连接 */
export async function disconnectPrisma(): Promise<void> {
await prisma.$disconnect();
}
/** 供健康检查使用:确认数据库可连通 */
export async function pingDatabase(): Promise<boolean> {
try {
await prisma.$queryRaw`SELECT 1`;
return true;
} catch (error) {
logger.error('数据库连接检查失败', error);
return false;
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { Response } from 'express';
import type { ApiResponse } from '@classhelper/shared';
/** 成功响应:{ success: true, data, message } */
export function sendOk<T>(res: Response, data: T, message = 'ok', status = 200): void {
const body: ApiResponse<T> = { success: true, data, message };
res.status(status).json(body);
}
/** 创建成功(201) */
export function sendCreated<T>(res: Response, data: T, message = '创建成功'): void {
sendOk(res, data, message, 201);
}
export type ApiErrorCode =
| 'BAD_REQUEST'
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */
export class ApiError extends Error {
readonly status: number;
readonly code: ApiErrorCode;
readonly details?: unknown;
constructor(status: number, code: ApiErrorCode, message: string, details?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
}
static badRequest(message: string, details?: unknown): ApiError {
return new ApiError(400, 'BAD_REQUEST', message, details);
}
static validation(message: string, details?: unknown): ApiError {
return new ApiError(422, 'VALIDATION_ERROR', message, details);
}
static unauthorized(message = '未登录或登录已过期'): ApiError {
return new ApiError(401, 'UNAUTHORIZED', message);
}
static forbidden(message = '无权访问该资源'): ApiError {
return new ApiError(403, 'FORBIDDEN', message);
}
static notFound(message = '资源不存在'): ApiError {
return new ApiError(404, 'NOT_FOUND', message);
}
static conflict(message = '资源冲突'): ApiError {
return new ApiError(409, 'CONFLICT', message);
}
static internal(message = '服务器内部错误', details?: unknown): ApiError {
return new ApiError(500, 'INTERNAL_ERROR', message, details);
}
}
+52
View File
@@ -0,0 +1,52 @@
import jwt from 'jsonwebtoken';
import type { SignOptions } from 'jsonwebtoken';
import type { UserRole } from '@classhelper/shared';
import { env } from '../config/env.js';
import { ApiError } from './http.js';
/** JWT 载荷(同时作为 req.auth.user 的类型来源) */
export interface TokenPayload {
sub: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
}
export function signToken(payload: TokenPayload): string {
// jsonwebtoken 的 expiresIn 类型为 ms.StringValue,这里由 .env 提供字符串,做一次显式收窄
const options = { expiresIn: env.jwtExpiresIn } as unknown as SignOptions;
return jwt.sign({ ...payload }, env.jwtSecret, options);
}
/** 校验并解析 token,失败统一抛出 401 */
export function verifyToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, env.jwtSecret);
if (typeof decoded === 'string') throw new Error('unexpected token payload');
const record = decoded as Record<string, unknown>;
const role = record.role;
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'STUDENT') {
throw new Error('unexpected role in token');
}
return {
sub: String(record.sub ?? ''),
username: String(record.username ?? ''),
name: String(record.name ?? ''),
role,
classId: record.classId ? String(record.classId) : null,
};
} catch {
throw ApiError.unauthorized('登录状态已失效,请重新登录');
}
}
/** 从 Authorization 头中提取 Bearer token */
export function extractBearerToken(headerValue: string | undefined): string | null {
if (!headerValue) return null;
const [scheme, token] = headerValue.split(' ');
if (!token || scheme.toLowerCase() !== 'bearer') return null;
return token.trim();
}
+21
View File
@@ -0,0 +1,21 @@
import { env } from '../config/env.js';
const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 } as const;
export type LogLevel = keyof typeof LEVEL_WEIGHT;
function write(level: LogLevel, message: string, meta?: unknown): void {
if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[env.logLevel]) return;
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
const sink = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
if (meta === undefined) sink(line);
else sink(line, meta);
}
export const logger = {
debug: (message: string, meta?: unknown): void => write('debug', message, meta),
info: (message: string, meta?: unknown): void => write('info', message, meta),
warn: (message: string, meta?: unknown): void => write('warn', message, meta),
error: (message: string, meta?: unknown): void => write('error', message, meta),
};
+303
View File
@@ -0,0 +1,303 @@
import type {
ClassDto,
ClassTeacherBrief,
CourseBrief,
CourseDto,
GradeDto,
HomeworkDto,
HomeworkStatusDto,
NotificationDto,
NotificationPriority,
ScheduleDto,
StudentDto,
UserDto,
UserRole,
} from '@classhelper/shared';
/**
* 数据库记录 -> API DTO 的映射层。
*
* 这里刻意使用结构化的 `XxxLike` 接口而不是 Prisma 生成的模型类型:
* 1) 生成代码的模型类型命名随版本变化,映射层不应该跟着漂移;
* 2) 只有被 include/select 出来的字段才会出现在结果里,结构化类型能精确表达这一点。
*/
const toIso = (value: Date): string => value.toISOString();
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
export interface UserLike {
id: string;
username: string;
name: string;
role: string;
classId: string | null;
createdAt: Date;
}
export interface TeacherBriefLike {
id: string;
name: string;
username: string;
}
export interface StudentLike extends UserLike {
class?: { name: string; grade: string } | null;
}
export interface CourseBriefLike {
id: string;
name: string;
}
export function toUserDto(user: UserLike): UserDto {
return {
id: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
createdAt: toIso(user.createdAt),
};
}
export function toStudentDto(user: StudentLike): StudentDto {
return {
...toUserDto(user),
className: user.class?.name ?? null,
grade: user.class?.grade ?? null,
};
}
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
return { id: user.id, name: user.name, username: user.username };
}
export function toCourseBrief(course: CourseBriefLike): CourseBrief {
return { id: course.id, name: course.name };
}
/* ------------------------------------------------------------------ 班级 */
export interface ClassLike {
id: string;
name: string;
grade: string;
teacherId: string;
createdAt: Date;
teacher?: TeacherBriefLike | null;
_count?: {
students?: number;
courses?: number;
homeworks?: number;
notifications?: number;
enrollments?: number;
};
}
export function toClassDto(item: ClassLike): ClassDto {
return {
id: item.id,
name: item.name,
grade: item.grade,
teacherId: item.teacherId,
createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
studentCount: item._count?.students ?? undefined,
courseCount: item._count?.courses ?? undefined,
homeworkCount: item._count?.homeworks ?? undefined,
notificationCount: item._count?.notifications ?? undefined,
};
}
/* ------------------------------------------------------------------ 课程 */
export interface CourseLike {
id: string;
name: string;
teacherId: string;
classId: string;
createdAt: Date;
teacher?: TeacherBriefLike | null;
}
export function toCourseDto(item: CourseLike): CourseDto {
return {
id: item.id,
name: item.name,
teacherId: item.teacherId,
classId: item.classId,
createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
};
}
/* ------------------------------------------------------------------ 课表 */
export interface ScheduleLike {
id: string;
classId: string;
courseId: string;
dayOfWeek: number;
startTime: string;
endTime: string;
location: string | null;
weekStart: number;
weekEnd: number;
createdAt: Date;
course?: CourseBriefLike | null;
}
export function toScheduleDto(item: ScheduleLike): ScheduleDto {
return {
id: item.id,
classId: item.classId,
courseId: item.courseId,
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
endTime: item.endTime,
location: item.location,
weekStart: item.weekStart,
weekEnd: item.weekEnd,
createdAt: toIso(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
};
}
/* ------------------------------------------------------------------ 作业 */
export interface HomeworkStatusLike {
id: string;
homeworkId: string;
userId: string;
completed: boolean;
updatedAt: Date;
}
export interface HomeworkLike {
id: string;
classId: string;
courseId: string | null;
title: string;
content: string;
attachmentUrl: string | null;
dueAt: Date | null;
createdBy: string;
createdAt: Date;
course?: CourseBriefLike | null;
creator?: TeacherBriefLike | null;
statuses?: HomeworkStatusLike[];
}
export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto {
return {
id: item.id,
homeworkId: item.homeworkId,
userId: item.userId,
completed: item.completed,
updatedAt: toIso(item.updatedAt),
};
}
/**
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
* @param options.withStatus 是否统计完成人数(教师视角)
*/
export function toHomeworkDto(
item: HomeworkLike,
options: { userId?: string | null; withStatus?: boolean } = {},
): HomeworkDto {
const statuses = item.statuses ?? [];
const own = options.userId ? statuses.find((status) => status.userId === options.userId) : undefined;
return {
id: item.id,
classId: item.classId,
courseId: item.courseId,
title: item.title,
content: item.content,
attachmentUrl: item.attachmentUrl,
dueAt: toIsoOrNull(item.dueAt),
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
creator: item.creator ? toTeacherBrief(item.creator) : null,
completed: own ? own.completed : undefined,
homeworkStatus: own ? toHomeworkStatusDto(own) : null,
completedCount: options.withStatus ? statuses.filter((status) => status.completed).length : undefined,
};
}
/* ------------------------------------------------------------------ 通知 */
export interface NotificationReadLike {
id: string;
notificationId: string;
userId: string;
readAt: Date;
}
export interface NotificationLike {
id: string;
classId: string;
title: string;
content: string;
priority: string;
createdBy: string;
createdAt: Date;
creator?: TeacherBriefLike | null;
reads?: NotificationReadLike[];
}
export function toNotificationDto(
item: NotificationLike,
options: { userId?: string | null; withStatus?: boolean } = {},
): NotificationDto {
const reads = item.reads ?? [];
const own = options.userId ? reads.find((read) => read.userId === options.userId) : undefined;
return {
id: item.id,
classId: item.classId,
title: item.title,
content: item.content,
priority: item.priority as NotificationPriority,
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
creator: item.creator ? toTeacherBrief(item.creator) : null,
read: own ? true : false,
readAt: toIsoOrNull(own?.readAt),
readCount: options.withStatus ? reads.length : undefined,
};
}
/* ------------------------------------------------------------------ 成绩 */
export interface GradeLike {
id: string;
classId: string;
courseId: string | null;
userId: string;
examName: string;
score: number;
totalScore: number;
publishedAt: Date;
course?: CourseBriefLike | null;
student?: TeacherBriefLike | null;
class?: { name: string } | null;
}
export function toGradeDto(item: GradeLike): GradeDto {
return {
id: item.id,
classId: item.classId,
courseId: item.courseId,
userId: item.userId,
examName: item.examName,
score: item.score,
totalScore: item.totalScore,
publishedAt: toIso(item.publishedAt),
course: item.course ? toCourseBrief(item.course) : null,
student: item.student ? toTeacherBrief(item.student) : null,
className: item.class?.name ?? null,
};
}
+12
View File
@@ -0,0 +1,12 @@
import * as bcrypt from 'bcryptjs';
import { env } from '../config/env.js';
/** 生成密码哈希(bcrypt 算法) */
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, env.bcryptRounds);
}
/** 校验明文密码与哈希是否匹配 */
export function verifyPassword(plain: string, passwordHash: string): Promise<boolean> {
return bcrypt.compare(plain, passwordHash);
}
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
/** 通用路径参数::id */
export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') });
/** HH:mm 时间 */
export const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, '时间格式应为 HH:mm');
/** ISO 日期时间字符串(接受 2026-03-01 或完整 ISO) */
export const dateTimeSchema = z
.string()
.min(1)
.refine((value) => !Number.isNaN(new Date(value).getTime()), '日期时间格式不正确');
/** 可选日期时间:允许 null / 空字符串,表示清空 */
export const optionalDateTimeSchema = z.union([dateTimeSchema, z.null(), z.literal('')]).optional();
/** 把可选日期时间统一转成 Date | null */
export function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
/** 查询参数里的布尔值(true/1) */
export const booleanFlagSchema = z
.union([z.boolean(), z.string()])
.optional()
.transform((value) => value === true || value === 'true' || value === '1');
/** 关键字查询 */
export const keywordSchema = z.string().trim().max(64).optional();
/** 周次:1..30 */
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
+23
View File
@@ -0,0 +1,23 @@
import { env } from '../config/env.js';
const MS_PER_DAY = 86_400_000;
export const MAX_TERM_WEEK = 30;
/**
* 依据 TERM_START_DATE 计算当前教学周(第 1 周从该日期所在周一开始)。
* 学生端与 Web 端"当前周次"默认值都走这里,保证三端口径一致。
*/
export function resolveCurrentWeek(now: Date = new Date()): number {
if (!env.termStartDate) return 1;
const start = new Date(env.termStartDate);
if (Number.isNaN(start.getTime())) return 1;
// 归一到该日期所在周的周一
const weekday = start.getDay() === 0 ? 7 : start.getDay();
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
monday.setHours(0, 0, 0, 0);
const diffDays = Math.floor((now.getTime() - monday.getTime()) / MS_PER_DAY);
const week = Math.floor(diffDays / 7) + 1;
return Math.min(Math.max(week, 1), MAX_TERM_WEEK);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Request, RequestHandler } from 'express';
import type { UserRole } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/**
* 认证中间件:解析 Bearer token 并回查数据库,
* 保证角色 / 班级变更(例如教师把学生转到别的班)立即生效,不被过期载荷影响。
*/
export function authenticate(): RequestHandler {
return async (req, _res, next) => {
try {
const token = extractBearerToken(req.headers.authorization);
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
const payload = verifyToken(token);
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true, classId: true },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
req.auth = {
token,
user: {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
},
};
next();
} catch (error) {
next(error);
}
};
}
/** 角色守卫 */
export function requireRole(...roles: UserRole[]): RequestHandler {
return (req, _res, next) => {
const user = req.auth?.user;
if (!user) {
next(ApiError.unauthorized());
return;
}
if (!roles.includes(user.role)) {
next(ApiError.forbidden(`该操作仅限 ${roles.join(' / ')} 角色使用`));
return;
}
next();
};
}
/** 取出当前登录用户,未认证时抛 401 */
export function getAuthUser(req: Request): TokenPayload {
if (!req.auth?.user) throw ApiError.unauthorized();
return req.auth.user;
}
+65
View File
@@ -0,0 +1,65 @@
import type { ErrorRequestHandler, RequestHandler } from 'express';
import { ZodError } from 'zod';
import type { ApiErrorResponse } from '@classhelper/shared';
import { ApiError } from '../lib/http.js';
import { logger } from '../lib/logger.js';
import { env } from '../config/env.js';
function sendError(
res: Parameters<ErrorRequestHandler>[2],
status: number,
message: string,
code: string,
details?: unknown,
): void {
const body: ApiErrorResponse = { success: false, data: null, message, code };
if (details !== undefined) body.details = details;
res.status(status).json(body);
}
/** 404:未匹配到任何路由 */
export const notFoundHandler: RequestHandler = (req, res) => {
sendError(res, 404, `接口不存在:${req.method} ${req.originalUrl}`, 'NOT_FOUND');
};
/** 统一错误处理:把 Zod / ApiError / Prisma 错误转换成统一响应体 */
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
if (error instanceof ZodError) {
const details = error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
}));
logger.warn(`参数校验失败:${req.method} ${req.originalUrl}`, details);
sendError(res, 422, '请求参数不合法', 'VALIDATION_ERROR', details);
return;
}
if (error instanceof ApiError) {
if (error.status >= 500) logger.error(error.message, error.details);
sendError(res, error.status, error.message, error.code, error.details);
return;
}
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
const prismaCode = (error as { code?: string }).code;
if (prismaCode === 'P2002') {
sendError(res, 409, '数据已存在,请检查唯一字段(用户名 / 课程名等)', 'CONFLICT');
return;
}
if (prismaCode === 'P2025') {
sendError(res, 404, '目标记录不存在或已被删除', 'NOT_FOUND');
return;
}
if (prismaCode === 'P2003') {
sendError(res, 400, '关联数据不存在或仍被引用', 'BAD_REQUEST');
return;
}
logger.error(`未处理异常:${req.method} ${req.originalUrl}`, error);
sendError(
res,
500,
env.isProduction ? '服务器内部错误' : `服务器内部错误:${(error as Error).message}`,
'INTERNAL_ERROR',
);
};
@@ -0,0 +1,38 @@
import type { Request, RequestHandler } from 'express';
import type { ZodType } from 'zod';
interface ValidationTargets<B, Q, P> {
body?: ZodType<B>;
query?: ZodType<Q>;
params?: ZodType<P>;
}
/**
* 基于 zod 的请求校验中间件。
* 校验结果写入 req.body / req.validatedQuery / req.validatedParams,
* 失败抛出的 ZodError 由统一错误中间件转换成 422。
*/
export function validate<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
): RequestHandler {
return (req, _res, next) => {
try {
if (targets.body) req.body = targets.body.parse(req.body) as unknown;
if (targets.query) req.validatedQuery = targets.query.parse(req.query) as unknown;
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
next();
} catch (error) {
next(error);
}
};
}
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
export function validatedQuery<T>(req: Request): T {
return (req.validatedQuery ?? {}) as T;
}
/** 读取已校验的 params */
export function validatedParams<T>(req: Request): T {
return (req.validatedParams ?? {}) as T;
}
@@ -0,0 +1,48 @@
import { Router } from 'express';
import type { LoginResponse } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
changePasswordSchema,
loginSchema,
type ChangePasswordInput,
type LoginInput,
} from './auth.schemas.js';
import * as authService from './auth.service.js';
const router = Router();
/** POST /api/auth/login - 登录(公开) */
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
const result: LoginResponse = await authService.login(req.body as LoginInput);
sendOk(res, result, '登录成功');
});
/** GET /api/auth/me - 当前用户信息 */
router.get('/me', authenticate(), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await authService.getProfile(user.sub), '获取当前用户成功');
});
/** PATCH /api/auth/password - 修改密码 */
router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => {
const user = getAuthUser(req);
await authService.changePassword(user.sub, req.body as ChangePasswordInput);
sendOk(res, { changed: true }, '密码修改成功,请重新登录');
});
/**
* POST /api/auth/logout - 退出登录。
* JWT 为无状态凭证,服务端不做黑名单,客户端丢弃 token 即可。
*/
router.post('/logout', authenticate(), (_req, res) => {
sendOk(res, { loggedOut: true }, '已退出登录');
});
export const authModule = defineModule({
name: 'auth',
basePath: '/auth',
router,
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().trim().min(1, '请输入用户名').max(64),
password: z.string().min(1, '请输入密码').max(128),
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, '请输入当前密码'),
newPassword: z.string().min(6, '新密码至少 6 位').max(128),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
@@ -0,0 +1,50 @@
import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { signToken } from '../../lib/jwt.js';
import { toStudentDto, toUserDto } from '../../lib/mappers.js';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import type { ChangePasswordInput, LoginInput } from './auth.schemas.js';
/** 登录:校验用户名密码并签发 JWT */
export async function login(input: LoginInput): Promise<LoginResponse> {
const user = await prisma.user.findUnique({ where: { username: input.username } });
if (!user) throw ApiError.unauthorized('用户名或密码错误');
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
const token = signToken({
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
});
return { token, user: toUserDto(user) };
}
/** 当前用户信息(学生额外带班级名与年级) */
export async function getProfile(userId: string): Promise<StudentDto> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { class: { select: { name: true, grade: true } } },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
return toStudentDto(user);
}
/** 修改自己的密码 */
export async function changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
const matched = await verifyPassword(input.currentPassword, user.passwordHash);
if (!matched) throw ApiError.badRequest('当前密码不正确');
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(input.newPassword) },
});
}
@@ -0,0 +1,134 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
addStudentSchema,
assignTeacherSchema,
classStudentParamSchema,
classTeacherParamSchema,
createClassSchema,
listClassesQuerySchema,
updateClassSchema,
type AddStudentInput,
type CreateClassInput,
type UpdateClassInput,
} from './classes.schemas.js';
import * as classService from './classes.service.js';
const router = Router();
// 班级相关接口统一需要登录
router.use(authenticate());
/** GET /api/classes - 班级列表(教师/管理员:自己管理的班级;学生:自己所在班级) */
router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
});
/** GET /api/classes/:id - 班级详情 */
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
});
/** POST /api/classes - 创建班级 */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createClassSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
});
/** PATCH /api/classes/:id - 编辑班级 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateClassSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.updateClass(user, id, req.body as UpdateClassInput), '班级更新成功');
},
);
/** DELETE /api/classes/:id - 删除班级 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await classService.deleteClass(user, id);
sendOk(res, { id }, '班级已删除');
},
);
/** GET /api/classes/:id/students - 班级学生名单 */
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
});
/** POST /api/classes/:id/students - 添加学生(支持已存在账号转入) */
router.post(
'/:id/students',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: addStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendCreated(res, await classService.addStudent(user, id, req.body as AddStudentInput), '学生已加入班级');
},
);
/** DELETE /api/classes/:id/students/:userId - 移出学生 */
router.delete(
'/:id/students/:userId',
requireRole('ADMIN', 'TEACHER'),
validate({ params: classStudentParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id, userId } = validatedParams<{ id: string; userId: string }>(req);
await classService.removeStudent(user, id, userId);
sendOk(res, { classId: id, userId }, '学生已移出班级');
},
);
/** POST /api/classes/:id/teachers - 分配协作教师 */
router.post(
'/:id/teachers',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: assignTeacherSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const { teacherId } = req.body as { teacherId: string };
await classService.assignTeacher(user, id, teacherId);
sendCreated(res, { classId: id, teacherId }, '教师已分配');
},
);
/** DELETE /api/classes/:id/teachers/:teacherId - 取消协作教师 */
router.delete(
'/:id/teachers/:teacherId',
requireRole('ADMIN', 'TEACHER'),
validate({ params: classTeacherParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id, teacherId } = validatedParams<{ id: string; teacherId: string }>(req);
await classService.removeTeacher(user, id, teacherId);
sendOk(res, { classId: id, teacherId }, '已取消教师分配');
},
);
export const classesModule = defineModule({
name: 'classes',
basePath: '/classes',
router,
});
@@ -0,0 +1,42 @@
import { z } from 'zod';
import { keywordSchema } from '../../lib/schemas.js';
export const createClassSchema = z.object({
name: z.string().trim().min(1, '请输入班级名称').max(64),
grade: z.string().trim().min(1, '请输入年级').max(32),
/** 仅管理员可指定班主任,教师创建时固定为自己 */
teacherId: z.string().min(1).optional(),
});
export const updateClassSchema = z.object({
name: z.string().trim().min(1).max(64).optional(),
grade: z.string().trim().min(1).max(32).optional(),
});
export const listClassesQuerySchema = z.object({
keyword: keywordSchema,
});
export const addStudentSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
});
export const assignTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择教师'),
});
export const classStudentParamSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
});
export const classTeacherParamSchema = z.object({
id: z.string().min(1),
teacherId: z.string().min(1),
});
export type CreateClassInput = z.infer<typeof createClassSchema>;
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>;
@@ -0,0 +1,231 @@
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import {
assertClassAccess,
assertClassWritable,
classScopeIdFilter,
resolveClassScope,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toClassDto, toCourseDto, toStudentDto, toTeacherBrief } from '../../lib/mappers.js';
import { hashPassword } from '../../lib/password.js';
import { emitToClass } from '../../realtime/bus.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import type { AddStudentInput, CreateClassInput, UpdateClassInput } from './classes.schemas.js';
const countSelect = {
students: true,
courses: true,
homeworks: true,
notifications: true,
} as const;
/** 班级列表(按权限过滤) */
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
const scope = await resolveClassScope(user);
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
const where = {
...classScopeIdFilter(scope),
...(keyword ? { OR: [{ name: { contains: keyword } }, { grade: { contains: keyword } }] } : {}),
};
const classes = await prisma.class.findMany({
where,
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
},
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
});
return classes.map(toClassDto);
}
/** 班级详情:学生名单 + 课程 + 协作教师 */
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
await assertClassAccess(user, classId);
const item = await prisma.class.findUnique({
where: { id: classId },
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
students: { orderBy: { username: 'asc' } },
courses: {
orderBy: { name: 'asc' },
include: { teacher: { select: { id: true, name: true, username: true } } },
},
teachers: {
include: { teacher: { select: { id: true, name: true, username: true } } },
orderBy: { createdAt: 'asc' },
},
},
});
if (!item) throw ApiError.notFound('班级不存在');
return {
...toClassDto(item),
students: item.students.map((student) => toStudentDto(student)),
courses: item.courses.map(toCourseDto),
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
};
}
/** 创建班级 */
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
let teacherId = user.sub;
if (input.teacherId && user.role === 'ADMIN') {
const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } });
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
}
teacherId = teacher.id;
}
const created = await prisma.class.create({
data: { name: input.name, grade: input.grade, teacherId },
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
},
});
return toClassDto(created);
}
/** 编辑班级 */
export async function updateClass(
user: TokenPayload,
classId: string,
input: UpdateClassInput,
): Promise<ClassDto> {
await assertClassWritable(user, classId);
const updated = await prisma.class.update({
where: { id: classId },
data: { ...(input.name ? { name: input.name } : {}), ...(input.grade ? { grade: input.grade } : {}) },
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
},
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated);
}
/** 删除班级(级联删除课表/作业/通知/成绩) */
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
await assertClassWritable(user, classId);
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
await prisma.class.delete({ where: { id: classId } });
}
/** 班级学生名单 */
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertClassAccess(user, classId);
const students = await prisma.user.findMany({
where: { classId, role: 'STUDENT' },
include: { class: { select: { name: true, grade: true } } },
orderBy: { username: 'asc' },
});
return students.map(toStudentDto);
}
/**
* 添加学生到班级。
* 已存在的学生账号直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
* 新账号使用 DEFAULT_STUDENT_PASSWORD 作为初始密码。
*/
export async function addStudent(
user: TokenPayload,
classId: string,
input: AddStudentInput,
): Promise<StudentDto> {
await assertClassWritable(user, classId);
const existing = await prisma.user.findUnique({ where: { username: input.username } });
let studentId: string;
if (existing) {
if (existing.role !== 'STUDENT') {
throw ApiError.conflict(`用户名 ${input.username} 已被教师/管理员账号占用`);
}
studentId = existing.id;
// 换班:清理其他班级的选课记录
await prisma.enrollment.deleteMany({ where: { userId: existing.id, classId: { not: classId } } });
await prisma.user.update({
where: { id: existing.id },
data: { classId, name: input.name },
});
} else {
const created = await prisma.user.create({
data: {
username: input.username,
name: input.name,
role: 'STUDENT',
classId,
passwordHash: await hashPassword(input.password ?? env.defaultStudentPassword),
},
});
studentId = created.id;
}
await prisma.enrollment.upsert({
where: { userId_classId: { userId: studentId, classId } },
create: { userId: studentId, classId },
update: {},
});
const student = await prisma.user.findUniqueOrThrow({
where: { id: studentId },
include: { class: { select: { name: true, grade: true } } },
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toStudentDto(student);
}
/** 将学生移出班级(保留账号) */
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
await assertClassWritable(user, classId);
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级');
await prisma.$transaction([
prisma.user.update({ where: { id: studentId }, data: { classId: null } }),
prisma.enrollment.deleteMany({ where: { userId: studentId, classId } }),
]);
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
/** 分配协作教师 */
export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
await assertClassWritable(user, classId);
const teacher = await prisma.user.findUnique({ where: { id: teacherId } });
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.badRequest('目标账号不存在或角色不是教师');
}
await prisma.classTeacher.upsert({
where: { classId_teacherId: { classId, teacherId } },
create: { classId, teacherId },
update: {},
});
}
/** 取消协作教师(班主任不可被移除) */
export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
await assertClassWritable(user, classId);
const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级');
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
}
@@ -0,0 +1,66 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createCourseSchema,
listCoursesQuerySchema,
updateCourseSchema,
type CreateCourseInput,
type UpdateCourseInput,
} from './courses.schemas.js';
import * as courseService from './courses.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/courses?classId= - 课程列表 */
router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功');
});
/** POST /api/courses - 新增课程 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await courseService.createCourse(user, req.body as CreateCourseInput), '课程创建成功');
},
);
/** PATCH /api/courses/:id - 修改课程 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await courseService.updateCourse(user, id, req.body as UpdateCourseInput), '课程更新成功');
},
);
/** DELETE /api/courses/:id - 删除课程 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await courseService.deleteCourse(user, id);
sendOk(res, { id }, '课程已删除');
},
);
export const coursesModule = defineModule({
name: 'courses',
basePath: '/courses',
router,
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createCourseSchema = z.object({
name: z.string().trim().min(1, '请输入课程名称').max(64),
classId: z.string().min(1, '请选择班级'),
});
export const updateCourseSchema = z.object({
name: z.string().trim().min(1).max(64).optional(),
});
export const listCoursesQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
export type CreateCourseInput = z.infer<typeof createCourseSchema>;
export type UpdateCourseInput = z.infer<typeof updateCourseSchema>;
@@ -0,0 +1,53 @@
import type { CourseDto } from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toCourseDto } from '../../lib/mappers.js';
import type { CreateCourseInput, UpdateCourseInput } from './courses.schemas.js';
const teacherSelect = { select: { id: true, name: true, username: true } } as const;
export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> {
const scope = await resolveClassScope(user, classId);
const courses = await prisma.course.findMany({
where: classScopeWhere(scope),
include: { teacher: teacherSelect },
orderBy: [{ classId: 'asc' }, { name: 'asc' }],
});
return courses.map(toCourseDto);
}
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertClassWritable(user, input.classId);
const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub },
include: { teacher: teacherSelect },
});
return toCourseDto(created);
}
export async function updateCourse(
user: TokenPayload,
courseId: string,
input: UpdateCourseInput,
): Promise<CourseDto> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
const updated = await prisma.course.update({
where: { id: courseId },
data: { ...(input.name ? { name: input.name } : {}) },
include: { teacher: teacherSelect },
});
return toCourseDto(updated);
}
export async function deleteCourse(user: TokenPayload, courseId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
await prisma.course.delete({ where: { id: courseId } });
}
@@ -0,0 +1,26 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { resolveCurrentWeek, MAX_TERM_WEEK } from '../../lib/term.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js';
import * as dashboardService from './dashboard.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
router.get('/summary', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
});
/** GET /api/dashboard/term - 学期信息(当前教学周) */
router.get('/term', (_req, res) => {
sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek: MAX_TERM_WEEK }, '获取学期信息成功');
});
export const dashboardModule = defineModule({
name: 'dashboard',
basePath: '/dashboard',
router,
});
@@ -0,0 +1,104 @@
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import {
classScopeIdFilter,
classScopeWhere,
isStudent,
resolveClassScope,
requireStudentClassId,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
/**
* 仪表盘汇总。
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
*/
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
const student = isStudent(user);
// 学生强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, student ? requireStudentClassId(user) : undefined);
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
const now = new Date();
const [
classCount,
studentCount,
courseCount,
scheduleCount,
homeworkCount,
notificationCount,
gradeCount,
unreadNotificationCount,
pendingHomeworkCount,
recentNotifications,
recentHomeworks,
upcoming,
] = await Promise.all([
prisma.class.count({ where: classWhere }),
prisma.user.count({ where: { ...where, role: 'STUDENT' } }),
prisma.course.count({ where }),
prisma.schedule.count({ where }),
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where }),
prisma.notification.count({ where: { ...where, reads: { none: { userId: user.sub } } } }),
student
? prisma.homework.count({
where: { ...where, statuses: { none: { userId: user.sub, completed: true } } },
})
: prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }),
prisma.notification.findMany({
where,
include: { creator: creatorSelect, reads: { where: { userId: user.sub } } },
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where,
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
},
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where: { ...where, dueAt: { gte: now } },
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
},
orderBy: { dueAt: 'asc' },
take: 5,
}),
]);
return {
classCount,
studentCount,
courseCount,
scheduleCount,
homeworkCount,
notificationCount,
gradeCount,
unreadNotificationCount,
pendingHomeworkCount,
recentNotifications: recentNotifications.map((item): NotificationDto =>
toNotificationDto(item, { userId: user.sub }),
),
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
),
upcomingDeadlines: upcoming.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
),
};
}
@@ -0,0 +1,105 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
bulkCreateGradeSchema,
createGradeSchema,
listGradesQuerySchema,
statsQuerySchema,
updateGradeSchema,
type BulkCreateGradeInput,
type CreateGradeInput,
type UpdateGradeInput,
} from './grades.schemas.js';
import * as gradeService from './grades.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/grades/my - 学生查看个人成绩(任意角色都可查自己的) */
router.get('/my', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await gradeService.listMyGrades(user), '获取个人成绩成功');
});
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
router.get(
'/stats',
requireRole('ADMIN', 'TEACHER'),
validate({ query: statsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; courseId?: string; examName?: string }>(req);
sendOk(res, await gradeService.getGradeStats(user, query), '获取成绩统计成功');
},
);
/** GET /api/grades?classId=&courseId=&userId=&examName= - 成绩列表(教师) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listGradesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; courseId?: string; userId?: string; examName?: string }>(
req,
);
sendOk(res, await gradeService.listGrades(user, query), '获取成绩列表成功');
},
);
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await gradeService.createGrade(user, req.body as CreateGradeInput),
'成绩录入成功,已实时推送',
);
});
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post(
'/bulk',
requireRole('ADMIN', 'TEACHER'),
validate({ body: bulkCreateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput);
sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`);
},
);
/** PATCH /api/grades/:id - 修改成绩 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await gradeService.updateGrade(user, id, req.body as UpdateGradeInput), '成绩更新成功');
},
);
/** DELETE /api/grades/:id - 删除成绩 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await gradeService.deleteGrade(user, id);
sendOk(res, { id }, '成绩已删除');
},
);
export const gradesModule = defineModule({
name: 'grades',
basePath: '/grades',
router,
});
@@ -0,0 +1,54 @@
import { z } from 'zod';
import { optionalDateTimeSchema } from '../../lib/schemas.js';
export const listGradesQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
userId: z.string().min(1).optional(),
examName: z.string().trim().max(64).optional(),
});
export const statsQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
examName: z.string().trim().max(64).optional(),
});
export const createGradeSchema = z.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1).nullish(),
userId: z.string().min(1, '请选择学生'),
examName: z.string().trim().min(1, '请输入考试名称').max(64),
score: z.coerce.number().min(0, '分数不能为负数'),
totalScore: z.coerce.number().positive('总分必须大于 0').optional(),
publishedAt: optionalDateTimeSchema,
});
export const updateGradeSchema = z.object({
examName: z.string().trim().min(1).max(64).optional(),
score: z.coerce.number().min(0).optional(),
totalScore: z.coerce.number().positive().optional(),
courseId: z.string().min(1).nullish(),
publishedAt: optionalDateTimeSchema,
});
/** 批量录入 / 导入成绩 */
export const bulkCreateGradeSchema = z.object({
classId: z.string().min(1),
courseId: z.string().min(1).nullish(),
examName: z.string().trim().min(1).max(64),
totalScore: z.coerce.number().positive().optional(),
publishedAt: optionalDateTimeSchema,
items: z
.array(
z.object({
userId: z.string().min(1),
score: z.coerce.number().min(0),
}),
)
.min(1, '至少需要一条成绩记录'),
});
export type CreateGradeInput = z.infer<typeof createGradeSchema>;
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
@@ -0,0 +1,241 @@
import {
SOCKET_EVENTS,
gradeLevel,
gradePercent,
type GradeDto,
type GradeLevel,
type GradeStats,
} from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toGradeDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { emitToClass, emitToUser } from '../../realtime/bus.js';
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
const studentSelect = { select: { id: true, name: true, username: true } } as const;
const classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
export interface ListGradeOptions {
classId?: string;
courseId?: string;
userId?: string;
examName?: string;
}
/** 学生查看自己的成绩 */
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
const grades = await prisma.grade.findMany({
where: { userId: user.sub },
include,
orderBy: [{ publishedAt: 'desc' }],
take: 300,
});
return grades.map(toGradeDto);
}
/** 教师查看班级成绩 */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId);
const grades = await prisma.grade.findMany({
where: {
...classScopeWhere(scope),
...(options.courseId ? { courseId: options.courseId } : {}),
...(options.userId ? { userId: options.userId } : {}),
...(options.examName ? { examName: options.examName } : {}),
},
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
});
return grades.map(toGradeDto);
}
/** 成绩统计(等级分布 + 各课程平均得分率),供 Web 端图表使用 */
export async function getGradeStats(user: TokenPayload, options: ListGradeOptions): Promise<GradeStats> {
const grades = await listGrades(user, options);
const levels: GradeLevel[] = ['A', 'B', 'C', 'D', 'E'];
const distribution = levels.map((level) => ({ level, count: 0 }));
const byCourseMap = new Map<string, { courseId: string | null; courseName: string; percents: number[] }>();
for (const grade of grades) {
const percent = gradePercent(grade.score, grade.totalScore);
const bucket = distribution.find((item) => item.level === gradeLevel(percent));
if (bucket) bucket.count += 1;
const key = grade.courseId ?? '__none__';
const entry = byCourseMap.get(key) ?? {
courseId: grade.courseId,
courseName: grade.course?.name ?? '未关联课程',
percents: [],
};
entry.percents.push(percent);
byCourseMap.set(key, entry);
}
const allPercents = grades.map((grade) => gradePercent(grade.score, grade.totalScore));
return {
total: grades.length,
averagePercent:
allPercents.length === 0
? 0
: Math.round((allPercents.reduce((sum, value) => sum + value, 0) / allPercents.length) * 10) / 10,
byCourse: [...byCourseMap.values()].map((entry) => ({
courseId: entry.courseId,
courseName: entry.courseName,
count: entry.percents.length,
averagePercent:
Math.round((entry.percents.reduce((sum, value) => sum + value, 0) / entry.percents.length) * 10) / 10,
})),
distribution,
};
}
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertClassWritable(user, input.classId);
await assertStudentInClass(input.userId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const dto = await upsertGrade({
classId: input.classId,
courseId: input.courseId ?? null,
userId: input.userId,
examName: input.examName,
score: input.score,
totalScore: input.totalScore ?? 100,
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
});
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
return dto;
}
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertClassWritable(user, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
const results: GradeDto[] = [];
for (const item of input.items) {
await assertStudentInClass(item.userId, input.classId);
const dto = await upsertGrade({
classId: input.classId,
courseId: input.courseId ?? null,
userId: item.userId,
examName: input.examName,
score: item.score,
totalScore: input.totalScore ?? 100,
publishedAt,
});
results.push(dto);
}
// 一次广播覆盖整班(房间内包含全部学生)
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
...results[0],
bulk: true,
count: results.length,
});
return results;
}
export async function updateGrade(
user: TokenPayload,
gradeId: string,
input: UpdateGradeInput,
): Promise<GradeDto> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const updated = await prisma.grade.update({
where: { id: gradeId },
data: {
...(input.examName ? { examName: input.examName } : {}),
...(input.score !== undefined ? { score: input.score } : {}),
...(input.totalScore !== undefined ? { totalScore: input.totalScore } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.publishedAt !== undefined
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
: {}),
},
include,
});
const dto = toGradeDto(updated);
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
return dto;
}
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertClassWritable(user, current.classId);
await prisma.grade.delete({ where: { id: gradeId } });
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
...toGradeDto({ ...current, course: null, student: null, class: null }),
deleted: true,
});
}
/**
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
*/
async function upsertGrade(data: {
classId: string;
courseId: string | null;
userId: string;
examName: string;
score: number;
totalScore: number;
publishedAt: Date;
}): Promise<GradeDto> {
const existing = await prisma.grade.findFirst({
where: {
classId: data.classId,
courseId: data.courseId,
userId: data.userId,
examName: data.examName,
},
select: { id: true },
});
if (existing) {
const updated = await prisma.grade.update({
where: { id: existing.id },
data: { score: data.score, totalScore: data.totalScore, publishedAt: data.publishedAt },
include,
});
return toGradeDto(updated);
}
const created = await prisma.grade.create({ data, include });
return toGradeDto(created);
}
async function assertStudentInClass(userId: string, classId: string): Promise<void> {
const student = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, classId: true },
});
if (!student || student.role !== 'STUDENT') throw ApiError.badRequest('学生不存在');
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
}
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -0,0 +1,104 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createHomeworkSchema,
listHomeworksQuerySchema,
updateHomeworkSchema,
updateHomeworkStatusSchema,
type CreateHomeworkInput,
type UpdateHomeworkInput,
type UpdateHomeworkStatusInput,
} from './homeworks.schemas.js';
import * as homeworkService from './homeworks.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
courseId?: string;
pendingOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功');
});
/** GET /api/homeworks/:id - 作业详情 */
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
});
/** POST /api/homeworks - 发布作业(发布后广播 homework:new) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await homeworkService.createHomework(user, req.body as CreateHomeworkInput),
'作业发布成功',
);
},
);
/** PATCH /api/homeworks/:id - 修改作业 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await homeworkService.updateHomework(user, id, req.body as UpdateHomeworkInput),
'作业更新成功',
);
},
);
/** DELETE /api/homeworks/:id - 删除作业 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await homeworkService.deleteHomework(user, id);
sendOk(res, { id }, '作业已删除');
},
);
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
router.patch(
'/:id/status',
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await homeworkService.updateHomeworkStatus(
user,
id,
req.body as UpdateHomeworkStatusInput,
);
sendOk(res, result, result.completed ? '已标记为完成' : '已取消完成标记');
},
);
export const homeworksModule = defineModule({
name: 'homeworks',
basePath: '/homeworks',
router,
});
@@ -0,0 +1,37 @@
import { z } from 'zod';
import { booleanFlagSchema, optionalDateTimeSchema } from '../../lib/schemas.js';
export const listHomeworksQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
/** 学生端:只看未完成 */
pendingOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
});
export const createHomeworkSchema = z.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1).nullish(),
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
attachmentUrl: z.string().trim().max(500).nullish(),
dueAt: optionalDateTimeSchema,
});
export const updateHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(),
title: z.string().trim().min(1).max(120).optional(),
content: z.string().min(1).max(5000).optional(),
attachmentUrl: z.string().trim().max(500).nullish(),
dueAt: optionalDateTimeSchema,
});
export const updateHomeworkStatusSchema = z.object({
completed: z.boolean(),
/** 教师代学生标记时使用;学生只能标记自己 */
userId: z.string().min(1).optional(),
});
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
@@ -0,0 +1,183 @@
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
classScopeWhere,
isStudent,
resolveClassScope,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateHomeworkInput,
UpdateHomeworkInput,
UpdateHomeworkStatusInput,
} from './homeworks.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
export interface ListHomeworkOptions {
classId?: string;
courseId?: string;
pendingOnly?: boolean;
keyword?: string;
}
/**
* 作业列表。
* - 学生:附带自己的完成状态,可通过 pendingOnly 只看未完成
* - 教师/管理员:附带完成人数统计
*/
export async function listHomeworks(
user: TokenPayload,
options: ListHomeworkOptions,
): Promise<HomeworkDto[]> {
const scope = await resolveClassScope(user, options.classId);
const student = isStudent(user);
const homeworks = await prisma.homework.findMany({
where: {
...classScopeWhere(scope),
...(options.courseId ? { courseId: options.courseId } : {}),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(student && options.pendingOnly
? { statuses: { none: { userId: user.sub, completed: true } } }
: {}),
},
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
},
orderBy: { createdAt: 'desc' },
take: 200,
});
return homeworks.map((item) =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
);
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
include: {
course: courseSelect,
creator: creatorSelect,
statuses: isStudent(user) ? { where: { userId: user.sub } } : true,
},
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
return toHomeworkDto(homework, { userId: isStudent(user) ? user.sub : null, withStatus: !isStudent(user) });
}
/** 发布作业并实时推送到班级房间 */
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertClassWritable(user, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.homework.create({
data: {
classId: input.classId,
courseId: input.courseId ?? null,
title: input.title,
content: input.content,
attachmentUrl: input.attachmentUrl ?? null,
dueAt: parseOptionalDate(input.dueAt),
createdBy: user.sub,
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
const dto = toHomeworkDto(created, { withStatus: true });
emitToClass(created.classId, SOCKET_EVENTS.homeworkNew, dto);
return dto;
}
export async function updateHomework(
user: TokenPayload,
homeworkId: string,
input: UpdateHomeworkInput,
): Promise<HomeworkDto> {
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const updated = await prisma.homework.update({
where: { id: homeworkId },
data: {
...(input.title ? { title: input.title } : {}),
...(input.content ? { content: input.content } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
...(input.dueAt !== undefined ? { dueAt: parseOptionalDate(input.dueAt) } : {}),
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
const dto = toHomeworkDto(updated, { withStatus: true });
emitToClass(updated.classId, SOCKET_EVENTS.homeworkUpdated, dto);
return dto;
}
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
const current = await prisma.homework.findUnique({
where: { id: homeworkId },
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
await prisma.homework.delete({ where: { id: homeworkId } });
emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, {
...toHomeworkDto(current),
deleted: true,
});
}
/** 标记完成 / 取消完成(学生标记自己;教师可代标记) */
export async function updateHomeworkStatus(
user: TokenPayload,
homeworkId: string,
input: UpdateHomeworkStatusInput,
): Promise<HomeworkStatusDto> {
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在');
const targetUserId = isStudent(user) ? user.sub : (input.userId ?? user.sub);
await assertClassAccess(user, homework.classId);
if (!isStudent(user) && input.userId) {
const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } });
if (!target || target.classId !== homework.classId) {
throw ApiError.badRequest('目标学生不属于该作业所在班级');
}
}
const status = await prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId: targetUserId } },
create: { homeworkId, userId: targetUserId, completed: input.completed },
update: { completed: input.completed },
});
const dto = toHomeworkStatusDto(status);
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, { ...dto, classId: homework.classId });
return dto;
}
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -0,0 +1,23 @@
import type { Router } from 'express';
/**
* 模块契约。
*
* app.ts 只做一件事:遍历 registry 里启用的模块并挂载到 `${API_PREFIX}${basePath}`。
* 因此「新增/移除一个功能模块」= 在 registry.ts 里增删一行,
* 不需要改动 app.ts、错误处理或实时通道。
*/
export interface ApiModule {
/** 模块名,用于日志 */
name: string;
/** 挂载路径,例如 '/classes' */
basePath: string;
/** Express 路由 */
router: Router;
/** 置为 false 可临时下线该模块(不出现在路由表中) */
enabled?: boolean;
}
export function defineModule(module: ApiModule): ApiModule {
return module;
}
@@ -0,0 +1,80 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createNotificationSchema,
listNotificationsQuerySchema,
notificationScopeQuerySchema,
type CreateNotificationInput,
} from './notifications.schemas.js';
import * as notificationService from './notifications.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
priority?: string;
unreadOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
});
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
});
/** POST /api/notifications/read-all - 全部标为已读 */
router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = (req.body ?? {}) as { classId?: string };
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
});
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
const dto = await notificationService.createNotification(user, req.body as CreateNotificationInput);
sendCreated(res, dto, '通知发布成功,已实时推送给学生');
},
);
/** POST /api/notifications/:id/read - 标记已读 */
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
});
/** DELETE /api/notifications/:id - 删除通知 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await notificationService.deleteNotification(user, id);
sendOk(res, { id }, '通知已删除');
},
);
export const notificationsModule = defineModule({
name: 'notifications',
basePath: '/notifications',
router,
});
@@ -0,0 +1,25 @@
import { z } from 'zod';
import { booleanFlagSchema } from '../../lib/schemas.js';
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
export const listNotificationsQuerySchema = z.object({
classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(),
unreadOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
});
export const createNotificationSchema = z.object({
classId: z.string().min(1, '请选择班级'),
title: z.string().trim().min(1, '请输入通知标题').max(120),
content: z.string().min(1, '请输入通知内容').max(5000),
priority: notificationPrioritySchema.optional(),
});
export const notificationScopeQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
@@ -0,0 +1,125 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toNotificationDto } from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import type { CreateNotificationInput } from './notifications.schemas.js';
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
export interface ListNotificationOptions {
classId?: string;
priority?: string;
unreadOnly?: boolean;
keyword?: string;
}
export async function listNotifications(
user: TokenPayload,
options: ListNotificationOptions,
): Promise<NotificationDto[]> {
const scope = await resolveClassScope(user, options.classId);
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
const notifications = await prisma.notification.findMany({
where: {
...classScopeWhere(scope),
...(options.priority ? { priority: options.priority } : {}),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}),
},
include: {
creator: creatorSelect,
reads: isStaff ? true : { where: { userId: user.sub } },
},
orderBy: [{ createdAt: 'desc' }],
take: 200,
});
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
}
/** 发布通知并实时广播到 class:{classId} 房间 */
export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
const created = await prisma.notification.create({
data: {
classId: input.classId,
title: input.title,
content: input.content,
priority: input.priority ?? 'NORMAL',
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
return dto;
}
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertClassWritable(user, current.classId);
await prisma.notification.delete({ where: { id: notificationId } });
}
/** 标记单条通知已读 */
export async function markAsRead(
user: TokenPayload,
notificationId: string,
): Promise<{ notificationId: string; readAt: string }> {
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId);
const read = await prisma.notificationRead.upsert({
where: { notificationId_userId: { notificationId, userId: user.sub } },
create: { notificationId, userId: user.sub },
update: {},
});
return { notificationId, readAt: read.readAt.toISOString() };
}
/** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
const scope = await resolveClassScope(user, classId);
const pending = await prisma.notification.findMany({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
select: { id: true },
});
for (const item of pending) {
await prisma.notificationRead.upsert({
where: { notificationId_userId: { notificationId: item.id, userId: user.sub } },
create: { notificationId: item.id, userId: user.sub },
update: {},
});
}
return { marked: pending.length };
}
/** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
const scope = await resolveClassScope(user, classId);
return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
});
}
+31
View File
@@ -0,0 +1,31 @@
import { authModule } from './auth/auth.module.js';
import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js';
import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import type { ApiModule } from './module.types.js';
/**
* 模块注册表 —— 模块化设计的唯一入口。
*
* 新增功能:新建 modules/<name>/ 目录并在这里加一行;
* 移除功能:删除对应一行(router 不再挂载,相关接口立即消失);
* 临时下线:把 enabled 置为 false。
*/
export const apiModules: ApiModule[] = [
authModule,
classesModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
gradesModule,
studentsModule,
teachersModule,
dashboardModule,
].filter((module) => module.enabled !== false);
@@ -0,0 +1,87 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createScheduleSchema,
gridQuerySchema,
listSchedulesQuerySchema,
updateScheduleSchema,
type CreateScheduleInput,
type UpdateScheduleInput,
} from './schedules.schemas.js';
import * as scheduleService from './schedules.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
});
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(
res,
await scheduleService.getScheduleGrid(user, { classId, week: week ?? resolveCurrentWeek() }),
'获取周视图成功',
);
});
/** POST /api/schedules - 新增课表 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await scheduleService.createSchedule(user, req.body as CreateScheduleInput),
'课表创建成功',
);
},
);
/** PATCH /api/schedules/:id - 修改课表 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await scheduleService.updateSchedule(user, id, req.body as UpdateScheduleInput),
'课表更新成功',
);
},
);
/** DELETE /api/schedules/:id - 删除课表 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await scheduleService.deleteSchedule(user, id);
sendOk(res, { id }, '课表已删除');
},
);
export const schedulesModule = defineModule({
name: 'schedules',
basePath: '/schedules',
router,
});
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { timeSchema, weekNumberSchema } from '../../lib/schemas.js';
export const listSchedulesQuerySchema = z.object({
classId: z.string().min(1).optional(),
/** 不传则按"当前教学周"过滤 */
week: weekNumberSchema.optional(),
dayOfWeek: z.coerce.number().int().min(1).max(7).optional(),
});
export const gridQuerySchema = z.object({
classId: z.string().min(1).optional(),
week: weekNumberSchema.optional(),
});
export const createScheduleSchema = z
.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1, '请选择课程'),
dayOfWeek: z.coerce.number().int().min(1, '星期范围为 1-7').max(7),
startTime: timeSchema,
endTime: timeSchema,
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
})
.refine((data) => data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
path: ['endTime'],
})
.refine((data) => (data.weekStart ?? 1) <= (data.weekEnd ?? 20), {
message: '起始周次不能大于结束周次',
path: ['weekEnd'],
});
export const updateScheduleSchema = z
.object({
courseId: z.string().min(1).optional(),
dayOfWeek: z.coerce.number().int().min(1).max(7).optional(),
startTime: timeSchema.optional(),
endTime: timeSchema.optional(),
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
})
.refine((data) => !data.startTime || !data.endTime || data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
path: ['endTime'],
});
export type CreateScheduleInput = z.infer<typeof createScheduleSchema>;
export type UpdateScheduleInput = z.infer<typeof updateScheduleSchema>;
@@ -0,0 +1,141 @@
import {
SOCKET_EVENTS,
buildScheduleWeekView,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toScheduleDto } from '../../lib/mappers.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { emitToClass } from '../../realtime/bus.js';
import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
export interface ListScheduleOptions {
classId?: string;
week?: number;
dayOfWeek?: number;
}
/** 课表列表:按班级权限过滤,week 过滤 weekStart <= week <= weekEnd */
export async function listSchedules(
user: TokenPayload,
options: ListScheduleOptions,
): Promise<ScheduleDto[]> {
const scope = await resolveClassScope(user, options.classId);
const week = options.week;
const schedules = await prisma.schedule.findMany({
where: {
...classScopeWhere(scope),
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
...(options.dayOfWeek ? { dayOfWeek: options.dayOfWeek } : {}),
},
include: { course: courseSelect },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
});
return schedules.map(toScheduleDto);
}
/** 周视图:7 列固定结构,供 Web / EXE 直接渲染 */
export async function getScheduleGrid(
user: TokenPayload,
options: { classId?: string; week?: number },
): Promise<ScheduleWeekView> {
const week = options.week ?? resolveCurrentWeek();
const items = await listSchedules(user, { classId: options.classId, week });
return buildScheduleWeekView(items, week);
}
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
await assertClassWritable(user, input.classId);
await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.schedule.create({
data: {
classId: input.classId,
courseId: input.courseId,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
location: input.location ?? null,
weekStart: input.weekStart ?? 1,
weekEnd: input.weekEnd ?? 20,
},
include: { course: courseSelect },
});
const dto = toScheduleDto(created);
emitToClass(created.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: created.classId,
action: 'created',
schedule: dto,
});
return dto;
}
export async function updateSchedule(
user: TokenPayload,
scheduleId: string,
input: UpdateScheduleInput,
): Promise<ScheduleDto> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const startTime = input.startTime ?? current.startTime;
const endTime = input.endTime ?? current.endTime;
if (startTime >= endTime) throw ApiError.badRequest('结束时间必须晚于开始时间');
const weekStart = input.weekStart ?? current.weekStart;
const weekEnd = input.weekEnd ?? current.weekEnd;
if (weekStart > weekEnd) throw ApiError.badRequest('起始周次不能大于结束周次');
const updated = await prisma.schedule.update({
where: { id: scheduleId },
data: {
...(input.courseId ? { courseId: input.courseId } : {}),
...(input.dayOfWeek ? { dayOfWeek: input.dayOfWeek } : {}),
startTime,
endTime,
...(input.location !== undefined ? { location: input.location ?? null } : {}),
weekStart,
weekEnd,
},
include: { course: courseSelect },
});
const dto = toScheduleDto(updated);
emitToClass(updated.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: updated.classId,
action: 'updated',
schedule: dto,
});
return dto;
}
export async function deleteSchedule(user: TokenPayload, scheduleId: string): Promise<void> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
await prisma.schedule.delete({ where: { id: scheduleId } });
emitToClass(current.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: current.classId,
action: 'deleted',
});
}
/** 校验课程确实属于该班级,避免把 A 班课程排进 B 班课表 */
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -0,0 +1,94 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createStudentSchema,
listStudentsQuerySchema,
resetPasswordSchema,
updateStudentSchema,
type CreateStudentInput,
type ResetPasswordInput,
type UpdateStudentInput,
} from './students.schemas.js';
import * as studentService from './students.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/students?classId=&keyword= - 学生名单(教师/管理员) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listStudentsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功');
},
);
/** POST /api/students - 新建学生账号 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await studentService.createStudent(user, req.body as CreateStudentInput),
'学生账号创建成功',
);
},
);
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await studentService.updateStudent(user, id, req.body as UpdateStudentInput),
'学生信息更新成功',
);
},
);
/** POST /api/students/:id/reset-password - 重置密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: resetPasswordSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await studentService.resetPassword(user, id, req.body as ResetPasswordInput);
sendOk(res, { id }, '密码已重置');
},
);
/** DELETE /api/students/:id - 删除学生账号 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await studentService.deleteStudent(user, id);
sendOk(res, { id }, '学生账号已删除');
},
);
export const studentsModule = defineModule({
name: 'students',
basePath: '/students',
router,
});
@@ -0,0 +1,28 @@
import { z } from 'zod';
import { keywordSchema } from '../../lib/schemas.js';
export const listStudentsQuerySchema = z.object({
classId: z.string().min(1).optional(),
keyword: keywordSchema,
});
export const createStudentSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
classId: z.string().min(1).nullish(),
});
export const updateStudentSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
name: z.string().trim().min(1).max(32).optional(),
classId: z.string().min(1).nullish(),
});
export const resetPasswordSchema = z.object({
newPassword: z.string().min(6, '新密码至少 6 位').max(128).optional(),
});
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
@@ -0,0 +1,149 @@
import type { StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { assertClassWritable, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toStudentDto } from '../../lib/mappers.js';
import { hashPassword } from '../../lib/password.js';
import type { CreateStudentInput, ResetPasswordInput, UpdateStudentInput } from './students.schemas.js';
export interface ListStudentOptions {
classId?: string;
keyword?: string;
}
/** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentOptions): Promise<StudentDto[]> {
const scope = await resolveClassScope(user, options.classId);
const students = await prisma.user.findMany({
where: {
role: 'STUDENT',
...classScopeWhere(scope),
...(options.keyword
? {
OR: [{ name: { contains: options.keyword } }, { username: { contains: options.keyword } }],
}
: {}),
},
include: { class: { select: { name: true, grade: true } } },
orderBy: [{ classId: 'asc' }, { username: 'asc' }],
take: 500,
});
return students.map(toStudentDto);
}
/** 新建学生账号(可选直接分班) */
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
if (input.classId) await assertClassWritable(user, input.classId);
const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword);
const created = await prisma.user.create({
data: {
username: input.username,
name: input.name,
role: 'STUDENT',
classId: input.classId ?? null,
passwordHash,
},
});
if (input.classId) {
await prisma.enrollment.upsert({
where: { userId_classId: { userId: created.id, classId: input.classId } },
create: { userId: created.id, classId: input.classId },
update: {},
});
}
const student = await prisma.user.findUniqueOrThrow({
where: { id: created.id },
include: { class: { select: { name: true, grade: true } } },
});
return toStudentDto(student);
}
/** 修改学生信息 / 调班 */
export async function updateStudent(
user: TokenPayload,
studentId: string,
input: UpdateStudentInput,
): Promise<StudentDto> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以直接修改');
}
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
if (nextClassId && nextClassId !== student.classId) {
await assertClassWritable(user, nextClassId);
}
const updated = await prisma.user.update({
where: { id: studentId },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
classId: nextClassId,
},
});
// 同步 Enrollment:换班时清理旧记录
if (nextClassId) {
await prisma.enrollment.deleteMany({ where: { userId: studentId, classId: { not: nextClassId } } });
await prisma.enrollment.upsert({
where: { userId_classId: { userId: studentId, classId: nextClassId } },
create: { userId: studentId, classId: nextClassId },
update: {},
});
} else {
await prisma.enrollment.deleteMany({ where: { userId: studentId } });
}
const result = await prisma.user.findUniqueOrThrow({
where: { id: updated.id },
include: { class: { select: { name: true, grade: true } } },
});
return toStudentDto(result);
}
/** 删除学生账号(级联删除其作业状态、成绩、已读记录) */
export async function deleteStudent(user: TokenPayload, studentId: string): Promise<void> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除');
}
await prisma.user.delete({ where: { id: studentId } });
}
/** 重置学生密码(未指定时使用默认初始密码) */
export async function resetPassword(
user: TokenPayload,
studentId: string,
input: ResetPasswordInput,
): Promise<void> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码');
}
await prisma.user.update({
where: { id: studentId },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultStudentPassword) },
});
}
@@ -0,0 +1,33 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createTeacherSchema, listTeachersQuerySchema, type CreateTeacherInput } from './teachers.schemas.js';
import * as teacherService from './teachers.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/teachers?keyword= - 教师列表(分配协作教师时使用) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
},
);
/** POST /api/teachers - 新建教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
getAuthUser(req);
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
});
export const teachersModule = defineModule({
name: 'teachers',
basePath: '/teachers',
router,
});
@@ -0,0 +1,15 @@
import { z } from 'zod';
import { keywordSchema } from '../../lib/schemas.js';
export const listTeachersQuerySchema = z.object({
keyword: keywordSchema,
});
export const createTeacherSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
password: z.string().min(6, '密码至少 6 位').max(128),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
export type CreateTeacherInput = z.infer<typeof createTeacherSchema>;
@@ -0,0 +1,33 @@
import type { UserDto } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { toUserDto } from '../../lib/mappers.js';
import { hashPassword } from '../../lib/password.js';
import type { CreateTeacherInput } from './teachers.schemas.js';
/**
* 教师列表:用于"分配协作教师"等需要选择教师的场景。
* 仅返回教师/管理员账号,字段与 UserDto 对齐。
*/
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
const teachers = await prisma.user.findMany({
where: {
role: { in: ['TEACHER', 'ADMIN'] },
...(keyword ? { OR: [{ name: { contains: keyword } }, { username: { contains: keyword } }] } : {}),
},
orderBy: [{ role: 'asc' }, { username: 'asc' }],
take: 200,
});
return teachers.map(toUserDto);
}
export async function createTeacher(input: CreateTeacherInput): Promise<UserDto> {
const created = await prisma.user.create({
data: {
username: input.username,
name: input.name,
role: input.role ?? 'TEACHER',
passwordHash: await hashPassword(input.password),
},
});
return toUserDto(created);
}
+30
View File
@@ -0,0 +1,30 @@
import { SOCKET_ROOMS } from '@classhelper/shared';
/**
* 实时事件总线。
*
* 业务模块只依赖这里,不直接依赖 Socket.IO 实例,
* 因此「移除实时推送」只需让它注册不到 emitter,业务代码无需改动。
*/
export type RealtimeEmitter = (rooms: string[], event: string, payload: unknown) => void;
let emitter: RealtimeEmitter | null = null;
export function registerEmitter(next: RealtimeEmitter): void {
emitter = next;
}
export function emitToRooms(rooms: string[], event: string, payload: unknown): void {
if (!emitter || rooms.length === 0) return;
emitter(rooms, event, payload);
}
/** 按班级房间广播(class:{classId}) */
export function emitToClass(classId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
}
/** 定向推送给单个用户 */
export function emitToUser(userId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.user(userId)], event, payload);
}
+141
View File
@@ -0,0 +1,141 @@
import type { Server as HttpServer } from 'node:http';
import { Server } from 'socket.io';
import {
SOCKET_EVENTS,
SOCKET_ROOMS,
type ClientToServerEvents,
type ServerToClientEvents,
type SocketAuthPayload,
type UserRole,
} from '@classhelper/shared';
import { env } from '../config/env.js';
import { getAccessibleClassIds } from '../lib/access.js';
import { verifyToken } from '../lib/jwt.js';
import { logger } from '../lib/logger.js';
import { registerEmitter, type RealtimeEmitter } from './bus.js';
interface SocketData {
user: {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
};
}
export type RealtimeServer = Server<
ClientToServerEvents,
ServerToClientEvents,
Record<string, never>,
SocketData
>;
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */
export function initRealtime(httpServer: HttpServer): RealtimeServer {
const io: RealtimeServer = new Server(httpServer, {
cors: { origin: env.corsOrigins, credentials: true },
transports: ['websocket', 'polling'],
pingInterval: 25000,
pingTimeout: 20000,
});
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
io.use((socket, next) => {
try {
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>;
const queryToken =
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null;
const token = auth.token ?? queryToken;
if (!token) {
next(new Error('缺少认证 token'));
return;
}
const payload = verifyToken(token);
socket.data.user = {
id: payload.sub,
username: payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,
};
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
});
io.on('connection', (socket) => {
const user = socket.data.user;
void (async () => {
const rooms = [SOCKET_ROOMS.user(user.id), SOCKET_ROOMS.role(user.role)];
try {
const classIds = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
});
for (const classId of classIds) {
await socket.join(SOCKET_ROOMS.class(classId));
rooms.push(SOCKET_ROOMS.class(classId));
}
} catch (error) {
logger.warn(`加入班级房间失败(user=${user.id})`, error);
}
await socket.join(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
rooms.push(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
socket.emit(SOCKET_EVENTS.connected, { userId: user.id, role: user.role, rooms });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)
socket.on('class:join', (classId, ack) => {
void (async () => {
try {
const allowed = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
});
if (!allowed.includes(classId)) {
ack?.({ ok: false, message: '无权订阅该班级' });
return;
}
await socket.join(SOCKET_ROOMS.class(classId));
ack?.({ ok: true });
} catch {
ack?.({ ok: false, message: '订阅失败' });
}
})();
});
socket.on('class:leave', (classId) => {
void socket.leave(SOCKET_ROOMS.class(classId));
});
socket.on('ping', (ack) => {
ack?.();
});
socket.on('disconnect', (reason) => {
logger.debug(`实时连接断开:${user.name} (${reason})`);
});
});
const emit: RealtimeEmitter = (rooms, event, payload) => {
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
target.emit(event, payload);
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
};
registerEmitter(emit);
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / user:{id})');
return io;
}
+22
View File
@@ -0,0 +1,22 @@
import type { TokenPayload } from '../lib/jwt.js';
/**
* 扩展 Express 的 Request 类型。
* - auth:认证中间件解析出的当前用户
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
*/
declare global {
namespace Express {
interface Request {
auth?: {
token: string;
user: TokenPayload;
};
validatedQuery?: unknown;
validatedParams?: unknown;
}
}
}
export {};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"lib": ["ES2023"],
"types": ["node"],
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "prisma/**/*.ts", "prisma.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@classhelper/shared",
"version": "0.1.0",
"private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
},
"devDependencies": {
"typescript": "~5.9.3"
}
}
+108
View File
@@ -0,0 +1,108 @@
import type { NotificationPriority, UserRole } from './types.js';
/** API 前缀 */
export const API_PREFIX = '/api';
/** REST 路径集中管理,避免前端散落硬编码字符串 */
export const API_PATHS = {
auth: {
login: '/auth/login',
logout: '/auth/logout',
me: '/auth/me',
changePassword: '/auth/password',
},
classes: '/classes',
courses: '/courses',
schedules: '/schedules',
homeworks: '/homeworks',
notifications: '/notifications',
grades: '/grades',
students: '/students',
teachers: '/teachers',
dashboard: '/dashboard/summary',
} as const;
/** 用户角色 */
export const USER_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER', 'STUDENT'];
export const ROLE_LABELS: Record<UserRole, string> = {
ADMIN: '系统管理员',
TEACHER: '教师',
STUDENT: '学生',
};
/** 通知优先级 */
export const NOTIFICATION_PRIORITIES: readonly NotificationPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
export const PRIORITY_LABELS: Record<NotificationPriority, string> = {
LOW: '低',
NORMAL: '普通',
HIGH: '重要',
URGENT: '紧急',
};
/** Element Plus tag 类型,三端统一文案与配色 */
export const PRIORITY_TAG_TYPES: Record<NotificationPriority, 'info' | 'primary' | 'warning' | 'danger'> = {
LOW: 'info',
NORMAL: 'primary',
HIGH: 'warning',
URGENT: 'danger',
};
/** 星期:1=周一 ... 7=周日(与数据库 dayOfWeek 一致) */
export const WEEKDAY_LABELS: Record<number, string> = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
};
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
/** 默认学期周次上限 */
export const DEFAULT_WEEK_COUNT = 20;
/** WebSocket 事件名(服务端广播 / 客户端监听保持一致) */
export const SOCKET_EVENTS = {
notificationNew: 'notification:new',
homeworkNew: 'homework:new',
homeworkUpdated: 'homework:updated',
homeworkStatus: 'homework:status',
gradeUpdated: 'grade:updated',
scheduleUpdated: 'schedule:updated',
classUpdated: 'class:updated',
connected: 'connected',
} as const;
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = {
class: (classId: string): string => `class:${classId}`,
user: (userId: string): string => `user:${userId}`,
teacher: (teacherId: string): string => `teacher:${teacherId}`,
role: (role: UserRole): string => `role:${role}`,
students: 'students',
teachers: 'teachers',
} as const;
/** 本地持久化键(Web 端 localStorage / EXE 端 IndexedDB 命名空间) */
export const STORAGE_KEYS = {
token: 'classhelper.token',
user: 'classhelper.user',
serverUrl: 'classhelper.serverUrl',
cachePrefix: 'classhelper.cache',
lastSyncAt: 'classhelper.lastSyncAt',
} as const;
/** 桌面客户端默认后端地址 */
export const DEFAULT_SERVER_URL = 'http://127.0.0.1:4000';
/** 默认端口与分页 */
export const DEFAULT_SERVER_PORT = 4000;
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 200;
/** 教师与管理员角色集合,便于 RBAC 判断 */
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
+7
View File
@@ -0,0 +1,7 @@
/**
* @classhelper/shared 入口。
* 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。
*/
export * from './constants.js';
export * from './utils.js';
export type * from './types.js';
+350
View File
@@ -0,0 +1,350 @@
/**
* 三端共享的领域类型定义。
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
* 后端返回值与前端调用方都必须依赖它,避免契约漂移。
*/
/** 用户角色:管理员 / 教师 / 学生 */
export type UserRole = 'ADMIN' | 'TEACHER' | 'STUDENT';
/** 通知优先级 */
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
/** 成绩等级 */
export type GradeLevel = 'A' | 'B' | 'C' | 'D' | 'E';
/** 统一响应体(后端所有接口均返回该结构) */
export interface ApiResponse<T = unknown> {
success: boolean;
data: T;
message: string;
}
/** 统一错误响应体 */
export interface ApiErrorResponse {
success: false;
data: null;
message: string;
code?: string;
details?: unknown;
}
/** 分页查询参数 */
export interface PaginationQuery {
page?: number;
pageSize?: number;
}
/** 分页结果 */
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/* ------------------------------------------------------------------ 用户 / 认证 */
export interface UserDto {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
createdAt: string;
}
export interface StudentDto extends UserDto {
className?: string | null;
grade?: string | null;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
user: UserDto;
}
/* ------------------------------------------------------------------ 班级 */
export interface ClassDto {
id: string;
name: string;
grade: string;
teacherId: string;
createdAt: string;
teacher?: ClassTeacherBrief | null;
studentCount?: number;
courseCount?: number;
homeworkCount?: number;
notificationCount?: number;
}
export interface ClassTeacherBrief {
id: string;
name: string;
username: string;
}
export interface ClassDetailDto extends ClassDto {
students: StudentDto[];
courses: CourseDto[];
teachers: ClassTeacherBrief[];
}
export interface CreateClassRequest {
name: string;
grade: string;
}
export interface UpdateClassRequest {
name?: string;
grade?: string;
}
/* ------------------------------------------------------------------ 课程 */
export interface CourseDto {
id: string;
name: string;
teacherId: string;
classId: string;
createdAt: string;
teacher?: ClassTeacherBrief | null;
}
export interface CreateCourseRequest {
name: string;
classId: string;
}
/* ------------------------------------------------------------------ 课表 */
/**
* 课表条目。
* 星期与周次均为 1 起始:dayOfWeek 1=周一 ... 7=周日,weekStart/weekEnd 为学期周次。
*/
export interface ScheduleDto {
id: string;
classId: string;
courseId: string;
dayOfWeek: number;
/** HH:mm */
startTime: string;
/** HH:mm */
endTime: string;
location: string | null;
weekStart: number;
weekEnd: number;
createdAt: string;
course?: CourseBrief | null;
}
export interface CourseBrief {
id: string;
name: string;
}
export interface CreateScheduleRequest {
classId: string;
courseId: string;
dayOfWeek: number;
startTime: string;
endTime: string;
location?: string | null;
weekStart?: number;
weekEnd?: number;
}
export type UpdateScheduleRequest = Partial<CreateScheduleRequest>;
/** 按天分组的课表视图,供客户端渲染周视图 */
export interface ScheduleDayColumn {
dayOfWeek: number;
label: string;
items: ScheduleDto[];
}
export interface ScheduleWeekView {
week: number;
columns: ScheduleDayColumn[];
}
/* ------------------------------------------------------------------ 作业 */
export interface HomeworkDto {
id: string;
classId: string;
courseId: string | null;
title: string;
content: string;
attachmentUrl: string | null;
dueAt: string | null;
createdBy: string;
createdAt: string;
course?: CourseBrief | null;
creator?: ClassTeacherBrief | null;
/** 当前登录学生的完成状态(学生端接口返回) */
completed?: boolean;
homeworkStatus?: HomeworkStatusDto | null;
/** 教师视角:已提交人数 */
completedCount?: number;
}
export interface HomeworkStatusDto {
id: string;
homeworkId: string;
userId: string;
completed: boolean;
updatedAt: string;
}
export interface CreateHomeworkRequest {
classId: string;
courseId?: string | null;
title: string;
content: string;
attachmentUrl?: string | null;
dueAt?: string | null;
}
export interface UpdateHomeworkStatusRequest {
completed: boolean;
}
export interface HomeworkQueryParams {
classId?: string;
courseId?: string;
/** 学生端:只看未完成 */
pendingOnly?: boolean;
keyword?: string;
}
/* ------------------------------------------------------------------ 通知 */
export interface NotificationDto {
id: string;
classId: string;
title: string;
content: string;
priority: NotificationPriority;
createdBy: string;
createdAt: string;
creator?: ClassTeacherBrief | null;
/** 当前用户是否已读 */
read?: boolean;
readAt?: string | null;
/** 教师视角:已读人数 */
readCount?: number;
}
export interface CreateNotificationRequest {
classId: string;
title: string;
content: string;
priority?: NotificationPriority;
}
export interface NotificationQueryParams {
classId?: string;
priority?: NotificationPriority;
unreadOnly?: boolean;
keyword?: string;
}
/* ------------------------------------------------------------------ 成绩 */
export interface GradeDto {
id: string;
classId: string;
courseId: string | null;
userId: string;
examName: string;
score: number;
totalScore: number;
publishedAt: string;
course?: CourseBrief | null;
student?: ClassTeacherBrief | null;
className?: string | null;
}
export interface CreateGradeRequest {
classId: string;
courseId?: string | null;
userId: string;
examName: string;
score: number;
totalScore?: number;
publishedAt?: string | null;
}
/** 批量导入成绩(教师端"导入成绩"使用) */
export interface BulkCreateGradeRequest {
classId: string;
courseId?: string | null;
examName: string;
totalScore?: number;
publishedAt?: string | null;
items: Array<{ userId: string; score: number }>;
}
/** 成绩统计(教师端图表 / 学生端汇总) */
export interface GradeStats {
total: number;
averagePercent: number;
byCourse: Array<{ courseId: string | null; courseName: string; count: number; averagePercent: number }>;
distribution: Array<{ level: GradeLevel; count: number }>;
}
/* ------------------------------------------------------------------ 仪表盘 */
export interface DashboardSummary {
classCount: number;
studentCount: number;
courseCount: number;
scheduleCount: number;
homeworkCount: number;
notificationCount: number;
gradeCount: number;
unreadNotificationCount: number;
pendingHomeworkCount: number;
recentNotifications: NotificationDto[];
recentHomeworks: HomeworkDto[];
upcomingDeadlines: HomeworkDto[];
}
/* ------------------------------------------------------------------ WebSocket */
/** 服务端 -> 客户端事件名 */
export interface ServerToClientEvents {
'notification:new': (payload: NotificationDto) => void;
'homework:new': (payload: HomeworkDto) => void;
/** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */
'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void;
'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void;
'grade:updated': (payload: GradeDto) => void;
'schedule:updated': (payload: {
classId: string;
action: 'created' | 'updated' | 'deleted';
schedule?: ScheduleDto;
}) => void;
'class:updated': (payload: { classId: string; action: 'created' | 'updated' | 'deleted' }) => void;
connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void;
}
/** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
'class:leave': (classId: string) => void;
ping: (ack?: () => void) => void;
}
/** 连接鉴权信息(Socket.IO handshake.auth) */
export interface SocketAuthPayload {
token: string;
clientType?: 'admin' | 'desktop';
}
+209
View File
@@ -0,0 +1,209 @@
import { WEEKDAY_LABELS, WEEKDAYS } from './constants.js';
import type { GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
/* ------------------------------------------------------------------ 日期时间 */
export function toDate(value: string | number | Date): Date {
return value instanceof Date ? value : new Date(value);
}
function pad(input: number): string {
return input < 10 ? `0${input}` : String(input);
}
/** 格式化为 YYYY-MM-DD,withTime=true 时追加 HH:mm */
export function formatDate(value: string | number | Date | null | undefined, withTime = false): string {
if (value === null || value === undefined || value === '') return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime ? `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
}
export function formatTime(value: string | number | Date | null | undefined): string {
if (value === null || value === undefined || value === '') return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** 周几,1=周一 ... 7=周日 */
export function weekdayOf(value: string | number | Date): number {
const day = toDate(value).getDay();
return day === 0 ? 7 : day;
}
/** ISO 周次(与学期周次解耦,仅用于展示当前自然周) */
export function getIsoWeek(value: string | number | Date): number {
const date = toDate(value);
const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNumber = target.getUTCDay() || 7;
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
return Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/** 相对时间描述,例如"3 分钟前" */
export function relativeTime(value: string | number | Date, now: Date = new Date()): string {
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffSeconds < 60) return '刚刚';
const minutes = Math.floor(diffSeconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
return formatDate(date);
}
/* ------------------------------------------------------------------ 课表 */
/** 判断某个课表条目在第 week 周是否上课 */
export function isScheduleActiveInWeek(
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'>,
week: number,
): boolean {
return week >= schedule.weekStart && week <= schedule.weekEnd;
}
/** 格式化周次范围,例如"1-20 周" / "单周" */
export function formatWeekRange(weekStart: number, weekEnd: number): string {
if (weekStart === weekEnd) return `第 ${weekStart} 周`;
return `${weekStart}-${weekEnd} 周`;
}
/** 按时间排序(HH:mm 字符串可直接字典序比较) */
export function sortScheduleByTime<T extends Pick<ScheduleDto, 'dayOfWeek' | 'startTime'>>(items: T[]): T[] {
return [...items].sort((a, b) =>
a.dayOfWeek === b.dayOfWeek ? a.startTime.localeCompare(b.startTime) : a.dayOfWeek - b.dayOfWeek,
);
}
/** 组装周视图:7 列固定输出,便于前端直接渲染 */
export function buildScheduleWeekView(items: ScheduleDto[], week: number): ScheduleWeekView {
const active = sortScheduleByTime(items.filter((item) => isScheduleActiveInWeek(item, week)));
return {
week,
columns: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
label: WEEKDAY_LABELS[dayOfWeek] ?? `周${dayOfWeek}`,
items: active.filter((item) => item.dayOfWeek === dayOfWeek),
})),
};
}
/** 计算两个 HH:mm 之间的时长(分钟),非法输入返回 0 */
export function durationMinutes(startTime: string, endTime: string): number {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
if ([sh, sm, eh, em].some((n) => Number.isNaN(n))) return 0;
return (eh as number) * 60 + (em as number) - ((sh as number) * 60 + (sm as number));
}
/* ------------------------------------------------------------------ 作业 */
/** 是否已过截止时间 */
export function isOverdue(dueAt: string | null | undefined, now: Date = new Date()): boolean {
if (!dueAt) return false;
const due = toDate(dueAt);
return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime();
}
/** 距离截止时间的可读描述,例如"还剩 2 天"、"已逾期 3 小时" */
export function formatDeadline(dueAt: string | null | undefined, now: Date = new Date()): string {
if (!dueAt) return '无截止时间';
const due = toDate(dueAt);
if (Number.isNaN(due.getTime())) return '无截止时间';
const diffMinutes = Math.round((due.getTime() - now.getTime()) / 60000);
const prefix = diffMinutes < 0 ? '已逾期' : '还剩';
const abs = Math.abs(diffMinutes);
if (abs < 60) return `${prefix} ${abs} 分钟`;
if (abs < 60 * 24) return `${prefix} ${Math.floor(abs / 60)} 小时`;
return `${prefix} ${Math.floor(abs / (60 * 24))} 天`;
}
/* ------------------------------------------------------------------ 成绩 */
/** 得分率(0-100),总分非法时返回 0 */
export function gradePercent(score: number, totalScore: number): number {
if (!Number.isFinite(score) || !Number.isFinite(totalScore) || totalScore <= 0) return 0;
return round((score / totalScore) * 100, 1);
}
/** 等级换算,学生端图表与标签复用 */
export function gradeLevel(percent: number): GradeLevel {
if (percent >= 90) return 'A';
if (percent >= 80) return 'B';
if (percent >= 70) return 'C';
if (percent >= 60) return 'D';
return 'E';
}
export function averageOf(numbers: number[]): number {
const valid = numbers.filter((n) => Number.isFinite(n));
if (valid.length === 0) return 0;
return round(valid.reduce((sum, n) => sum + n, 0) / valid.length, 1);
}
export function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/* ------------------------------------------------------------------ 通用 */
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function groupBy<T, K extends string | number>(items: T[], keyOf: (item: T) => K): Map<K, T[]> {
const map = new Map<K, T[]>();
for (const item of items) {
const key = keyOf(item);
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
return map;
}
export function uniqueBy<T, K>(items: T[], keyOf: (item: T) => K): T[] {
const seen = new Set<K>();
const result: T[] = [];
for (const item of items) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
/** 生成 [1, max] 的周次选项,课表周次切换与表单下拉复用 */
export function buildWeekOptions(max = 20, min = 1): number[] {
const safeMax = Math.max(min, Math.floor(max));
return Array.from({ length: safeMax - min + 1 }, (_, index) => min + index);
}
/** 安全的数字转换,表单输入(字符串)到接口字段(number)之间使用 */
export function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
/** 截断长文本(列表页展示摘要) */
export function truncate(text: string | null | undefined, max = 60): string {
if (!text) return '';
return text.length <= max ? text : `${text.slice(0, max)}…`;
}
/** HH:mm 校验 */
export function isValidTimeString(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"lib": ["ES2023"]
},
"include": ["src/**/*.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
# ---------------------------------------------------------------------------
# Web 管理端环境变量(可选)
# 默认直连 http://127.0.0.1:4000,一般无需配置。
# 复制为 .env 后按需修改:cp .env.example .env
# ---------------------------------------------------------------------------
# 后端 API 基地址
# 直连(默认):http://127.0.0.1:4000/api
# 走 Vite 开发代理(vite.config.ts 已配置 /api -> 后端):/api
# VITE_API_BASE_URL=http://127.0.0.1:4000/api
# Socket.IO 服务地址(不含 /socket.io 路径)
# VITE_SOCKET_URL=http://127.0.0.1:4000
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班级小助手 · 管理端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@classhelper/web-admin",
"version": "0.1.0",
"private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
"build:only": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"socket.io-client": "^4.8.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@types/node": "^22.20.2",
"@vitejs/plugin-vue": "^6.0.8",
"typescript": "~5.9.3",
"vite": "^8.3.0",
"vue-tsc": "^3.3.11"
}
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
const auth = useAuthStore();
const realtime = useRealtimeStore();
/** 已登录时建立实时连接,退出登录时断开 */
function syncRealtime(token: string | null): void {
if (token) realtime.connect(token);
else realtime.disconnect();
}
onMounted(() => {
syncRealtime(auth.token);
});
watch(
() => auth.token,
(token) => syncRealtime(token),
);
onUnmounted(() => {
realtime.disconnect();
});
</script>
<template>
<router-view />
</template>
+65
View File
@@ -0,0 +1,65 @@
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { ElMessage } from 'element-plus';
import { STORAGE_KEYS, type ApiErrorResponse, type ApiResponse } from '@classhelper/shared';
import { API_BASE_URL } from '@/config';
/**
* Axios 封装:
* 1) 自动携带 JWT
* 2) 统一拆包(响应拦截器直接把 data 返回给调用方)
* 3) 统一错误提示;401 自动清理会话并回到登录页
*/
const instance = axios.create({
baseURL: API_BASE_URL,
timeout: 20000,
});
instance.interceptors.request.use((config) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
instance.interceptors.response.use(
(response) => {
const body = response.data as ApiResponse<unknown> | undefined;
if (body && typeof body === 'object' && 'success' in body) {
if (!body.success) {
ElMessage.error(body.message || '请求失败');
return Promise.reject(new Error(body.message || '请求失败'));
}
return body.data as never;
}
return response.data as never;
},
(error: AxiosError<ApiErrorResponse>) => {
const status = error.response?.status;
const message = error.response?.data?.message ?? error.message ?? '网络请求失败';
if (status === 401) {
localStorage.removeItem(STORAGE_KEYS.token);
localStorage.removeItem(STORAGE_KEYS.user);
ElMessage.warning('登录状态已失效,请重新登录');
if (!window.location.pathname.endsWith('/login')) {
window.location.assign('/login');
}
} else {
ElMessage.error(message);
}
return Promise.reject(error);
},
);
/** 统一请求入口:返回值已经是响应体里的 data */
async function request<T>(config: AxiosRequestConfig): Promise<T> {
return (await instance.request(config)) as unknown as T;
}
export const api = {
get: <T>(url: string, params?: Record<string, unknown>): Promise<T> =>
request<T>({ method: 'GET', url, params }),
post: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'POST', url, data }),
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
put: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PUT', url, data }),
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
};
+182
View File
@@ -0,0 +1,182 @@
import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type CourseDto,
type CreateClassRequest,
type CreateCourseRequest,
type CreateGradeRequest,
type CreateHomeworkRequest,
type CreateNotificationRequest,
type CreateScheduleRequest,
type DashboardSummary,
type GradeDto,
type GradeStats,
type HomeworkDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
type ScheduleDto,
type ScheduleWeekView,
type StudentDto,
type UpdateClassRequest,
type UserDto,
} from '@classhelper/shared';
import { api } from './http';
/* ------------------------------------------------------------------ 认证 */
export const authApi = {
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
changePassword: (payload: {
currentPassword: string;
newPassword: string;
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
};
/* ------------------------------------------------------------------ 班级 */
export const classApi = {
list: (params?: { keyword?: string }): Promise<ClassDto[]> => api.get(API_PATHS.classes, params),
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
create: (payload: CreateClassRequest): Promise<ClassDto> => api.post(API_PATHS.classes, payload),
update: (id: string, payload: UpdateClassRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`),
students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`),
addStudent: (
id: string,
payload: { username: string; name: string; password?: string },
): Promise<StudentDto> => api.post(`${API_PATHS.classes}/${id}/students`, payload),
removeStudent: (id: string, userId: string): Promise<unknown> =>
api.delete(`${API_PATHS.classes}/${id}/students/${userId}`),
assignTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
};
/* ------------------------------------------------------------------ 课程 */
export const courseApi = {
list: (classId?: string): Promise<CourseDto[]> =>
api.get(API_PATHS.courses, classId ? { classId } : undefined),
create: (payload: CreateCourseRequest): Promise<CourseDto> => api.post(API_PATHS.courses, payload),
update: (id: string, payload: { name: string }): Promise<CourseDto> =>
api.patch(`${API_PATHS.courses}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.courses}/${id}`),
};
/* ------------------------------------------------------------------ 课表 */
export const scheduleApi = {
list: (params: { classId?: string; week?: number; dayOfWeek?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
api.get(`${API_PATHS.schedules}/grid`, params),
create: (payload: CreateScheduleRequest): Promise<ScheduleDto> => api.post(API_PATHS.schedules, payload),
update: (id: string, payload: Partial<CreateScheduleRequest>): Promise<ScheduleDto> =>
api.patch(`${API_PATHS.schedules}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.schedules}/${id}`),
};
/* ------------------------------------------------------------------ 作业 */
export const homeworkApi = {
list: (params: {
classId?: string;
courseId?: string;
pendingOnly?: boolean;
keyword?: string;
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
};
/* ------------------------------------------------------------------ 通知 */
export const notificationApi = {
list: (params: {
classId?: string;
priority?: string;
unreadOnly?: boolean;
keyword?: string;
}): Promise<NotificationDto[]> => api.get(API_PATHS.notifications, params),
create: (payload: CreateNotificationRequest): Promise<NotificationDto> =>
api.post(API_PATHS.notifications, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.notifications}/${id}`),
unreadCount: (classId?: string): Promise<{ count: number }> =>
api.get(`${API_PATHS.notifications}/unread-count`, classId ? { classId } : undefined),
};
/* ------------------------------------------------------------------ 成绩 */
export const gradeApi = {
list: (params: {
classId?: string;
courseId?: string;
userId?: string;
examName?: string;
}): Promise<GradeDto[]> => api.get(API_PATHS.grades, params),
stats: (params: { classId?: string; courseId?: string; examName?: string }): Promise<GradeStats> =>
api.get(`${API_PATHS.grades}/stats`, params),
create: (payload: CreateGradeRequest): Promise<GradeDto> => api.post(API_PATHS.grades, payload),
bulkCreate: (payload: {
classId: string;
courseId?: string | null;
examName: string;
totalScore?: number;
publishedAt?: string | null;
items: Array<{ userId: string; score: number }>;
}): Promise<{ count: number; items: GradeDto[] }> => api.post(`${API_PATHS.grades}/bulk`, payload),
update: (id: string, payload: Partial<CreateGradeRequest>): Promise<GradeDto> =>
api.patch(`${API_PATHS.grades}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.grades}/${id}`),
};
/* ------------------------------------------------------------------ 学生 */
export const studentApi = {
list: (params?: { classId?: string; keyword?: string }): Promise<StudentDto[]> =>
api.get(API_PATHS.students, params),
create: (payload: {
username: string;
name: string;
password?: string;
classId?: string | null;
}): Promise<StudentDto> => api.post(API_PATHS.students, payload),
update: (
id: string,
payload: { username?: string; name?: string; classId?: string | null },
): Promise<StudentDto> => api.patch(`${API_PATHS.students}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
resetPassword: (id: string, newPassword?: string): Promise<unknown> =>
api.post(`${API_PATHS.students}/${id}/reset-password`, newPassword ? { newPassword } : {}),
};
/* ------------------------------------------------------------------ 仪表盘 */
export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
};
/* ------------------------------------------------------------------ 协作教师 */
export const teacherApi = {
list: (keyword?: string): Promise<UserDto[]> =>
api.get(API_PATHS.teachers, keyword ? { keyword } : undefined),
create: (payload: {
username: string;
name: string;
password: string;
role?: 'TEACHER' | 'ADMIN';
}): Promise<UserDto> => api.post(API_PATHS.teachers, payload),
/** 班级详情中的 teachers 字段即为已分配的协作教师 */
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
};
+12
View File
@@ -0,0 +1,12 @@
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
/**
* 运行时配置。
* 默认直连后端(开发/生产都可用);如需走 Vite 代理,把 .env 里的
* VITE_API_BASE_URL 设为 /api 即可(vite.config.ts 已配置代理)。
*/
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? `${DEFAULT_SERVER_URL}/api`;
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL ?? DEFAULT_SERVER_URL;
export const APP_TITLE = '班级小助手 · 管理端';
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 后端 API 基地址,默认 http://127.0.0.1:4000/api */
readonly VITE_API_BASE_URL?: string;
/** Socket.IO 服务地址,默认 http://127.0.0.1:4000 */
readonly VITE_SOCKET_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { authApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { APP_TITLE } from '@/config';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const menuItems = [
{ name: 'dashboard', title: '仪表盘', icon: 'Odometer' },
{ name: 'classes', title: '班级管理', icon: 'School' },
{ name: 'students', title: '学生管理', icon: 'User' },
{ name: 'schedules', title: '课表管理', icon: 'Calendar' },
{ name: 'homeworks', title: '作业发布', icon: 'Notebook' },
{ name: 'notifications', title: '通知发布', icon: 'Bell' },
{ name: 'grades', title: '成绩录入', icon: 'Trophy' },
];
const activeMenu = computed(() => `/${String(route.name ?? 'dashboard')}`);
const connectionText = computed(() => {
if (realtime.connected) return '实时通道已连接';
if (realtime.connecting) return '实时通道连接中';
return '实时通道已断开';
});
const connectionType = computed(() =>
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
);
function go(name: string): void {
void router.push({ name });
}
async function handleLogout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
await auth.logout();
ElMessage.success('已退出登录');
void router.push({ name: 'login' });
}
/* ------------------------------------------------------------ 修改密码 */
const passwordVisible = ref(false);
const passwordFormRef = ref<FormInstance>();
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const passwordRules: FormRules = {
currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' },
],
confirmPassword: [
{
validator: (_rule, value: string, callback: (error?: Error) => void) => {
if (!value) callback(new Error('请再次输入新密码'));
else if (value !== passwordForm.newPassword) callback(new Error('两次输入的密码不一致'));
else callback();
},
trigger: 'blur',
},
],
};
async function submitPassword(): Promise<void> {
const valid = await passwordFormRef.value?.validate().catch(() => false);
if (!valid) return;
await authApi.changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
});
passwordVisible.value = false;
passwordForm.currentPassword = '';
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
ElMessage.success('密码修改成功,请重新登录');
await auth.logout();
void router.push({ name: 'login' });
}
</script>
<template>
<el-container class="layout">
<el-aside width="210px" class="layout-aside">
<div class="brand">
<el-icon :size="22"><School /></el-icon>
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="go">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="header-left">
<span class="header-title">{{ APP_TITLE }}</span>
<el-tag :type="connectionType" size="small" effect="light">
<span class="header-conn">{{ connectionText }}</span>
</el-tag>
<el-tag v-if="realtime.eventCount > 0" size="small" type="info" effect="plain">
已接收 {{ realtime.eventCount }} 条实时事件
</el-tag>
</div>
<div class="header-right">
<el-dropdown trigger="click">
<span class="user-chip">
<el-icon><UserFilled /></el-icon>
{{ auth.displayName }}
<el-tag size="small" type="info" effect="plain">{{ auth.roleLabel }}</el-tag>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="passwordVisible = true">
<el-icon><Lock /></el-icon>
修改密码
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</el-main>
</el-container>
<el-dialog v-model="passwordVisible" title="修改密码" width="420px">
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="90px">
<el-form-item label="当前密码" prop="currentPassword">
<el-input v-model="passwordForm.currentPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="passwordVisible = false">取消</el-button>
<el-button type="primary" @click="submitPassword">确认修改</el-button>
</template>
</el-dialog>
</el-container>
</template>
<style scoped>
.layout {
height: 100vh;
}
.layout-aside {
background: #1f2d3d;
color: #fff;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 56px;
padding: 0 16px;
color: #fff;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-text {
font-size: 16px;
}
.layout-menu {
border-right: none;
background: transparent;
flex: 1;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #ffffff;
}
.layout-menu :deep(.el-menu-item.is-active) {
background: #409eff;
border-radius: 6px;
margin: 0 8px;
}
.layout-menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.layout-header {
background: #fff;
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-title {
font-weight: 600;
}
.header-conn {
margin-left: 4px;
}
.header-right {
display: flex;
align-items: center;
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
outline: none;
}
.layout-main {
background: var(--ch-bg);
padding: 0;
overflow-y: auto;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
import '@/styles/index.css';
const app = createApp(App);
// 全量注册 Element Plus 图标,模板里可直接 <el-icon><Bell /></el-icon>
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
app.mount('#app');
+90
View File
@@ -0,0 +1,90 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { STORAGE_KEYS } from '@classhelper/shared';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true, title: '登录' },
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
},
{
path: 'classes',
name: 'classes',
component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School' },
},
{
path: 'students',
name: 'students',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User' },
},
{
path: 'schedules',
name: 'schedules',
component: () => import('@/views/SchedulesView.vue'),
meta: { title: '课表管理', icon: 'Calendar' },
},
{
path: 'homeworks',
name: 'homeworks',
component: () => import('@/views/HomeworksView.vue'),
meta: { title: '作业发布', icon: 'Notebook' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/NotificationsView.vue'),
meta: { title: '通知发布', icon: 'Bell' },
},
{
path: 'grades',
name: 'grades',
component: () => import('@/views/GradesView.vue'),
meta: { title: '成绩录入', icon: 'Trophy' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
meta: { public: true, title: '页面不存在' },
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) {
if (to.name === 'login' && token) return { name: 'dashboard' };
return true;
}
if (!token) {
return { name: 'login', query: { redirect: to.fullPath } };
}
return true;
});
router.afterEach((to) => {
const title = typeof to.meta.title === 'string' ? to.meta.title : '';
document.title = title ? `${title} · 班级小助手` : '班级小助手 · 管理端';
});
export default router;
+89
View File
@@ -0,0 +1,89 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, type LoginRequest, type LoginResponse, type StudentDto } from '@classhelper/shared';
import { authApi } from '@/api';
function readStoredUser(): StudentDto | null {
const raw = localStorage.getItem(STORAGE_KEYS.user);
if (!raw) return null;
try {
return JSON.parse(raw) as StudentDto;
} catch {
return null;
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(STORAGE_KEYS.token));
const user = ref<StudentDto | null>(readStoredUser());
const loading = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null);
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
const displayName = computed(() => user.value?.name ?? '未登录');
const roleLabel = computed(() => {
if (role.value === 'ADMIN') return '管理员';
if (role.value === 'TEACHER') return '教师';
return '学生';
});
function persist(): void {
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
else localStorage.removeItem(STORAGE_KEYS.token);
if (user.value) localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(user.value));
else localStorage.removeItem(STORAGE_KEYS.user);
}
function setSession(payload: LoginResponse): void {
token.value = payload.token;
user.value = { ...payload.user } as StudentDto;
persist();
}
async function login(payload: LoginRequest): Promise<void> {
loading.value = true;
try {
setSession(await authApi.login(payload));
} finally {
loading.value = false;
}
}
async function fetchProfile(): Promise<void> {
if (!token.value) return;
user.value = await authApi.me();
persist();
}
async function logout(): Promise<void> {
try {
if (token.value) await authApi.logout();
} catch {
// 退出登录失败不阻塞本地清理
} finally {
clear();
}
}
function clear(): void {
token.value = null;
user.value = null;
persist();
}
return {
token,
user,
loading,
isAuthenticated,
isStaff,
role,
displayName,
roleLabel,
login,
logout,
fetchProfile,
clear,
};
});
+178
View File
@@ -0,0 +1,178 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { ElNotification } from 'element-plus';
import { io, type Socket } from 'socket.io-client';
import {
PRIORITY_LABELS,
SOCKET_EVENTS,
type GradeDto,
type HomeworkDto,
type NotificationDto,
type ScheduleDto,
type ServerToClientEvents,
} from '@classhelper/shared';
import { SOCKET_URL } from '@/config';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
const EVENT_LABELS: Record<string, string> = {
[SOCKET_EVENTS.notificationNew]: '新通知',
[SOCKET_EVENTS.homeworkNew]: '新作业',
[SOCKET_EVENTS.homeworkUpdated]: '作业变更',
[SOCKET_EVENTS.gradeUpdated]: '成绩更新',
[SOCKET_EVENTS.scheduleUpdated]: '课表变更',
[SOCKET_EVENTS.homeworkStatus]: '作业状态',
};
/**
* 实时通道 store:
* - 持有 Socket.IO 连接(自动重连)
* - 收到事件时弹出 Element Plus 通知
* - 通过 on/off 让页面订阅事件后自动刷新列表
*/
export const useRealtimeStore = defineStore('realtime', () => {
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
const lastEventAt = ref<number | null>(null);
const eventCount = ref(0);
const handlers = new Map<string, Set<EventHandler>>();
let socket: Socket | null = null;
function notify(
event: string,
title: string,
message: string,
type: 'success' | 'info' | 'warning' | 'error' = 'info',
): void {
ElNotification({ title: `${title}`, message, type, duration: 4000, position: 'bottom-right' });
}
function dispatch(event: string, payload: unknown): void {
for (const handler of handlers.get(event) ?? []) {
handler(payload);
}
}
function bindEvents(instance: Socket): void {
instance.on('connect', () => {
connected.value = true;
connecting.value = false;
});
instance.on('disconnect', () => {
connected.value = false;
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
connecting.value = false;
// 重连过程中的失败只提示一次,避免刷屏
if (eventCount.value === 0) notify('error', '实时通道连接失败', error.message);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
markEvent(SOCKET_EVENTS.notificationNew);
notify(
'info',
`新通知 · ${PRIORITY_LABELS[payload.priority] ?? payload.priority}`,
`${payload.title}`,
payload.priority === 'URGENT' ? 'error' : payload.priority === 'HIGH' ? 'warning' : 'info',
);
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
markEvent(SOCKET_EVENTS.homeworkNew);
notify('success', '新作业发布', payload.title);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto) => {
markEvent(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
});
instance.on(SOCKET_EVENTS.homeworkStatus, (payload) => {
markEvent(SOCKET_EVENTS.homeworkStatus);
dispatch(SOCKET_EVENTS.homeworkStatus, payload);
});
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
markEvent(SOCKET_EVENTS.gradeUpdated);
notify('success', '成绩已更新', `${payload.examName}:${payload.score}/${payload.totalScore}`);
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
});
instance.on(SOCKET_EVENTS.scheduleUpdated, (payload: { schedule?: ScheduleDto; action: string }) => {
markEvent(SOCKET_EVENTS.scheduleUpdated);
if (payload.action !== 'updated') {
notify(
'info',
'课表变更',
`课表已${payload.action === 'created' ? '新增' : '删除'}:${payload.schedule?.course?.name ?? ''}`,
);
}
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
});
instance.on(SOCKET_EVENTS.connected, (payload: { rooms: string[] }) => {
markEvent(SOCKET_EVENTS.connected);
console.info('[realtime] 已加入房间', payload.rooms);
});
}
function markEvent(event: string): void {
lastEventName.value = event;
lastEventAt.value = Date.now();
eventCount.value += 1;
}
function connect(token: string): void {
if (socket?.connected || connecting.value) return;
connecting.value = true;
socket = io(SOCKET_URL, {
auth: { token, clientType: 'admin' },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 8000,
});
bindEvents(socket);
}
function disconnect(): void {
socket?.disconnect();
socket?.removeAllListeners();
socket = null;
connected.value = false;
connecting.value = false;
}
function on(event: RealtimeEventName, handler: EventHandler): void {
const set = handlers.get(event) ?? new Set<EventHandler>();
set.add(handler);
handlers.set(event, set);
}
function off(event: RealtimeEventName, handler: EventHandler): void {
handlers.get(event)?.delete(handler);
}
function eventLabel(): string {
return lastEventName.value ? (EVENT_LABELS[lastEventName.value] ?? lastEventName.value) : '';
}
return {
connected,
connecting,
lastEventName,
lastEventAt,
eventCount,
eventLabel,
connect,
disconnect,
on,
off,
};
});
+119
View File
@@ -0,0 +1,119 @@
:root {
--ch-bg: #f5f7fa;
--ch-card-radius: 10px;
--ch-border: #e4e7ed;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--ch-bg);
color: #303133;
}
.page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 12px;
flex-wrap: wrap;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.page-subtitle {
color: #909399;
font-size: 13px;
margin: 4px 0 0;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.stat-card {
background: #fff;
border-radius: var(--ch-card-radius);
padding: 16px;
border: 1px solid var(--ch-border);
}
.stat-label {
color: #909399;
font-size: 13px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
margin-top: 6px;
}
.stat-hint {
color: #c0c4cc;
font-size: 12px;
margin-top: 4px;
}
.text-muted {
color: #909399;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.timetable-cell {
min-height: 34px;
}
.timetable-course {
font-weight: 600;
}
.timetable-meta {
font-size: 12px;
color: #909399;
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
margin-right: 6px;
}
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
formatDate,
type ClassDetailDto,
type ClassDto,
type CourseDto,
type StudentDto,
type UserDto,
} from '@classhelper/shared';
import { classApi, courseApi, teacherApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const keyword = ref('');
async function loadClasses(): Promise<void> {
loading.value = true;
try {
classes.value = await classApi.list(keyword.value ? { keyword: keyword.value } : undefined);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 新建 / 编辑班级 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({ name: '', grade: '' });
const rules: FormRules = {
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.name = '';
form.grade = '';
formVisible.value = true;
}
function openEdit(row: ClassDto): void {
editingId.value = row.id;
form.name = row.name;
form.grade = row.grade;
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (editingId.value) {
await classApi.update(editingId.value, { name: form.name, grade: form.grade });
ElMessage.success('班级已更新');
} else {
await classApi.create({ name: form.name, grade: form.grade });
ElMessage.success('班级创建成功');
}
formVisible.value = false;
await loadClasses();
}
async function removeClass(row: ClassDto): Promise<void> {
await ElMessageBox.confirm(
`删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`,
'危险操作',
{ type: 'warning', confirmButtonText: '确认删除' },
);
await classApi.remove(row.id);
ElMessage.success('班级已删除');
await loadClasses();
}
/* ------------------------------------------------------------ 班级详情抽屉 */
const detailVisible = ref(false);
const detailLoading = ref(false);
const detail = ref<ClassDetailDto | null>(null);
const activeTab = ref('students');
async function openDetail(row: ClassDto): Promise<void> {
detailVisible.value = true;
detailLoading.value = true;
activeTab.value = 'students';
try {
detail.value = await classApi.detail(row.id);
} finally {
detailLoading.value = false;
}
}
async function refreshDetail(): Promise<void> {
if (!detail.value) return;
detailLoading.value = true;
try {
detail.value = await classApi.detail(detail.value.id);
} finally {
detailLoading.value = false;
}
}
/* ------------------------------------------------------------ 学生 */
const studentFormVisible = ref(false);
const studentForm = reactive({ username: '', name: '', password: '' });
const studentRules: FormRules = {
username: [{ required: true, message: '请输入学号/用户名', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
};
async function addStudent(): Promise<void> {
if (!detail.value) return;
if (!studentForm.username.trim() || !studentForm.name.trim()) {
ElMessage.warning('请填写用户名和姓名');
return;
}
const created = await classApi.addStudent(detail.value.id, {
username: studentForm.username.trim(),
name: studentForm.name.trim(),
...(studentForm.password ? { password: studentForm.password } : {}),
});
ElMessage.success(`已加入学生:${created.name}`);
studentFormVisible.value = false;
studentForm.username = '';
studentForm.name = '';
studentForm.password = '';
await refreshDetail();
await loadClasses();
}
async function removeStudent(row: StudentDto): Promise<void> {
if (!detail.value) return;
await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' });
await classApi.removeStudent(detail.value.id, row.id);
ElMessage.success('已移出班级');
await refreshDetail();
await loadClasses();
}
/* ------------------------------------------------------------ 课程 */
const courseForm = reactive({ name: '' });
async function addCourse(): Promise<void> {
if (!detail.value || !courseForm.name.trim()) {
ElMessage.warning('请输入课程名称');
return;
}
await courseApi.create({ name: courseForm.name.trim(), classId: detail.value.id });
ElMessage.success('课程已添加');
courseForm.name = '';
await refreshDetail();
}
async function removeCourse(row: CourseDto): Promise<void> {
await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', {
type: 'warning',
});
await courseApi.remove(row.id);
ElMessage.success('课程已删除');
await refreshDetail();
}
/* ------------------------------------------------------------ 协作教师 */
const teachers = ref<UserDto[]>([]);
const selectedTeacherId = ref<string>('');
async function loadTeachers(): Promise<void> {
teachers.value = await teacherApi.list();
}
async function assignTeacher(): Promise<void> {
if (!detail.value || !selectedTeacherId.value) {
ElMessage.warning('请选择教师');
return;
}
await classApi.assignTeacher(detail.value.id, selectedTeacherId.value);
ElMessage.success('教师已分配');
selectedTeacherId.value = '';
await refreshDetail();
}
async function removeTeacher(teacherId: string): Promise<void> {
if (!detail.value) return;
await classApi.removeTeacher(detail.value.id, teacherId);
ElMessage.success('已取消分配');
await refreshDetail();
}
/* ------------------------------------------------------------ 实时刷新 */
function onClassChanged(): void {
void loadClasses();
if (detailVisible.value) void refreshDetail();
}
onMounted(async () => {
await Promise.all([loadClasses(), loadTeachers()]);
realtime.on(SOCKET_EVENTS.classUpdated, onClassChanged);
realtime.on(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.on(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classUpdated, onClassChanged);
realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">班级管理</h2>
<p class="page-subtitle">教师只能看到并管理自己创建或被分配的班级</p>
</div>
<div class="toolbar">
<el-input
v-model="keyword"
placeholder="搜索班级名称 / 年级"
clearable
style="width: 220px"
@keyup.enter="loadClasses"
@clear="loadClasses"
/>
<el-button :icon="'Search'" @click="loadClasses">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="classes" empty-text="暂无班级,点击右上角新建">
<el-table-column prop="name" label="班级" min-width="140" />
<el-table-column prop="grade" label="年级" width="100" />
<el-table-column label="班主任" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="学生" width="80">
<template #default="{ row }">{{ row.studentCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="课程" width="80">
<template #default="{ row }">{{ row.courseCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="作业" width="80">
<template #default="{ row }">{{ row.homeworkCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="通知" width="80">
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeClass(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新建 / 编辑 -->
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="班级名称" prop="name">
<el-input v-model="form.name" placeholder="例如 高一(1)班" />
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="form.grade" placeholder="例如 高一" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<!-- 班级详情 -->
<el-drawer v-model="detailVisible" size="60%" :title="detail ? `${detail.name} · 详情` : '班级详情'">
<div v-loading="detailLoading">
<el-descriptions v-if="detail" :column="3" border size="small">
<el-descriptions-item label="年级">{{ detail.grade }}</el-descriptions-item>
<el-descriptions-item label="班主任">{{ detail.teacher?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="学生数">{{ detail.students.length }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="activeTab" class="mt-12">
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
<el-button type="primary" size="small" :icon="'Plus'" @click="studentFormVisible = true">
添加学生
</el-button>
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
</div>
<el-table :data="detail?.students ?? []" size="small" class="mt-12" empty-text="暂无学生">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="加入时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="课程" name="courses">
<el-table :data="detail?.courses ?? []" size="small" class="mt-12" empty-text="暂无课程">
<el-table-column prop="name" label="课程" min-width="140" />
<el-table-column label="任课教师" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-input
v-model="courseForm.name"
placeholder="输入课程名称后回车快速添加"
class="mt-12"
@keyup.enter="addCourse"
/>
</el-tab-pane>
<el-tab-pane label="协作教师" name="teachers">
<el-table :data="detail?.teachers ?? []" size="small" class="mt-12" empty-text="暂无协作教师">
<el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button link type="danger" @click="removeTeacher(row.id)">取消分配</el-button>
</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-select v-model="selectedTeacherId" placeholder="选择教师" style="width: 220px" filterable>
<el-option
v-for="item in teachers"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
<el-button type="primary" @click="assignTeacher">分配</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
<!-- 添加学生 -->
<el-dialog v-model="studentFormVisible" title="添加学生到本班" width="440px">
<el-form :model="studentForm" :rules="studentRules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input v-model="studentForm.username" placeholder="学号或登录名,已存在则直接转入本班" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="studentForm.name" />
</el-form-item>
<el-form-item label="初始密码">
<el-input v-model="studentForm.password" placeholder="留空则使用默认密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="studentFormVisible = false">取消</el-button>
<el-button type="primary" @click="addStudent">确认添加</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
formatDate,
formatDeadline,
relativeTime,
type DashboardSummary,
} from '@classhelper/shared';
import { dashboardApi, notificationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const summary = ref<DashboardSummary | null>(null);
const unreadCount = ref(0);
const statCards = computed(() => {
const data = summary.value;
return [
{ label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{
label: '作业总数',
value: data?.homeworkCount ?? 0,
hint: `待截止/待完成 ${data?.pendingHomeworkCount ?? 0}`,
},
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` },
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
];
});
async function load(): Promise<void> {
loading.value = true;
try {
summary.value = await dashboardApi.summary();
const result = await notificationApi.unreadCount();
unreadCount.value = result.count;
} finally {
loading.value = false;
}
}
function onRealtimeEvent(): void {
void load();
}
onMounted(async () => {
await load();
realtime.on(SOCKET_EVENTS.notificationNew, onRealtimeEvent);
realtime.on(SOCKET_EVENTS.homeworkNew, onRealtimeEvent);
realtime.on(SOCKET_EVENTS.gradeUpdated, onRealtimeEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onRealtimeEvent);
realtime.off(SOCKET_EVENTS.homeworkNew, onRealtimeEvent);
realtime.off(SOCKET_EVENTS.gradeUpdated, onRealtimeEvent);
});
function go(name: string): void {
void router.push({ name });
}
function openNotification(item: NotificationDto): void {
void router.push({ name: 'notifications', query: { highlight: item.id } });
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-header">
<div>
<h2 class="page-title">仪表盘</h2>
<p class="page-subtitle">
{{ auth.displayName }}({{ auth.roleLabel }})· 数据范围随账号权限自动收敛
</p>
</div>
<div class="toolbar">
<el-tag :type="realtime.connected ? 'success' : 'danger'" effect="light">
{{ realtime.connected ? '实时推送已连接' : '实时推送未连接' }}
</el-tag>
<el-button :icon="'Refresh'" @click="load">刷新</el-button>
</div>
</div>
<div class="stat-grid">
<div v-for="card in statCards" :key="card.label" class="stat-card">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">{{ card.value }}</div>
<div class="stat-hint">{{ card.hint }}</div>
</div>
</div>
<el-row :gutter="12" class="mt-16">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>最新通知</span>
<el-button link type="primary" @click="go('notifications')">去发布</el-button>
</div>
</template>
<el-empty v-if="(summary?.recentNotifications ?? []).length === 0" description="暂无通知" />
<ul v-else class="list">
<li
v-for="item in summary?.recentNotifications"
:key="item.id"
class="list-item"
@click="openNotification(item)"
>
<el-tag :type="PRIORITY_TAG_TYPES[item.priority]" size="small" effect="light">
{{ PRIORITY_LABELS[item.priority] }}
</el-tag>
<span class="list-title">{{ item.title }}</span>
<span class="text-muted">{{ relativeTime(item.createdAt) }}</span>
</li>
</ul>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>即将截止的作业</span>
<el-button link type="primary" @click="go('homeworks')">去发布</el-button>
</div>
</template>
<el-empty v-if="(summary?.upcomingDeadlines ?? []).length === 0" description="暂无待截止作业" />
<ul v-else class="list">
<li v-for="item in summary?.upcomingDeadlines" :key="item.id" class="list-item">
<el-tag size="small" effect="plain">{{ item.course?.name ?? '未关联课程' }}</el-tag>
<span class="list-title">{{ item.title }}</span>
<span class="text-muted">{{ formatDeadline(item.dueAt) }}</span>
</li>
</ul>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16">
<template #header>
<div class="card-header">
<span>最近作业</span>
<el-button link type="primary" @click="go('homeworks')">查看全部</el-button>
</div>
</template>
<el-table :data="summary?.recentHomeworks ?? []" size="small" empty-text="暂无作业">
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column label="课程" width="120">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="完成情况" width="120">
<template #default="{ row }">
<span>{{ row.completedCount ?? 0 }} 人已完成</span>
</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 4px;
border-bottom: 1px dashed var(--ch-border);
cursor: pointer;
}
.list-item:last-child {
border-bottom: none;
}
.list-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
formatDate,
gradeLevel,
gradePercent,
type ClassDto,
type CourseDto,
type GradeDto,
type GradeStats,
type StudentDto,
} from '@classhelper/shared';
import { classApi, courseApi, gradeApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const grades = ref<GradeDto[]>([]);
const students = ref<StudentDto[]>([]);
const stats = ref<GradeStats | null>(null);
const filter = reactive({ classId: '', courseId: '', examName: '' });
const levelChartRef = ref<HTMLDivElement>();
const courseChartRef = ref<HTMLDivElement>();
const levelChart = ref<ReturnType<typeof echarts.init> | null>(null);
const courseChart = ref<ReturnType<typeof echarts.init> | null>(null);
const averagePercent = computed(() => {
if (grades.value.length === 0) return 0;
const sum = grades.value.reduce((total, item) => total + gradePercent(item.score, item.totalScore), 0);
return Math.round((sum / grades.value.length) * 10) / 10;
});
const examOptions = computed(() => [...new Set(grades.value.map((item) => item.examName))]);
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
if (filter.classId) {
courses.value = await courseApi.list(filter.classId);
students.value = await classApi.students(filter.classId);
}
}
async function loadGrades(): Promise<void> {
if (!filter.classId) return;
loading.value = true;
try {
const params: { classId?: string; courseId?: string; examName?: string } = { classId: filter.classId };
if (filter.courseId) params.courseId = filter.courseId;
if (filter.examName) params.examName = filter.examName;
const [list, statistic] = await Promise.all([gradeApi.list(params), gradeApi.stats(params)]);
grades.value = list;
stats.value = statistic;
renderCharts();
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
filter.courseId = '';
filter.examName = '';
courses.value = await courseApi.list(filter.classId);
students.value = await classApi.students(filter.classId);
await loadGrades();
await loadTeacherSuggestions();
}
/* ------------------------------------------------------------ 图表 */
function renderCharts(): void {
const statistic = stats.value;
if (!statistic) return;
if (levelChartRef.value) {
levelChart.value ??= echarts.init(levelChartRef.value);
levelChart.value.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 16, top: 30, bottom: 28 },
xAxis: { type: 'category', data: statistic.distribution.map((item) => `${item.level} 等`) },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
type: 'bar',
barWidth: '46%',
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
data: statistic.distribution.map((item) => item.count),
},
],
});
}
if (courseChartRef.value) {
courseChart.value ??= echarts.init(courseChartRef.value);
courseChart.value.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 16, top: 30, bottom: 28 },
xAxis: { type: 'category', data: statistic.byCourse.map((item) => item.courseName) },
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'line',
smooth: true,
symbolSize: 8,
areaStyle: { opacity: 0.12 },
itemStyle: { color: '#67c23a' },
data: statistic.byCourse.map((item) => item.averagePercent),
},
],
});
}
}
function handleResize(): void {
levelChart.value?.resize();
courseChart.value?.resize();
}
/* ------------------------------------------------------------ 单条录入 */
const singleVisible = ref(false);
const singleForm = reactive({ userId: '', courseId: '', examName: '', score: 90, totalScore: 100 });
function openSingle(): void {
if (!filter.classId) {
ElMessage.warning('请先选择班级');
return;
}
singleForm.userId = students.value[0]?.id ?? '';
singleForm.courseId = filter.courseId || courses.value[0]?.id || '';
singleForm.examName = filter.examName || '期中考试';
singleForm.score = 90;
singleForm.totalScore = 100;
singleVisible.value = true;
}
async function submitSingle(): Promise<void> {
if (!singleForm.userId || !singleForm.examName.trim()) {
ElMessage.warning('请选择学生并填写考试名称');
return;
}
await gradeApi.create({
classId: filter.classId,
courseId: singleForm.courseId || null,
userId: singleForm.userId,
examName: singleForm.examName.trim(),
score: singleForm.score,
totalScore: singleForm.totalScore,
});
ElMessage.success('成绩已录入,已实时推送给该学生');
singleVisible.value = false;
await loadGrades();
}
/* ------------------------------------------------------------ 批量录入 */
const bulkVisible = ref(false);
const bulkForm = reactive({ courseId: '', examName: '期中考试', totalScore: 100 });
const bulkRows = ref<Array<{ userId: string; name: string; username: string; score: number | null }>>([]);
function openBulk(): void {
if (students.value.length === 0) {
ElMessage.warning('该班级暂无学生');
return;
}
bulkForm.courseId = filter.courseId || courses.value[0]?.id || '';
bulkForm.examName = filter.examName || '期中考试';
bulkForm.totalScore = 100;
bulkRows.value = students.value.map((item) => ({
userId: item.id,
name: item.name,
username: item.username,
score: null,
}));
bulkVisible.value = true;
}
async function submitBulk(): Promise<void> {
const items = bulkRows.value
.filter((row) => row.score !== null && row.score !== undefined)
.map((row) => ({ userId: row.userId, score: Number(row.score) }));
if (items.length === 0) {
ElMessage.warning('至少录入一位学生的成绩');
return;
}
if (!bulkForm.examName.trim()) {
ElMessage.warning('请填写考试名称');
return;
}
const result = await gradeApi.bulkCreate({
classId: filter.classId,
courseId: bulkForm.courseId || null,
examName: bulkForm.examName.trim(),
totalScore: bulkForm.totalScore,
items,
});
ElMessage.success(`已录入 ${result.count} 条成绩并广播到班级`);
bulkVisible.value = false;
await loadGrades();
}
async function removeGrade(row: GradeDto): Promise<void> {
await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', {
type: 'warning',
});
await gradeApi.remove(row.id);
ElMessage.success('已删除');
await loadGrades();
}
/* ------------------------------------------------------------ 教师视角建议:按考试分组统计 */
const teacherCourseSuggestions = ref<CourseDto[]>([]);
async function loadTeacherSuggestions(): Promise<void> {
teacherCourseSuggestions.value = courses.value;
}
function onGradeEvent(): void {
void loadGrades();
}
watch(
() => [filter.courseId, filter.examName],
() => {
void loadGrades();
},
);
onMounted(async () => {
await loadClasses();
await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
levelChart.value?.dispose();
courseChart.value?.dispose();
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">成绩录入</h2>
<p class="page-subtitle">同一学生 + 课程 + 考试重复录入会覆盖旧分数,避免产生重复记录</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="filter.courseId" placeholder="全部课程" clearable style="width: 150px">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="filter.examName" placeholder="全部考试" clearable style="width: 160px">
<el-option v-for="item in examOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-button type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
<el-button type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="6">
<div class="stat-card">
<div class="stat-label">成绩记录</div>
<div class="stat-value">{{ grades.length }}</div>
<div class="stat-hint">当前筛选条件</div>
</div>
<div class="stat-card mt-12">
<div class="stat-label">平均得分率</div>
<div class="stat-value">{{ averagePercent }}%</div>
<div class="stat-hint">按总分换算</div>
</div>
</el-col>
<el-col :xs="24" :md="9">
<el-card shadow="never">
<template #header><span>等级分布</span></template>
<div ref="levelChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="9">
<el-card shadow="never">
<template #header><span>各课程平均得分率</span></template>
<div ref="courseChartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16">
<el-table v-loading="loading" :data="grades" empty-text="暂无成绩记录" max-height="520">
<el-table-column label="学生" width="140">
<template #default="{ row }">{{ row.student?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="用户名" width="130">
<template #default="{ row }">{{ row.student?.username ?? '-' }}</template>
</el-table-column>
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column prop="examName" label="考试" width="150" />
<el-table-column label="分数" width="130">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
<el-table-column label="得分率" width="190">
<template #default="{ row }">
<el-progress
:percentage="gradePercent(row.score, row.totalScore)"
:stroke-width="10"
:status="gradePercent(row.score, row.totalScore) >= 60 ? 'success' : 'exception'"
/>
<span class="text-muted">{{ gradeLevel(gradePercent(row.score, row.totalScore)) }} 等</span>
</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 单条录入 -->
<el-dialog v-model="singleVisible" title="单条成绩录入" width="480px">
<el-form :model="singleForm" label-width="90px">
<el-form-item label="学生">
<el-select v-model="singleForm.userId" filterable style="width: 100%">
<el-option
v-for="item in students"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="课程">
<el-select v-model="singleForm.courseId" clearable style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="考试名称">
<el-input v-model="singleForm.examName" placeholder="例如 第一次月考" />
</el-form-item>
<el-form-item label="分数">
<el-input-number v-model="singleForm.score" :min="0" :max="singleForm.totalScore" />
<span style="margin: 0 8px">/</span>
<el-input-number v-model="singleForm.totalScore" :min="1" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="singleVisible = false">取消</el-button>
<el-button type="primary" @click="submitSingle">录入并推送</el-button>
</template>
</el-dialog>
<!-- 批量录入 -->
<el-dialog v-model="bulkVisible" title="批量录入成绩" width="620px">
<el-form :model="bulkForm" label-width="90px" inline>
<el-form-item label="课程">
<el-select v-model="bulkForm.courseId" clearable style="width: 150px">
<el-option
v-for="item in teacherCourseSuggestions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="考试">
<el-input v-model="bulkForm.examName" style="width: 170px" />
</el-form-item>
<el-form-item label="总分">
<el-input-number v-model="bulkForm.totalScore" :min="1" :max="1000" />
</el-form-item>
</el-form>
<el-table :data="bulkRows" height="360" size="small">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column label="分数">
<template #default="{ row }">
<el-input-number v-model="row.score" :min="0" :max="bulkForm.totalScore" size="small" />
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="bulkVisible = false">取消</el-button>
<el-button type="primary" @click="submitBulk">提交并广播</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.chart {
height: 220px;
width: 100%;
}
</style>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
formatDate,
formatDeadline,
isOverdue,
truncate,
type ClassDto,
type CourseDto,
type HomeworkDto,
} from '@classhelper/shared';
import { classApi, courseApi, homeworkApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const homeworks = ref<HomeworkDto[]>([]);
const filter = reactive({ classId: '', courseId: '', keyword: '' });
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
const classStudents = ref<Array<{ id: string; name: string }>>([]);
const completedMap = computed(() => {
const total = classStudents.value.length;
const done = current.value?.completedCount ?? 0;
return { total, done, rate: total === 0 ? 0 : Math.round((done / total) * 100) };
});
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
await loadCourses();
}
async function loadCourses(): Promise<void> {
courses.value = filter.classId ? await courseApi.list(filter.classId) : [];
}
async function loadHomeworks(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; courseId?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.courseId) params.courseId = filter.courseId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
homeworks.value = await homeworkApi.list(params);
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
filter.courseId = '';
await loadCourses();
await loadHomeworks();
}
/* ------------------------------------------------------------ 发布 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({
classId: '',
courseId: '',
title: '',
content: '',
attachmentUrl: '',
dueAt: '',
});
const rules: FormRules = {
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
title: [{ required: true, message: '请输入作业标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入作业内容', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.classId = filter.classId || classes.value[0]?.id || '';
form.courseId = '';
form.title = '';
form.content = '';
form.attachmentUrl = '';
form.dueAt = '';
formVisible.value = true;
}
function openEdit(row: HomeworkDto): void {
editingId.value = row.id;
form.classId = row.classId;
form.courseId = row.courseId ?? '';
form.title = row.title;
form.content = row.content;
form.attachmentUrl = row.attachmentUrl ?? '';
form.dueAt = row.dueAt ? row.dueAt.slice(0, 16) : '';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const payload = {
classId: form.classId,
courseId: form.courseId || null,
title: form.title.trim(),
content: form.content,
attachmentUrl: form.attachmentUrl.trim() || null,
dueAt: form.dueAt ? new Date(form.dueAt).toISOString() : null,
};
if (editingId.value) {
await homeworkApi.update(editingId.value, payload);
ElMessage.success('作业已更新');
} else {
await homeworkApi.create(payload);
ElMessage.success('作业已发布,实时推送已发出');
}
formVisible.value = false;
await loadHomeworks();
}
async function removeHomework(row: HomeworkDto): Promise<void> {
await ElMessageBox.confirm(`删除作业「${row.title}」?`, '确认', { type: 'warning' });
await homeworkApi.remove(row.id);
ElMessage.success('已删除');
await loadHomeworks();
}
async function openDetail(row: HomeworkDto): Promise<void> {
current.value = await homeworkApi.detail(row.id);
const students = await classApi.students(row.classId);
classStudents.value = students.map((item) => ({ id: item.id, name: item.name }));
detailVisible.value = true;
}
function onHomeworkEvent(): void {
void loadHomeworks();
}
onMounted(async () => {
await loadClasses();
await loadHomeworks();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">作业发布</h2>
<p class="page-subtitle">发布后立即通过 WebSocket 广播到学生客户端(homework:new)</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select
v-model="filter.courseId"
placeholder="全部课程"
clearable
style="width: 150px"
@change="loadHomeworks"
>
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input
v-model="filter.keyword"
placeholder="搜索标题/内容"
clearable
style="width: 170px"
@keyup.enter="loadHomeworks"
@clear="loadHomeworks"
/>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布作业</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="homeworks" empty-text="暂无作业">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) }">
{{ formatDate(row.dueAt, true) || '不限' }}
</span>
</template>
</el-table-column>
<el-table-column label="剩余" width="130">
<template #default="{ row }">{{ formatDeadline(row.dueAt) }}</template>
</el-table-column>
<el-table-column label="已完成" width="100">
<template #default="{ row }">{{ row.completedCount ?? 0 }} 人</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeHomework(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑作业' : '发布作业'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="目标班级" prop="classId">
<el-select v-model="form.classId" style="width: 100%" :disabled="Boolean(editingId)">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="所属课程">
<el-select v-model="form.courseId" placeholder="可选" clearable style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="5" maxlength="5000" show-word-limit />
</el-form-item>
<el-form-item label="附件链接">
<el-input v-model="form.attachmentUrl" placeholder="https://..." />
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="form.dueAt"
type="datetime"
placeholder="选择截止时间"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">{{ editingId ? '保存' : '发布' }}</el-button>
</template>
</el-dialog>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
<template v-if="current">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>
<el-descriptions-item label="完成情况" :span="2">
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
</el-descriptions-item>
</el-descriptions>
<div class="mt-16">
<h4>作业内容</h4>
<p class="content-block">{{ current.content }}</p>
</div>
<div v-if="current.attachmentUrl" class="mt-12">
<el-link type="primary" :href="current.attachmentUrl" target="_blank">查看附件</el-link>
</div>
<el-alert
class="mt-16"
type="info"
:closable="false"
title="学生端实时同步"
:description="`学生客户端在收到 homework:new / homework:status 事件后会自动刷新列表,并在离线时使用本地缓存(摘要:${truncate(current.content, 30)})`"
/>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.text-danger {
color: #f56c6c;
}
.content-block {
white-space: pre-wrap;
line-height: 1.7;
background: #fafafa;
border-radius: 8px;
padding: 12px;
margin: 0;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const formRef = ref<FormInstance>();
const form = reactive({ username: '', password: '' });
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const demoAccounts = [
{ role: '管理员', username: 'admin', password: 'admin123' },
{ role: '教师', username: 'teacher1', password: 'teacher123' },
{ role: '教师', username: 'teacher2', password: 'teacher123' },
];
function fill(username: string, password: string): void {
form.username = username;
form.password = password;
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
try {
await auth.login({ username: form.username.trim(), password: form.password });
ElMessage.success(`欢迎回来,${auth.displayName}`);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard';
await router.replace(redirect);
} catch {
// 错误提示已由 axios 拦截器统一处理
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-brand">
<el-icon :size="30" color="#409eff"><School /></el-icon>
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-subtitle">Web 管理端 · 教师 / 管理员登录</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="submit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="'User'" clearable />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="'Lock'"
show-password
@keyup.enter="submit"
/>
</el-form-item>
<el-button type="primary" size="large" class="login-button" :loading="auth.loading" @click="submit">
登录
</el-button>
</el-form>
<el-divider>演示账号(点击填充)</el-divider>
<div class="demo-list">
<el-tag
v-for="item in demoAccounts"
:key="item.username"
class="demo-tag"
effect="plain"
@click="fill(item.username, item.password)"
>
{{ item.role }}:{{ item.username }} / {{ item.password }}
</el-tag>
</div>
<p class="login-tip">学生请使用班级小助手桌面客户端登录;种子数据见 README。</p>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 55%, #409eff 100%);
}
.login-card {
width: 400px;
background: #fff;
border-radius: 14px;
padding: 30px 28px 22px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 22px;
}
.login-title {
font-size: 20px;
margin: 0;
}
.login-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
}
.login-button {
width: 100%;
}
.demo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.demo-tag {
cursor: pointer;
justify-content: flex-start;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
color: #c0c4cc;
line-height: 1.6;
}
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
<div class="not-found">
<el-result icon="warning" title="404" sub-title="页面不存在或已被移除">
<template #extra>
<el-button type="primary" @click="router.push('/dashboard')">返回仪表盘</el-button>
</template>
</el-result>
</div>
</template>
<style scoped>
.not-found {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
SOCKET_EVENTS,
formatDate,
relativeTime,
truncate,
type ClassDto,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
import { classApi, notificationApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const notifications = ref<NotificationDto[]>([]);
const studentCount = ref(0);
const filter = reactive({ classId: '', priority: '', keyword: '' });
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
}
async function loadNotifications(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; priority?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.priority) params.priority = filter.priority;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
notifications.value = await notificationApi.list(params);
if (filter.classId) {
const students = await classApi.students(filter.classId);
studentCount.value = students.length;
}
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 发布 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const form = reactive<{ classId: string; title: string; content: string; priority: NotificationPriority }>({
classId: '',
title: '',
content: '',
priority: 'NORMAL',
});
const rules: FormRules = {
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
};
function openCreate(): void {
form.classId = filter.classId || classes.value[0]?.id || '';
form.title = '';
form.content = '';
form.priority = 'NORMAL';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const created = await notificationApi.create({
classId: form.classId,
title: form.title.trim(),
content: form.content,
priority: form.priority,
});
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
formVisible.value = false;
await loadNotifications();
}
async function removeNotification(row: NotificationDto): Promise<void> {
await ElMessageBox.confirm(`删除通知「${row.title}」?`, '确认', { type: 'warning' });
await notificationApi.remove(row.id);
ElMessage.success('已删除');
await loadNotifications();
}
function readRate(row: NotificationDto): number {
if (!studentCount.value) return 0;
return Math.round(((row.readCount ?? 0) / studentCount.value) * 100);
}
function onNotificationEvent(): void {
void loadNotifications();
}
onMounted(async () => {
await loadClasses();
await loadNotifications();
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通知发布</h2>
<p class="page-subtitle">发布后按班级房间广播(notification:new),学生端 5 秒内到达</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
style="width: 170px"
@change="loadNotifications"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select
v-model="filter.priority"
placeholder="全部优先级"
clearable
style="width: 150px"
@change="loadNotifications"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<el-input
v-model="filter.keyword"
placeholder="搜索标题/内容"
clearable
style="width: 170px"
@keyup.enter="loadNotifications"
@clear="loadNotifications"
/>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布通知</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="notifications" empty-text="暂无通知">
<el-table-column label="优先级" width="90">
<template #default="{ row }">
<el-tag
:type="PRIORITY_TAG_TYPES[row.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[row.priority as NotificationPriority] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="内容摘要" min-width="220">
<template #default="{ row }">{{ truncate(row.content, 40) }}</template>
</el-table-column>
<el-table-column label="发布人" width="110">
<template #default="{ row }">{{ row.creator?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">
<div>{{ formatDate(row.createdAt, true) }}</div>
<div class="text-muted">{{ relativeTime(row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="已读率" width="150">
<template #default="{ row }">
<el-progress :percentage="readRate(row)" :stroke-width="10" />
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ studentCount }} 人</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeNotification(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" title="发布通知" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="目标班级" prop="classId">
<el-select v-model="form.classId" style="width: 100%">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-radio-group v-model="form.priority">
<el-radio-button v-for="item in NOTIFICATION_PRIORITIES" :key="item" :value="item">
{{ PRIORITY_LABELS[item] }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="5" maxlength="5000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">立即发布</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
buildWeekOptions,
formatWeekRange,
type ClassDto,
type CourseDto,
type CreateScheduleRequest,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const grid = ref<ScheduleWeekView | null>(null);
const rawSchedules = ref<ScheduleDto[]>([]);
const query = reactive({ classId: '', week: 1 });
const weekOptions = ref<number[]>(buildWeekOptions(20));
/** 把同一时间段的课表行聚合成"节次"行,形成 7 列课表 */
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
for (const item of rawSchedules.value) {
const key = `${item.startTime}-${item.endTime}`;
const bucket = buckets.get(key) ?? [];
bucket.push(item);
buckets.set(key, bucket);
}
return [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, items]) => {
const [start, end] = key.split('-');
return { start: start ?? '', end: end ?? '', items };
});
});
function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): ScheduleDto[] {
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
}
async function loadBase(): Promise<void> {
const [classList, term] = await Promise.all([classApi.list(), dashboardApi.term()]);
classes.value = classList;
weekOptions.value = buildWeekOptions(term.maxWeek);
query.week = term.currentWeek;
if (!query.classId && classList.length > 0) query.classId = classList[0]?.id ?? '';
if (query.classId) await loadCourses();
}
async function loadCourses(): Promise<void> {
courses.value = query.classId ? await courseApi.list(query.classId) : [];
}
async function loadSchedules(): Promise<void> {
if (!query.classId) {
grid.value = null;
rawSchedules.value = [];
return;
}
loading.value = true;
try {
const [weekGrid, list] = await Promise.all([
scheduleApi.grid({ classId: query.classId, week: query.week }),
scheduleApi.list({ classId: query.classId, week: query.week }),
]);
grid.value = weekGrid;
rawSchedules.value = list;
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
await loadCourses();
await loadSchedules();
}
/* ------------------------------------------------------------ 新增 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({
courseId: '',
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
location: '',
weekStart: 1,
weekEnd: 20,
});
const rules: FormRules = {
courseId: [{ required: true, message: '请选择课程', trigger: 'change' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
};
function openCreate(): void {
if (!query.classId) {
ElMessage.warning('请先选择班级');
return;
}
editingId.value = null;
form.courseId = courses.value[0]?.id ?? '';
form.dayOfWeek = 1;
form.startTime = '08:00';
form.endTime = '08:45';
form.location = '';
form.weekStart = 1;
form.weekEnd = weekOptions.value.at(-1) ?? 20;
formVisible.value = true;
}
function openEdit(item: ScheduleDto): void {
editingId.value = item.id;
form.courseId = item.courseId;
form.dayOfWeek = item.dayOfWeek;
form.startTime = item.startTime;
form.endTime = item.endTime;
form.location = item.location ?? '';
form.weekStart = item.weekStart;
form.weekEnd = item.weekEnd;
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (form.startTime >= form.endTime) {
ElMessage.warning('结束时间必须晚于开始时间');
return;
}
const payload: CreateScheduleRequest = {
classId: query.classId,
courseId: form.courseId,
dayOfWeek: Number(form.dayOfWeek),
startTime: form.startTime,
endTime: form.endTime,
location: form.location || null,
weekStart: Number(form.weekStart),
weekEnd: Number(form.weekEnd),
};
if (editingId.value) {
await scheduleApi.update(editingId.value, payload);
ElMessage.success('课表已更新');
} else {
await scheduleApi.create(payload);
ElMessage.success('课表已新增');
}
formVisible.value = false;
await loadSchedules();
}
async function removeSchedule(item: ScheduleDto): Promise<void> {
await ElMessageBox.confirm(
`删除 ${WEEKDAY_LABELS[item.dayOfWeek]} ${item.startTime} ${item.course?.name ?? ''} 这节课?`,
'确认',
{
type: 'warning',
},
);
await scheduleApi.remove(item.id);
ElMessage.success('已删除');
await loadSchedules();
}
function onScheduleEvent(): void {
void loadSchedules();
}
onMounted(async () => {
await loadBase();
await loadSchedules();
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">课表管理</h2>
<p class="page-subtitle">按周次维护课程安排,支持单双周范围(起始周 - 结束周)</p>
</div>
<div class="toolbar">
<el-select
v-model="query.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.week" placeholder="周次" style="width: 130px" @change="loadSchedules">
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
</el-select>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新增课表</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="toolbar">
<span>
第 {{ query.week }} 周课表
<span class="text-muted">(共 {{ rawSchedules.length }} 节课)</span>
</span>
</div>
</template>
<el-empty v-if="periods.length === 0" description="该周暂无课表,点击右上角新增" />
<el-table v-else :data="periods" border size="small">
<el-table-column label="节次" width="120" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" min-width="130">
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '-' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<div>
<el-button link type="primary" size="small" @click="openEdit(item)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeSchedule(item)">删除</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑课表' : '新增课表'" width="480px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="课程" prop="courseId">
<el-select v-model="form.courseId" placeholder="选择课程" style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="星期" prop="dayOfWeek">
<el-select v-model="form.dayOfWeek" style="width: 100%">
<el-option v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" :value="day" />
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-time-select
v-model="form.startTime"
start="07:00"
step="00:05"
end="21:00"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-time-select v-model="form.endTime" start="07:00" step="00:05" end="22:00" style="width: 100%" />
</el-form-item>
<el-form-item label="地点">
<el-input v-model="form.location" placeholder="例如 教学楼 A301" />
</el-form-item>
<el-form-item label="周次范围">
<el-input-number v-model="form.weekStart" :min="1" :max="30" />
<span style="margin: 0 8px">至</span>
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { ROLE_LABELS, formatDate, type ClassDto, type StudentDto } from '@classhelper/shared';
import { classApi, studentApi } from '@/api';
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
const filter = reactive({ classId: '', keyword: '' });
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
}
async function loadStudents(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
students.value = await studentApi.list(params);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 新建 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({ username: '', name: '', password: '', classId: '' });
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, message: '用户名至少 3 位', trigger: 'blur' },
],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.username = '';
form.name = '';
form.password = '';
form.classId = filter.classId;
formVisible.value = true;
}
function openEdit(row: StudentDto): void {
editingId.value = row.id;
form.username = row.username;
form.name = row.name;
form.password = '';
form.classId = row.classId ?? '';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (editingId.value) {
await studentApi.update(editingId.value, {
username: form.username.trim(),
name: form.name.trim(),
classId: form.classId || null,
});
ElMessage.success('学生信息已更新');
} else {
await studentApi.create({
username: form.username.trim(),
name: form.name.trim(),
classId: form.classId || null,
...(form.password ? { password: form.password } : {}),
});
ElMessage.success('学生账号创建成功');
}
formVisible.value = false;
await loadStudents();
}
async function removeStudent(row: StudentDto): Promise<void> {
await ElMessageBox.confirm(`删除学生「${row.name}」及其成绩、作业记录?`, '危险操作', {
type: 'warning',
confirmButtonText: '确认删除',
});
await studentApi.remove(row.id);
ElMessage.success('学生已删除');
await loadStudents();
}
async function resetPassword(row: StudentDto): Promise<void> {
await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认密码?`, '重置密码', { type: 'warning' });
await studentApi.resetPassword(row.id);
ElMessage.success('密码已重置为默认密码');
}
onMounted(async () => {
await Promise.all([loadClasses(), loadStudents()]);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">学生管理</h2>
<p class="page-subtitle">学生账号、分班与密码重置(数据范围按班级权限收敛)</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="全部班级"
clearable
style="width: 180px"
@change="loadStudents"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input
v-model="filter.keyword"
placeholder="姓名 / 用户名"
clearable
style="width: 180px"
@keyup.enter="loadStudents"
@clear="loadStudents"
/>
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="students" empty-text="暂无学生数据">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="角色" width="90">
<template #default="{ row }">{{ ROLE_LABELS[row.role as 'STUDENT'] ?? row.role }}</template>
</el-table-column>
<el-table-column label="班级" width="140">
<template #default="{ row }">
<el-tag v-if="row.className" size="small" effect="plain">{{ row.className }}</el-tag>
<span v-else class="text-muted">未分班</span>
</template>
</el-table-column>
<el-table-column label="年级" width="90">
<template #default="{ row }">{{ row.grade ?? '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" @click="resetPassword(row)">重置密码</el-button>
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="460px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="Boolean(editingId)" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item v-if="!editingId" label="初始密码">
<el-input v-model="form.password" placeholder="留空使用默认密码" />
</el-form-item>
<el-form-item label="班级">
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"jsx": "preserve",
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const BACKEND_ORIGIN = process.env.CLASSHELPER_BACKEND ?? 'http://127.0.0.1:4000';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '127.0.0.1',
port: 5173,
// 开发期把 /api 与 /socket.io 代理到后端,避免跨域配置
proxy: {
'/api': { target: BACKEND_ORIGIN, changeOrigin: true },
'/socket.io': { target: BACKEND_ORIGIN, changeOrigin: true, ws: true },
},
},
build: {
outDir: 'dist',
sourcemap: false,
chunkSizeWarningLimit: 2000,
},
});
+4832
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
packages:
- 'packages/*'
# pnpm 10+/11 默认拦截依赖的构建脚本(postinstall 等)。
# 这里显式允许本项目的原生/引擎类依赖执行构建脚本,否则 install 会以
# ERR_PNPM_IGNORED_BUILDS 失败,且后续任何 `pnpm exec` 都会连带失败。
allowBuilds:
prisma: true
'@prisma/engines': true
'@prisma/client': true
esbuild: true
electron: true
vue-demi: true
'@parcel/watcher': true
unrs-resolver: true
# 上游新版本发布后立即安装可能带来风险,这里保留默认解析行为。
# 若要锁定 Node 版本或开启更严格的 peer 校验,可在此追加:
# strictPeerDependencies: false
# 不因为一次失败的依赖校验就中断后续命令。
# 依赖安装请显式执行 pnpm install(见 README)。
verifyDepsBeforeRun: false
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more