feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -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
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
release
|
||||
packages/server/src/generated
|
||||
pnpm-lock.yaml
|
||||
.cache
|
||||
.pnpm-store
|
||||
.probe
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 110,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"htmlWhitespaceSensitivity": "ignore"
|
||||
}
|
||||
@@ -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 流水线。
|
||||
@@ -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` 之外移除。
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } } },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 {};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @classhelper/shared 入口。
|
||||
* 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。
|
||||
*/
|
||||
export * from './constants.js';
|
||||
export * from './utils.js';
|
||||
export type * from './types.js';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2023"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 = '班级小助手 · 管理端';
|
||||
Vendored
+12
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+4832
File diff suppressed because it is too large.
Load diff
@@ -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
Reference in new issue
Block a user